@cldmv/gh-broker 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -40
- package/config.example.json +21 -8
- package/config.sample.json +45 -0
- package/deploy/README.md +73 -15
- package/deploy/install.mjs +166 -101
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +1638 -202
- package/dist/index.mjs.map +4 -4
- package/docs/TOOLS.md +192 -1
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +13 -11
- package/package.json +7 -7
- package/src/availability.ts +42 -0
- package/src/catalog.ts +18 -1
- package/src/config.ts +29 -2
- package/src/router.ts +43 -2
- package/src/secrets.ts +34 -4
- package/src/types.ts +25 -1
- package/src/verbs/ghcr/archive.ts +314 -0
- package/src/verbs/ghcr/config.ts +142 -0
- package/src/verbs/ghcr/jobs.ts +141 -0
- package/src/verbs/ghcr/push.ts +192 -0
- package/src/verbs/ghcr/registry.ts +275 -0
- package/src/verbs/ghcr/tar.ts +203 -0
- package/src/verbs/ghcr_push.ts +264 -0
- package/src/verbs/npm-registry.ts +168 -0
- package/src/verbs/npm-tarball.ts +76 -0
- package/src/verbs/npm_scaffold.ts +325 -0
- package/src/verbs/release-analysis.ts +3 -1
- package/tests/catalog.test.vitest.mts +55 -1
- package/tests/config-merge.test.vitest.mts +275 -8
- package/tests/config.test.vitest.mts +75 -1
- package/tests/credentials.test.vitest.mts +127 -0
- package/tests/ghcr-archive.test.vitest.mts +439 -0
- package/tests/ghcr-config.test.vitest.mts +96 -0
- package/tests/ghcr-push.test.vitest.mts +526 -0
- package/tests/ghcr-registry.test.vitest.mts +206 -0
- package/tests/helpers/ghcr-fixtures.mts +373 -0
- package/tests/install-args.test.vitest.mts +152 -0
- package/tests/issue-labels.test.vitest.mts +15 -0
- package/tests/npm-scaffold.test.vitest.mts +479 -0
- package/tests/npm-setup.test.vitest.mts +196 -0
- package/tests/paths.test.vitest.mts +5 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +10 -0
- package/tests/router.test.vitest.mts +108 -2
- package/tests/secret-store.test.vitest.mts +64 -3
- package/tests/secrets.test.vitest.mts +53 -0
- package/tests/security-alerts.test.vitest.mts +10 -0
- package/tests/server.test.vitest.mts +187 -7
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/verb-helpers.test.vitest.mts +46 -0
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/verbs/npm_scaffold.ts
|
|
5
|
+
* @Date: 2026-10-31T10:57:49-07:00 (1793469469)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-31T12:58:19-07:00 (1793476699)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Octokit } from "@octokit/core";
|
|
17
|
+
import { NpmOtpRequired, type NpmRegistry } from "./npm-registry.ts";
|
|
18
|
+
import { buildTarball, tarballDigests } from "./npm-tarball.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Custom verb: npm_scaffold — create a new npm package and register its GitHub Actions
|
|
22
|
+
* trusted publisher, so the repo's own publish workflow can release it from then on.
|
|
23
|
+
*
|
|
24
|
+
* Why it exists: npm only accepts a trusted publisher on a package that already exists, and
|
|
25
|
+
* the first publish needs a human's 2FA. The broker holds a granular access token created
|
|
26
|
+
* WITHOUT 2FA bypass (by the installer), and the user supplies a one-time password for each
|
|
27
|
+
* call, which the agent passes through. The token alone can do nothing here.
|
|
28
|
+
*
|
|
29
|
+
* Guardrails, all checked before any npm write:
|
|
30
|
+
* - the package is under a scope listed in config.npm.scopes;
|
|
31
|
+
* - the repo belongs to the routed owner, is not archived, its package.json on the default
|
|
32
|
+
* branch names this package, and the publish workflow file exists there;
|
|
33
|
+
* - the package does not exist yet, or exists only as this verb's own placeholder (resume).
|
|
34
|
+
* An existing real package is refused, so the verb can never publish a version of one.
|
|
35
|
+
*
|
|
36
|
+
* The placeholder is built here from fixed fields (no agent-supplied files): a package.json
|
|
37
|
+
* marked `scaffoldedBy: "gh-broker"` and a README pointing at the repo. Access follows the
|
|
38
|
+
* repo's visibility (public repo → public package), the rule the CI publish also uses.
|
|
39
|
+
*
|
|
40
|
+
* Every npm write needs its own one-time password, so each call makes exactly one write:
|
|
41
|
+
* the first call publishes the placeholder and returns `needs_otp` for the trusted
|
|
42
|
+
* publisher; the second call (with the next code) adds it. A refused or expired code is
|
|
43
|
+
* not an error either: the result is `needs_otp` naming what is left.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export const PLACEHOLDER_VERSION = "0.0.0-stage";
|
|
47
|
+
export const SCAFFOLD_MARKER = "gh-broker";
|
|
48
|
+
const DEFAULT_WORKFLOW = "publish.yml";
|
|
49
|
+
const INPUT_KEYS = new Set(["owner", "repo", "package", "otp", "workflow"]);
|
|
50
|
+
const PACKAGE_NAME = /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/;
|
|
51
|
+
|
|
52
|
+
export interface NpmScaffoldInput {
|
|
53
|
+
owner: string;
|
|
54
|
+
repo: string;
|
|
55
|
+
/** Scoped package name, e.g. "@cldmv/new-thing". */
|
|
56
|
+
package: string;
|
|
57
|
+
/** One-time password from the user's authenticator. */
|
|
58
|
+
otp: string;
|
|
59
|
+
/** The repo's publish workflow file name. Defaults to publish.yml. */
|
|
60
|
+
workflow?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const NPM_SCAFFOLD_SCHEMA = {
|
|
64
|
+
type: "object" as const,
|
|
65
|
+
properties: {
|
|
66
|
+
owner: { type: "string", description: "Repository owner (a configured owner)." },
|
|
67
|
+
repo: { type: "string", description: "Repository whose package.json names the package." },
|
|
68
|
+
package: { type: "string", description: 'Scoped package name, e.g. "@cldmv/new-thing". Must be under a scope in config.npm.scopes.' },
|
|
69
|
+
otp: { type: "string", description: "One-time password the user read from their authenticator. Ask the user for it right before calling; codes expire in about 30 seconds." },
|
|
70
|
+
workflow: { type: "string", description: 'The repo\'s publish workflow file in .github/workflows. Default "publish.yml".' }
|
|
71
|
+
},
|
|
72
|
+
required: ["owner", "repo", "package", "otp"],
|
|
73
|
+
additionalProperties: false
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type NpmScaffoldStep = "publish" | "trust";
|
|
77
|
+
|
|
78
|
+
export interface NpmScaffoldResult {
|
|
79
|
+
status: "done" | "needs_otp";
|
|
80
|
+
package: string;
|
|
81
|
+
repository: string;
|
|
82
|
+
workflow: string;
|
|
83
|
+
access: "public" | "restricted";
|
|
84
|
+
/** Steps this call completed. */
|
|
85
|
+
done: NpmScaffoldStep[];
|
|
86
|
+
/** Steps still to do: only set with needs_otp. */
|
|
87
|
+
remaining?: NpmScaffoldStep[];
|
|
88
|
+
/** Steps skipped because an earlier call already completed them. */
|
|
89
|
+
skipped?: NpmScaffoldStep[];
|
|
90
|
+
version: string;
|
|
91
|
+
url: string;
|
|
92
|
+
trustId?: string;
|
|
93
|
+
message: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface NpmScaffoldDeps {
|
|
97
|
+
/** Read access to the repo (contents + metadata). */
|
|
98
|
+
octokit: Octokit;
|
|
99
|
+
registry: NpmRegistry;
|
|
100
|
+
scopes: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fail = (msg: string): never => {
|
|
104
|
+
throw new Error(`npm_scaffold: ${msg}`);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function validate(input: unknown): Required<NpmScaffoldInput> {
|
|
108
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) fail("arguments must be an object");
|
|
109
|
+
const a = input as Record<string, unknown>;
|
|
110
|
+
const unknown = Object.keys(a).filter((k) => !INPUT_KEYS.has(k));
|
|
111
|
+
if (unknown.length) fail(`unknown argument(s): ${unknown.join(", ")} — expected ${[...INPUT_KEYS].join(", ")}`);
|
|
112
|
+
if (typeof a.owner !== "string" || !/^[A-Za-z0-9-]+$/.test(a.owner)) fail('"owner" must be a GitHub owner name');
|
|
113
|
+
if (typeof a.repo !== "string" || !/^[A-Za-z0-9._-]+$/.test(a.repo)) fail('"repo" must be a repository name');
|
|
114
|
+
if (typeof a.package !== "string" || a.package.length > 214 || !PACKAGE_NAME.test(a.package)) fail('"package" must be a lowercase scoped npm package name, e.g. "@cldmv/new-thing"');
|
|
115
|
+
// Never echo the value: it is a live credential.
|
|
116
|
+
if (typeof a.otp !== "string" || !/^[0-9]{6,8}$/.test(a.otp)) fail('"otp" must be the 6-8 digit code from the authenticator');
|
|
117
|
+
if (a.workflow !== undefined && (typeof a.workflow !== "string" || !/^[A-Za-z0-9._-]+\.ya?ml$/.test(a.workflow))) {
|
|
118
|
+
fail('"workflow" must be a workflow file name in .github/workflows, e.g. "publish.yml"');
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
owner: a.owner as string,
|
|
122
|
+
repo: a.repo as string,
|
|
123
|
+
package: a.package as string,
|
|
124
|
+
otp: a.otp as string,
|
|
125
|
+
workflow: (a.workflow as string | undefined) ?? DEFAULT_WORKFLOW
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function statusOf(err: unknown): number | undefined {
|
|
130
|
+
return (err as { status?: number })?.status;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function repoFile(octokit: Octokit, owner: string, repo: string, path: string, ref: string): Promise<string | undefined> {
|
|
134
|
+
try {
|
|
135
|
+
const res = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref });
|
|
136
|
+
const d = res.data as { content?: string; encoding?: string; type?: string };
|
|
137
|
+
if (d.type !== "file" || typeof d.content !== "string") return undefined;
|
|
138
|
+
return d.encoding === "base64" ? Buffer.from(d.content, "base64").toString("utf8") : d.content;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (statusOf(err) === 404) return undefined;
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface RepoFacts {
|
|
146
|
+
repository: string;
|
|
147
|
+
isPrivate: boolean;
|
|
148
|
+
license?: string;
|
|
149
|
+
description?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function checkRepo(octokit: Octokit, args: Required<NpmScaffoldInput>): Promise<RepoFacts> {
|
|
153
|
+
let info: { full_name?: string; private?: boolean; archived?: boolean; default_branch?: string };
|
|
154
|
+
try {
|
|
155
|
+
info = (await octokit.request("GET /repos/{owner}/{repo}", { owner: args.owner, repo: args.repo })).data as typeof info;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (statusOf(err) === 404) fail(`repository ${args.owner}/${args.repo} not found`);
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
const asked = `${args.owner}/${args.repo}`;
|
|
161
|
+
const repository = info.full_name ?? asked;
|
|
162
|
+
// GitHub answers a renamed or transferred repo with a redirect, which the client follows.
|
|
163
|
+
// The trusted publisher must name the repo that was routed and checked, so refuse that.
|
|
164
|
+
if (repository.toLowerCase() !== asked.toLowerCase()) fail(`${asked} redirects to ${repository} (renamed or transferred) — call with the repository's current owner and name`);
|
|
165
|
+
if (info.archived) fail(`repository ${repository} is archived`);
|
|
166
|
+
const ref = info.default_branch ?? "HEAD";
|
|
167
|
+
const raw = await repoFile(octokit, args.owner, args.repo, "package.json", ref);
|
|
168
|
+
if (raw === undefined) fail(`${repository} has no package.json on ${ref}`);
|
|
169
|
+
let pkg: { name?: unknown; license?: unknown; description?: unknown; private?: unknown };
|
|
170
|
+
try {
|
|
171
|
+
pkg = JSON.parse(raw as string) as typeof pkg;
|
|
172
|
+
} catch {
|
|
173
|
+
return fail(`${repository}'s package.json on ${ref} is not valid JSON`);
|
|
174
|
+
}
|
|
175
|
+
if (pkg.name !== args.package) fail(`${repository}'s package.json on ${ref} names ${JSON.stringify(pkg.name)}, not "${args.package}"`);
|
|
176
|
+
if (pkg.private === true) fail(`${repository}'s package.json is marked "private": true, so it is not meant to be published`);
|
|
177
|
+
if ((await repoFile(octokit, args.owner, args.repo, `.github/workflows/${args.workflow}`, ref)) === undefined) {
|
|
178
|
+
fail(`${repository} has no .github/workflows/${args.workflow} on ${ref} — the trusted publisher must name the workflow that publishes`);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
repository,
|
|
182
|
+
isPrivate: info.private !== false,
|
|
183
|
+
...(typeof pkg.license === "string" ? { license: pkg.license } : {}),
|
|
184
|
+
...(typeof pkg.description === "string" ? { description: pkg.description } : {})
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The registry document for the placeholder's first publish (the shape libnpmpublish sends). */
|
|
189
|
+
export function placeholderDocument(name: string, facts: RepoFacts, access: "public" | "restricted", registry: string) {
|
|
190
|
+
const repoUrl = `https://github.com/${facts.repository}`;
|
|
191
|
+
const manifest = {
|
|
192
|
+
name,
|
|
193
|
+
version: PLACEHOLDER_VERSION,
|
|
194
|
+
description: `Placeholder for ${name}. Releases are published from ${repoUrl} by CI.`,
|
|
195
|
+
license: facts.license ?? "UNLICENSED",
|
|
196
|
+
repository: { type: "git", url: `git+${repoUrl}.git` },
|
|
197
|
+
homepage: `${repoUrl}#readme`,
|
|
198
|
+
scaffoldedBy: SCAFFOLD_MARKER
|
|
199
|
+
};
|
|
200
|
+
const readme = `# ${name}\n\nThis version is a placeholder that reserves the package name so the repository's release workflow can publish it with npm trusted publishing.\n\n${facts.description ? `${facts.description}\n\n` : ""}Source and real releases: ${repoUrl}\n`;
|
|
201
|
+
const tarball = buildTarball([
|
|
202
|
+
{ path: "package.json", content: JSON.stringify(manifest, null, "\t") + "\n" },
|
|
203
|
+
{ path: "README.md", content: readme }
|
|
204
|
+
]);
|
|
205
|
+
const { integrity, shasum } = tarballDigests(tarball);
|
|
206
|
+
const tarballName = `${name}-${PLACEHOLDER_VERSION}.tgz`;
|
|
207
|
+
const unscoped = name.split("/")[1]!;
|
|
208
|
+
return {
|
|
209
|
+
_id: name,
|
|
210
|
+
name,
|
|
211
|
+
description: manifest.description,
|
|
212
|
+
"dist-tags": { latest: PLACEHOLDER_VERSION },
|
|
213
|
+
access,
|
|
214
|
+
versions: {
|
|
215
|
+
[PLACEHOLDER_VERSION]: {
|
|
216
|
+
...manifest,
|
|
217
|
+
readme,
|
|
218
|
+
_id: `${name}@${PLACEHOLDER_VERSION}`,
|
|
219
|
+
dist: { integrity, shasum, tarball: `${registry}/${name}/-/${unscoped}-${PLACEHOLDER_VERSION}.tgz` }
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
_attachments: { [tarballName]: { content_type: "application/octet-stream", data: tarball.toString("base64"), length: tarball.length } }
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function npmScaffold(input: unknown, deps: NpmScaffoldDeps): Promise<NpmScaffoldResult> {
|
|
227
|
+
const args = validate(input);
|
|
228
|
+
const scope = args.package.split("/")[0]!;
|
|
229
|
+
if (!deps.scopes.includes(scope)) fail(`scope ${scope} is not in config.npm.scopes (${deps.scopes.join(", ")})`);
|
|
230
|
+
|
|
231
|
+
const facts = await checkRepo(deps.octokit, args);
|
|
232
|
+
const access = facts.isPrivate ? "restricted" : "public";
|
|
233
|
+
const base = {
|
|
234
|
+
package: args.package,
|
|
235
|
+
repository: facts.repository,
|
|
236
|
+
workflow: args.workflow,
|
|
237
|
+
access,
|
|
238
|
+
version: PLACEHOLDER_VERSION,
|
|
239
|
+
url: `https://www.npmjs.com/package/${args.package}`
|
|
240
|
+
} as const;
|
|
241
|
+
|
|
242
|
+
const existing = await deps.registry.packument(args.package);
|
|
243
|
+
if (!existing) {
|
|
244
|
+
try {
|
|
245
|
+
await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.otp);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err instanceof NpmOtpRequired) {
|
|
248
|
+
return { ...base, status: "needs_otp", done: [], remaining: ["publish", "trust"], message: "npm refused the one-time password before publishing; nothing was written. Call again with a fresh code." };
|
|
249
|
+
}
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
// That code is spent: every npm write needs its own one-time password, so don't
|
|
253
|
+
// send it again (a guaranteed refusal that counts as a failed 2FA attempt).
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
status: "needs_otp",
|
|
257
|
+
done: ["publish"],
|
|
258
|
+
remaining: ["trust"],
|
|
259
|
+
message: "The placeholder is published. Each npm write needs its own one-time password: call again with the next code (wait for the authenticator to change) to add the trusted publisher."
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The package exists: carry on only if it is this verb's own placeholder (a resumed call).
|
|
264
|
+
const versions = Object.keys(existing.versions ?? {});
|
|
265
|
+
const placeholder = existing.versions?.[PLACEHOLDER_VERSION];
|
|
266
|
+
const ours = versions.length === 1 && versions[0] === PLACEHOLDER_VERSION && placeholder?.scaffoldedBy === SCAFFOLD_MARKER;
|
|
267
|
+
if (!ours) fail(`${args.package} already exists on the registry (${versions.length} version(s)) and was not created by npm_scaffold — set up its trusted publisher on npmjs.com instead`);
|
|
268
|
+
// The placeholder records the repo it was made for; the trusted publisher may only go there.
|
|
269
|
+
const madeFor = (placeholder?.repository as { url?: unknown } | undefined)?.url;
|
|
270
|
+
if (typeof madeFor !== "string" || madeFor.toLowerCase() !== `git+https://github.com/${facts.repository}.git`.toLowerCase()) {
|
|
271
|
+
fail(`${args.package}'s placeholder was published for ${typeof madeFor === "string" ? madeFor : "an unrecorded repository"}, not ${facts.repository}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let out: Awaited<ReturnType<NpmRegistry["addGithubTrust"]>>;
|
|
275
|
+
try {
|
|
276
|
+
out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.otp);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof NpmOtpRequired) {
|
|
279
|
+
return {
|
|
280
|
+
...base,
|
|
281
|
+
status: "needs_otp",
|
|
282
|
+
done: [],
|
|
283
|
+
remaining: ["trust"],
|
|
284
|
+
skipped: ["publish"],
|
|
285
|
+
message: "The placeholder is published, but npm refused the one-time password for the trusted publisher (expired or already used). Call again with a new code to finish."
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let alreadyThere = false;
|
|
292
|
+
if (out.conflict) {
|
|
293
|
+
// npm already has a trusted publisher for this package. Only call it done if it is the
|
|
294
|
+
// one asked for. The code was spent on the POST, so the list may need another one.
|
|
295
|
+
const access = `https://www.npmjs.com/package/${args.package}/access`;
|
|
296
|
+
let configs: Awaited<ReturnType<NpmRegistry["listTrust"]>>;
|
|
297
|
+
try {
|
|
298
|
+
configs = await deps.registry.listTrust(args.package);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err instanceof NpmOtpRequired) fail(`${args.package} already has a trusted publisher, and npm needs another one-time password to show which; check it at ${access}`);
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
const claimsOf = (c: Record<string, unknown>) => (c.claims ?? {}) as { repository?: unknown; workflow_ref?: { file?: unknown } };
|
|
304
|
+
const matches = configs.find((c) => {
|
|
305
|
+
const cl = claimsOf(c);
|
|
306
|
+
return c.type === "github" && typeof cl.repository === "string" && cl.repository.toLowerCase() === facts.repository.toLowerCase() && cl.workflow_ref?.file === args.workflow;
|
|
307
|
+
});
|
|
308
|
+
if (!matches) {
|
|
309
|
+
const existingList = configs.map((c) => `${String(c.type)} ${String(claimsOf(c).repository)} (${String(claimsOf(c).workflow_ref?.file)})`).join("; ") || "unreadable";
|
|
310
|
+
fail(`${args.package} already has a different trusted publisher: ${existingList}. Change it at ${access}`);
|
|
311
|
+
}
|
|
312
|
+
alreadyThere = true;
|
|
313
|
+
out = { config: matches, conflict: true };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const trustId = typeof out.config?.id === "string" ? out.config.id : undefined;
|
|
317
|
+
return {
|
|
318
|
+
...base,
|
|
319
|
+
status: "done",
|
|
320
|
+
done: alreadyThere ? [] : ["trust"],
|
|
321
|
+
skipped: alreadyThere ? ["publish", "trust"] : ["publish"],
|
|
322
|
+
...(trustId ? { trustId } : {}),
|
|
323
|
+
message: `${args.package} exists with a trusted publisher for ${facts.repository} (.github/workflows/${args.workflow}); its next release can publish through OIDC.`
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
8
|
* -----
|
|
9
9
|
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-
|
|
10
|
+
* @Last modified time: 2026-10-31T23:26:36-07:00 (1793514396)
|
|
11
11
|
* -----
|
|
12
12
|
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
13
|
*
|
|
@@ -135,6 +135,8 @@ export function assessFreshness(version: string, changelog: string, commits: Rel
|
|
|
135
135
|
const missing = commits.filter((c) => !mentioned.has(c.number));
|
|
136
136
|
const missingUserFacing = missing.filter((c) => !INTERNAL_TYPES.has(c.type)).map(({ number, title }) => ({ number, title }));
|
|
137
137
|
const missingInternal = missing.filter((c) => INTERNAL_TYPES.has(c.type)).map(({ number, title }) => ({ number, title }));
|
|
138
|
+
// String.prototype.split always returns at least one element, so the "" fallback is unreachable; it only satisfies noUncheckedIndexedAccess.
|
|
139
|
+
/* v8 ignore next */
|
|
138
140
|
const firstLine = changelog.split("\n", 1)[0] ?? "";
|
|
139
141
|
const headingMatchesVersion = new RegExp(`v?${version.replace(/\./g, "\\.")}\\b`).test(firstLine);
|
|
140
142
|
const readmeMentionsVersion = readme !== undefined && new RegExp(`Latest:\\s*v?${version.replace(/\./g, "\\.")}\\b`).test(readme);
|
|
@@ -11,7 +11,7 @@ describe("buildCatalog", () => {
|
|
|
11
11
|
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
12
12
|
|
|
13
13
|
beforeEach(() => {
|
|
14
|
-
secrets = { resolve: vi.fn(() => "tok") };
|
|
14
|
+
secrets = { resolve: vi.fn(() => "tok"), has: vi.fn(() => true) };
|
|
15
15
|
upstream = { listToolsFor: vi.fn() };
|
|
16
16
|
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
17
17
|
});
|
|
@@ -74,6 +74,26 @@ describe("buildCatalog", () => {
|
|
|
74
74
|
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
it("does not expose npm_scaffold without an npm block, or when its token is not stored", async () => {
|
|
78
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
79
|
+
const rules = [{ name: "npm_scaffold", mode: "write", custom: true }];
|
|
80
|
+
expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
|
|
81
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: the npm integration is not configured/));
|
|
82
|
+
secrets.has.mockReturnValue(false);
|
|
83
|
+
const noToken = { ...makeConfig(rules), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } };
|
|
84
|
+
expect(await buildCatalog(noToken, secrets as any, upstream as any)).toEqual([]);
|
|
85
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: its credential "npm-token" is not stored/));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("advertises npm_scaffold with its local schema", async () => {
|
|
89
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
90
|
+
const catalog = await buildCatalog({ ...makeConfig([{ name: "npm_scaffold", mode: "write", custom: true }]), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } }, secrets as any, upstream as any);
|
|
91
|
+
expect(catalog).toHaveLength(1);
|
|
92
|
+
expect(catalog[0]).toMatchObject({ name: "npm_scaffold" });
|
|
93
|
+
expect(catalog[0]?.description).toMatch(/one-time password/);
|
|
94
|
+
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "package", "otp"] });
|
|
95
|
+
});
|
|
96
|
+
|
|
77
97
|
it("advertises issue_labels with its local schema", async () => {
|
|
78
98
|
upstream.listToolsFor.mockResolvedValue([]);
|
|
79
99
|
const config = makeConfig([{ name: "issue_labels", mode: "write", custom: true }]);
|
|
@@ -104,6 +124,12 @@ describe("buildCatalog", () => {
|
|
|
104
124
|
expect(without[0]?.description).toMatch(/pass the full set/);
|
|
105
125
|
});
|
|
106
126
|
|
|
127
|
+
it("warns on issue_write even when the upstream tool carries no description", async () => {
|
|
128
|
+
upstream.listToolsFor.mockResolvedValue([{ name: "issue_write", inputSchema: { type: "object" } }]);
|
|
129
|
+
const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
130
|
+
expect(catalog[0]?.description).toMatch(/^WARNING \(gh-broker\): "labels" REPLACES/);
|
|
131
|
+
});
|
|
132
|
+
|
|
107
133
|
it("still warns on issue_write when the upstream fetch fails (minimal schema)", async () => {
|
|
108
134
|
upstream.listToolsFor.mockRejectedValue(new Error("down"));
|
|
109
135
|
const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
@@ -177,4 +203,32 @@ describe("buildCatalog", () => {
|
|
|
177
203
|
expect(catalog.map((t) => t.name)).toEqual(["get_me"]);
|
|
178
204
|
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 15ms/));
|
|
179
205
|
});
|
|
206
|
+
|
|
207
|
+
it("advertises ghcr_push / ghcr_push_status / ghcr_tags with their local schemas", async () => {
|
|
208
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
209
|
+
const ghcrTools = [
|
|
210
|
+
{ name: "ghcr_push", mode: "write", custom: true },
|
|
211
|
+
{ name: "ghcr_push_status", mode: "read", custom: true },
|
|
212
|
+
{ name: "ghcr_tags", mode: "read", custom: true }
|
|
213
|
+
];
|
|
214
|
+
const ghcr = { owners: { cldmv: { credential: "cred:cldmv-packages" }, other: { credential: "cred:other-packages" } } };
|
|
215
|
+
const config = { ...makeConfig(ghcrTools), ghcr };
|
|
216
|
+
// Exposed as soon as any one GHCR owner's credential is stored.
|
|
217
|
+
secrets.has.mockImplementation((ref: string) => ref === "cred:other-packages");
|
|
218
|
+
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
219
|
+
expect(catalog.map((t) => t.name)).toEqual(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
|
|
220
|
+
expect(catalog[0]?.inputSchema).toMatchObject({ required: ["owner", "image", "tag", "archive"], additionalProperties: false });
|
|
221
|
+
expect(catalog[0]?.description).toMatch(/background job/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("does not expose the GHCR tools without config.ghcr, or while no GHCR credential is stored", async () => {
|
|
225
|
+
upstream.listToolsFor.mockResolvedValue([]);
|
|
226
|
+
const rules = [{ name: "ghcr_push", mode: "write", custom: true }];
|
|
227
|
+
expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
|
|
228
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"ghcr_push" is not exposed: GHCR pushes are not configured/));
|
|
229
|
+
secrets.has.mockReturnValue(false);
|
|
230
|
+
const config = { ...makeConfig(rules), ghcr: { owners: { cldmv: { credential: "cred:cldmv-packages" } } } };
|
|
231
|
+
expect(await buildCatalog(config, secrets as any, upstream as any)).toEqual([]);
|
|
232
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/no GHCR credential is stored \("cldmv-packages"; re-run the installer/));
|
|
233
|
+
});
|
|
180
234
|
});
|