@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.
Files changed (90) hide show
  1. package/README.md +134 -40
  2. package/config.example.json +21 -8
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +73 -15
  5. package/deploy/install.mjs +166 -101
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +1638 -202
  14. package/dist/index.mjs.map +4 -4
  15. package/docs/TOOLS.md +192 -1
  16. package/docs/changelog/v0/v0.8.0.md +115 -0
  17. package/docs/deploy-architecture.md +13 -11
  18. package/package.json +7 -7
  19. package/src/availability.ts +42 -0
  20. package/src/catalog.ts +18 -1
  21. package/src/config.ts +29 -2
  22. package/src/router.ts +43 -2
  23. package/src/secrets.ts +34 -4
  24. package/src/types.ts +25 -1
  25. package/src/verbs/ghcr/archive.ts +314 -0
  26. package/src/verbs/ghcr/config.ts +142 -0
  27. package/src/verbs/ghcr/jobs.ts +141 -0
  28. package/src/verbs/ghcr/push.ts +192 -0
  29. package/src/verbs/ghcr/registry.ts +275 -0
  30. package/src/verbs/ghcr/tar.ts +203 -0
  31. package/src/verbs/ghcr_push.ts +264 -0
  32. package/src/verbs/npm-registry.ts +168 -0
  33. package/src/verbs/npm-tarball.ts +76 -0
  34. package/src/verbs/npm_scaffold.ts +325 -0
  35. package/src/verbs/release-analysis.ts +3 -1
  36. package/tests/catalog.test.vitest.mts +55 -1
  37. package/tests/config-merge.test.vitest.mts +275 -8
  38. package/tests/config.test.vitest.mts +75 -1
  39. package/tests/credentials.test.vitest.mts +127 -0
  40. package/tests/ghcr-archive.test.vitest.mts +439 -0
  41. package/tests/ghcr-config.test.vitest.mts +96 -0
  42. package/tests/ghcr-push.test.vitest.mts +526 -0
  43. package/tests/ghcr-registry.test.vitest.mts +206 -0
  44. package/tests/helpers/ghcr-fixtures.mts +373 -0
  45. package/tests/install-args.test.vitest.mts +152 -0
  46. package/tests/issue-labels.test.vitest.mts +15 -0
  47. package/tests/npm-scaffold.test.vitest.mts +479 -0
  48. package/tests/npm-setup.test.vitest.mts +196 -0
  49. package/tests/paths.test.vitest.mts +5 -0
  50. package/tests/prompt.test.vitest.mts +128 -0
  51. package/tests/release-prs.test.vitest.mts +10 -0
  52. package/tests/router.test.vitest.mts +108 -2
  53. package/tests/secret-store.test.vitest.mts +64 -3
  54. package/tests/secrets.test.vitest.mts +53 -0
  55. package/tests/security-alerts.test.vitest.mts +10 -0
  56. package/tests/server.test.vitest.mts +187 -7
  57. package/tests/smoke.test.vitest.mts +177 -0
  58. package/tests/system.test.vitest.mts +355 -0
  59. package/tests/verb-helpers.test.vitest.mts +46 -0
  60. package/.configs/fix-headers.json +0 -3
  61. package/.configs/vitest.config.mjs +0 -44
  62. package/.github/dependabot.yml +0 -111
  63. package/.github/workflows/branch-retention.yml +0 -44
  64. package/.github/workflows/bundle-size.yml +0 -52
  65. package/.github/workflows/ci.yml +0 -389
  66. package/.github/workflows/cla.yml +0 -66
  67. package/.github/workflows/codeql.yml +0 -87
  68. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  69. package/.github/workflows/dependabot-recreate.yml +0 -67
  70. package/.github/workflows/dependency-review.yml +0 -46
  71. package/.github/workflows/feature-pr.yml +0 -75
  72. package/.github/workflows/hotfix-redirector.yml +0 -67
  73. package/.github/workflows/hotfixes-release.yml +0 -70
  74. package/.github/workflows/labeler.yml +0 -48
  75. package/.github/workflows/master-commit-audit.yml +0 -56
  76. package/.github/workflows/member-auto-merge.yml +0 -75
  77. package/.github/workflows/next-release.yml +0 -76
  78. package/.github/workflows/next-reset.yml +0 -50
  79. package/.github/workflows/pr-notify.yml +0 -44
  80. package/.github/workflows/pr-title-normalizer.yml +0 -52
  81. package/.github/workflows/provenance.yml +0 -51
  82. package/.github/workflows/publish.yml +0 -146
  83. package/.github/workflows/release-merge.yml +0 -104
  84. package/.github/workflows/release-notify.yml +0 -41
  85. package/.github/workflows/scorecard.yml +0 -64
  86. package/.github/workflows/stale.yml +0 -50
  87. package/.github/workflows/tag-health.yml +0 -70
  88. package/.github/workflows/update-major-version-tags.yml +0 -94
  89. package/.github/workflows/v4-bootstrap.yml +0 -115
  90. package/.github/workflows/welcome.yml +0 -42
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseArgs, envSecretKey } from "../deploy/lib/install-args.mjs";
3
+
4
+ describe("parseArgs", () => {
5
+ it("defaults", () => {
6
+ expect(parseArgs([])).toEqual({ positional: [], nonInteractive: false, createAgentUser: false, npm: false, npmScopes: [], npmOrgs: [], cliSecrets: {} });
7
+ });
8
+
9
+ it("reads every flag", () => {
10
+ const o = parseArgs([
11
+ "repo",
12
+ "-h",
13
+ "-y",
14
+ "--create-agent-user",
15
+ "--agent-user",
16
+ "agent",
17
+ "--secrets-provider",
18
+ "file",
19
+ "--secrets-file",
20
+ "/x/s.json",
21
+ "--npm",
22
+ "--npm-scope",
23
+ "@cldmv",
24
+ "--npm-scope",
25
+ "@other",
26
+ "--npm-org",
27
+ "cldmv",
28
+ "--npm-token-days",
29
+ "30"
30
+ ]);
31
+ expect(o).toEqual({
32
+ positional: ["repo"],
33
+ help: true,
34
+ nonInteractive: true,
35
+ createAgentUser: true,
36
+ agentUser: "agent",
37
+ provider: "file",
38
+ secretsFile: "/x/s.json",
39
+ npm: true,
40
+ npmScopes: ["@cldmv", "@other"],
41
+ npmOrgs: ["cldmv"],
42
+ npmTokenDays: 30,
43
+ cliSecrets: {}
44
+ });
45
+ expect(parseArgs(["--help", "--non-interactive"])).toMatchObject({ help: true, nonInteractive: true });
46
+ });
47
+
48
+ it("parses every config flag", () => {
49
+ const o = parseArgs([
50
+ "--config", "/etc/x.json",
51
+ "--owner", "acme", "--owner", "beta",
52
+ "--installation-id", "acme=42",
53
+ "--default-owner", "acme",
54
+ "--listen", "unix:/run/x.sock",
55
+ "--upstream", "https://example.test/mcp",
56
+ "--app-id", "77",
57
+ "--app-private-key", "cred:my-key",
58
+ "--secrets-path", "/etc/s.json",
59
+ "--npm", "--npm-registry", "https://registry.example.test",
60
+ "--ghcr-owner", "acme", "--ghcr-username", "Me-1",
61
+ "--ghcr-image", "app", "--ghcr-image", "app-*", "--ghcr-image", "team/svc",
62
+ "--ghcr-drop-dir", "/srv/x/drop",
63
+ "--ghcr-max-archive-bytes", "1048576",
64
+ "--no-protect-release-tags"
65
+ ]);
66
+ expect(o).toMatchObject({
67
+ configFile: "/etc/x.json",
68
+ owners: ["acme", "beta"],
69
+ installationIds: { acme: 42 },
70
+ defaultOwner: "acme",
71
+ listen: "unix:/run/x.sock",
72
+ upstream: "https://example.test/mcp",
73
+ appId: 77,
74
+ appPrivateKey: "my-key",
75
+ secretsPath: "/etc/s.json",
76
+ npmRegistry: "https://registry.example.test",
77
+ ghcrOwner: "acme",
78
+ ghcrUsername: "Me-1",
79
+ ghcrImages: ["app", "app-*", "team/svc"],
80
+ ghcrDropDir: "/srv/x/drop",
81
+ ghcrMaxArchiveBytes: 1048576,
82
+ protectReleaseTags: false
83
+ });
84
+ expect(parseArgs(["--protect-release-tags"]).protectReleaseTags).toBe(true);
85
+ expect(parseArgs(["--app-id", "5"]).appPrivateKey).toBeUndefined();
86
+ });
87
+
88
+ it("leaves the config fields unset when no config flag is given", () => {
89
+ const o = parseArgs(["--agent-user", "a"]);
90
+ for (const k of ["configFile", "owners", "defaultOwner", "listen", "upstream", "appId", "ghcrOwner", "protectReleaseTags"]) expect(o[k]).toBeUndefined();
91
+ });
92
+
93
+ it("collects --secret name=token pairs, keeping any = inside the token", () => {
94
+ const o = parseArgs(["--secret", "cldmv-read=ghp_abc", "--secret", "npm-token=npm_x=y==", "--secret", "cldmv-read=ghp_new"]);
95
+ expect(o.cliSecrets).toEqual({ "cldmv-read": "ghp_new", "npm-token": "npm_x=y==" });
96
+ });
97
+
98
+ it.each([
99
+ [["--secret"], /--secret requires a value/],
100
+ [["--secret", "novalue"], /--secret takes <cred-name>=<token>/],
101
+ [["--secret", "=tok"], /--secret takes <cred-name>=<token>/],
102
+ [["--secret", "name="], /--secret takes <cred-name>=<token>/],
103
+ [["--secret", "../x=tok"], /unsafe credential name/],
104
+ [["--agent-user", "--secrets-provider", "file"], /--agent-user requires a value/],
105
+ [["--secrets-provider", "vault"], /--secrets-provider must be "systemd-creds" or "file"/],
106
+ [["--npm-scope", "cldmv"], /--npm-scope must be a lowercase npm scope/],
107
+ [["--npm-org", "@cldmv"], /--npm-org must be a lowercase npm org name/],
108
+ [["--npm-token-days", "0"], /--npm-token-days must be a whole number of days/],
109
+ [["--npm-token-days", "1.5"], /--npm-token-days must be a whole number of days/],
110
+ [["--config"], /--config requires a value/],
111
+ [["--config", "--owner"], /--config requires a value/],
112
+ [["--owner", "Acme"], /--owner must be a lowercase GitHub owner name/],
113
+ [["--owner", "-x"], /--owner requires a value/],
114
+ [["--installation-id", "acme"], /--installation-id takes <owner>=<numeric id>/],
115
+ [["--installation-id", "Acme=1"], /--installation-id takes/],
116
+ [["--installation-id", "acme=0"], /--installation-id takes/],
117
+ [["--installation-id", "=5"], /--installation-id takes/],
118
+ [["--default-owner", "A_B"], /--default-owner must be a lowercase GitHub owner name/],
119
+ [["--listen", "/run/x.sock"], /--listen must be a "unix:<absolute path>"/],
120
+ [["--upstream", "ftp://x"], /--upstream must be an http\(s\) URL/],
121
+ [["--app-id", "abc"], /--app-id must be the numeric GitHub App id/],
122
+ [["--app-private-key", "k"], /--app-private-key needs --app-id/],
123
+ [["--app-id", "1", "--app-private-key", "../k"], /unsafe credential name/],
124
+ [["--secrets-path", "rel.json"], /--secrets-path must be an absolute path/],
125
+ [["--npm-registry", "https://r"], /--npm-registry only applies together with --npm/],
126
+ [["--npm", "--npm-registry", "http://r"], /--npm-registry must be an https URL/],
127
+ [["--ghcr-owner", "Acme"], /--ghcr-owner must be a lowercase GitHub owner name/],
128
+ [["--ghcr-owner", "acme"], /--ghcr-owner needs --ghcr-username and at least one --ghcr-image/],
129
+ [["--ghcr-owner", "acme", "--ghcr-username", "me"], /--ghcr-owner needs --ghcr-username/],
130
+ [["--ghcr-owner", "acme", "--ghcr-image", "app"], /--ghcr-owner needs --ghcr-username/],
131
+ [["--ghcr-username", "me"], /need --ghcr-owner/],
132
+ [["--ghcr-image", "app"], /need --ghcr-owner/],
133
+ [["--ghcr-username", "-bad"], /--ghcr-username requires a value/],
134
+ [["--ghcr-username", "bad_name"], /--ghcr-username must be a GitHub login/],
135
+ [["--ghcr-image", "Bad/IMG"], /--ghcr-image must be a lowercase image name/],
136
+ [["--ghcr-image", "a".repeat(256)], /--ghcr-image must be a lowercase image name/],
137
+ [["--ghcr-drop-dir", "drop"], /--ghcr-drop-dir must be an absolute path/],
138
+ [["--ghcr-max-archive-bytes", "0"], /--ghcr-max-archive-bytes must be a positive whole number/],
139
+ [["--ghcr-max-archive-bytes", "9999999999999999"], /--ghcr-max-archive-bytes must be a positive whole number/],
140
+ [["--ghcr-max-archive-bytes", "1.5"], /--ghcr-max-archive-bytes must be a positive whole number/],
141
+ [["--bogus"], /unknown flag: --bogus/]
142
+ ] as Array<[string[], RegExp]>)("refuses %j", (argv, msg) => {
143
+ expect(() => parseArgs(argv)).toThrow(msg);
144
+ });
145
+ });
146
+
147
+ describe("envSecretKey", () => {
148
+ it("upper-cases and replaces non-alphanumerics", () => {
149
+ expect(envSecretKey("npm-token")).toBe("GH_BROKER_SECRET_NPM_TOKEN");
150
+ expect(envSecretKey("cldmv.read")).toBe("GH_BROKER_SECRET_CLDMV_READ");
151
+ });
152
+ });
@@ -245,6 +245,21 @@ describe("issueLabels", () => {
245
245
  expect(gh.issue).toEqual(["type: bug"]);
246
246
  });
247
247
 
248
+ it("reports a set as applied when only building the result fails", async () => {
249
+ // The PUT succeeds but answers with a label that has no name, which the result builder cannot lowercase.
250
+ const g = fakeGitHub(REPO, ["type: bug"]);
251
+ const inner = g.request.getMockImplementation()!;
252
+ g.request.mockImplementation(async (route: string, p: any) => (route.startsWith("PUT") ? { data: [{}] } : inner(route, p)));
253
+ const err = await issueLabels(g.octokit, { ...base, set: ["type: bug"] }).catch((e) => e);
254
+ expect(err.message).toMatch(/failed part-way on cldmv\/gh-broker#117: .*Applied: set \[type: bug\]\. Not applied: nothing\.$/);
255
+ });
256
+
257
+ it("treats label lists that are not arrays as empty", async () => {
258
+ const request = vi.fn(async (route: string) => (route.startsWith("POST /repos/{owner}/{repo}/issues") ? { data: null } : route.startsWith("GET /repos/{owner}/{repo}/issues") ? { data: {} } : { data: { name: "x" } }));
259
+ const out = await issueLabels({ request } as any, { ...base, add: "x" });
260
+ expect(out.labels).toEqual([]);
261
+ });
262
+
248
263
  it("a failing first read or label lookup writes nothing and is passed through unchanged", async () => {
249
264
  gh = fakeGitHub(REPO, ["type: bug"], { fail: (route) => (route === "GET /repos/{owner}/{repo}/labels/{name}" ? httpError(500, "Server Error") : undefined) });
250
265
  await expect(issueLabels(gh.octokit, { ...base, add: "area: auth" })).rejects.toThrow(/^Server Error$/);
@@ -0,0 +1,479 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { gunzipSync } from "node:zlib";
3
+ import { createHash } from "node:crypto";
4
+ import { npmScaffold, placeholderDocument, NPM_SCAFFOLD_SCHEMA, PLACEHOLDER_VERSION, SCAFFOLD_MARKER } from "../src/verbs/npm_scaffold.ts";
5
+ import { NpmRegistry, NpmOtpRequired, NpmHttpError, escapePackageName, type FetchLike } from "../src/verbs/npm-registry.ts";
6
+ import { buildTarball, tarballDigests } from "../src/verbs/npm-tarball.ts";
7
+
8
+ const TOKEN = "npm_SECRETTOKENVALUE";
9
+ const OTP = "123456";
10
+ const OTP2 = "654321";
11
+ const PKG = "@cldmv/new-thing";
12
+
13
+ /** Read the ustar entries back out of a gzipped tarball. */
14
+ function untar(tgz: Buffer): Record<string, string> {
15
+ const tar = gunzipSync(tgz);
16
+ const out: Record<string, string> = {};
17
+ for (let off = 0; off + 512 <= tar.length; ) {
18
+ const head = tar.subarray(off, off + 512);
19
+ if (head.every((b) => b === 0)) break;
20
+ const name = head.subarray(0, 100).toString("ascii").replace(/\0.*$/, "");
21
+ const size = parseInt(head.subarray(124, 136).toString("ascii").replace(/\0.*$/, ""), 8);
22
+ const stored = parseInt(head.subarray(148, 156).toString("ascii").replace(/[\0 ].*$/, ""), 8);
23
+ const copy = Buffer.from(head);
24
+ copy.fill(0x20, 148, 156);
25
+ expect(copy.reduce((s, b) => s + b, 0)).toBe(stored);
26
+ out[name] = tar.subarray(off + 512, off + 512 + size).toString("utf8");
27
+ off += 512 + Math.ceil(size / 512) * 512;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ type RegistryOpts = {
33
+ existing?: Record<string, unknown>;
34
+ /** Codes the registry accepts; each works once. */
35
+ otps?: string[];
36
+ trustStatus?: number;
37
+ publishStatus?: number;
38
+ lookupStatus?: number;
39
+ /** Trusted publishers already on the package: a POST then answers 409. */
40
+ existingTrust?: any[];
41
+ /** GET of the trust list wants an OTP (it is behind otplease in the npm CLI). */
42
+ trustListNeedsOtp?: boolean;
43
+ /** POST-trust reply body instead of the created configs (e.g. one without an id). */
44
+ trustCreatedBody?: string;
45
+ /** Raw GET-trust reply instead of the list. */
46
+ trustListReply?: { status: number; body: string };
47
+ };
48
+
49
+ function fakeRegistry(opts: RegistryOpts = {}) {
50
+ const packages = new Map<string, any>(Object.entries(opts.existing ?? {}));
51
+ const valid = [...(opts.otps ?? [OTP])];
52
+ const trust: any[] = [...(opts.existingTrust ?? [])];
53
+ const calls: Array<{ method: string; url: string; headers: Record<string, string>; body?: any }> = [];
54
+ const otpRefused = () => ({ status: 401, headers: { get: (n: string) => (n === "www-authenticate" ? "OTP" : null) }, text: async () => '{"error":"You must provide a one-time pass."}' });
55
+ const reply = (status: number, body: unknown) => ({ status, headers: { get: () => null }, text: async () => (typeof body === "string" ? body : JSON.stringify(body)) });
56
+ const fetch = vi.fn<FetchLike>(async (url, init) => {
57
+ calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: JSON.parse(init.body) } : {}) });
58
+ expect(init.headers.authorization).toBe(`Bearer ${TOKEN}`);
59
+ const path = new URL(url).pathname;
60
+ const useOtp = () => {
61
+ const code = init.headers["npm-otp"];
62
+ const at = code ? valid.indexOf(code) : -1;
63
+ if (at === -1) return false;
64
+ valid.splice(at, 1);
65
+ return true;
66
+ };
67
+ if (init.method === "GET" && path === `/${escapePackageName(PKG)}`) {
68
+ // Read from the primary, not a cache, as npm does before a write.
69
+ expect(new URL(url).searchParams.get("write")).toBe("true");
70
+ if (opts.lookupStatus) return reply(opts.lookupStatus, { error: "boom" });
71
+ const p = packages.get(PKG);
72
+ return p ? reply(200, p) : reply(404, { error: "Not found" });
73
+ }
74
+ if (init.method === "PUT" && path === `/${escapePackageName(PKG)}`) {
75
+ if (!useOtp()) return otpRefused();
76
+ if (opts.publishStatus) return reply(opts.publishStatus, { error: "You do not have permission to publish" });
77
+ const doc = JSON.parse(init.body!);
78
+ packages.set(PKG, { name: doc.name, versions: doc.versions, "dist-tags": doc["dist-tags"] });
79
+ return reply(200, { ok: true });
80
+ }
81
+ if (init.method === "POST" && path === `/-/package/${escapePackageName(PKG)}/trust`) {
82
+ if (!useOtp()) return otpRefused();
83
+ if (opts.trustStatus) return reply(opts.trustStatus, { error: "bad request" });
84
+ if (trust.length) return reply(409, { error: "a trusted publisher already exists" });
85
+ const body = JSON.parse(init.body!);
86
+ trust.push(...body);
87
+ if (opts.trustCreatedBody !== undefined) return reply(201, opts.trustCreatedBody);
88
+ return reply(201, body.map((b: any, i: number) => ({ ...b, id: `trust-${i + 1}` })));
89
+ }
90
+ if (init.method === "GET" && path === `/-/package/${escapePackageName(PKG)}/trust`) {
91
+ if (opts.trustListNeedsOtp && !useOtp()) return otpRefused();
92
+ if (opts.trustListReply) return reply(opts.trustListReply.status, opts.trustListReply.body);
93
+ return reply(200, trust);
94
+ }
95
+ throw new Error(`unexpected ${init.method} ${url}`);
96
+ });
97
+ return { registry: new NpmRegistry({ token: TOKEN, fetch }), fetch, calls, packages, trust };
98
+ }
99
+
100
+ type RepoOpts = { private?: boolean; archived?: boolean; pkg?: unknown; pkgRaw?: string; workflow?: boolean; missing?: boolean; pkgIsDir?: boolean; plainEncoding?: boolean; bareRepo?: boolean };
101
+
102
+ function fakeGitHub(o: RepoOpts = {}) {
103
+ const notFound = () => Object.assign(new Error("Not Found"), { status: 404 });
104
+ const file = (s: string) => (o.plainEncoding ? { data: { type: "file", encoding: "utf-8", content: s } } : { data: { type: "file", encoding: "base64", content: Buffer.from(s).toString("base64") } });
105
+ const request = vi.fn(async (route: string, p: any) => {
106
+ if (route === "GET /repos/{owner}/{repo}") {
107
+ if (o.missing) throw notFound();
108
+ if (o.bareRepo) return { data: { private: false } };
109
+ return { data: { full_name: "CLDMV/new-thing", private: o.private ?? false, archived: o.archived ?? false, default_branch: "master" } };
110
+ }
111
+ if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
112
+ expect(p.ref).toBe(o.bareRepo ? "HEAD" : "master");
113
+ if (p.path === "package.json") {
114
+ if (o.pkgIsDir) return { data: [{ type: "file", name: "x" }] };
115
+ if (o.pkg === undefined && o.pkgRaw === undefined) return file(JSON.stringify({ name: PKG, license: "Apache-2.0", description: "A new thing." }));
116
+ if (o.pkg === null) throw notFound();
117
+ return file(o.pkgRaw ?? JSON.stringify(o.pkg));
118
+ }
119
+ if (p.path === ".github/workflows/publish.yml" || p.path === ".github/workflows/release.yaml") {
120
+ if (o.workflow === false) throw notFound();
121
+ return file("name: publish\n");
122
+ }
123
+ throw notFound();
124
+ }
125
+ throw new Error(`unexpected route ${route}`);
126
+ });
127
+ return { octokit: { request } as any, request };
128
+ }
129
+
130
+ const args = (extra: Record<string, unknown> = {}) => ({ owner: "cldmv", repo: "new-thing", package: PKG, otp: OTP, ...extra });
131
+ const deps = (gh: ReturnType<typeof fakeGitHub>, reg: ReturnType<typeof fakeRegistry>, scopes = ["@cldmv"]) => ({ octokit: gh.octokit, registry: reg.registry, scopes });
132
+
133
+ describe("buildTarball", () => {
134
+ it("writes package/-prefixed ustar entries with valid checksums, gzipped", () => {
135
+ const files = untar(buildTarball([{ path: "package.json", content: '{"name":"x"}' }, { path: "README.md", content: "# x\n".repeat(200) }]));
136
+ expect(Object.keys(files)).toEqual(["package/package.json", "package/README.md"]);
137
+ expect(files["package/package.json"]).toBe('{"name":"x"}');
138
+ expect(files["package/README.md"]).toBe("# x\n".repeat(200));
139
+ });
140
+
141
+ it("is reproducible (fixed mtime)", () => {
142
+ const f = [{ path: "a.txt", content: "a" }];
143
+ expect(buildTarball(f).equals(buildTarball(f))).toBe(true);
144
+ });
145
+
146
+ it("refuses paths it cannot store", () => {
147
+ expect(() => buildTarball([{ path: "x".repeat(100), content: "" }])).toThrow(/unsupported path/);
148
+ expect(() => buildTarball([{ path: "naïve.txt", content: "" }])).toThrow(/unsupported path/);
149
+ });
150
+
151
+ it("computes the sha512 integrity and sha1 shasum of the tarball", () => {
152
+ const tgz = buildTarball([{ path: "a", content: "a" }]);
153
+ const d = tarballDigests(tgz);
154
+ expect(d.integrity).toBe(`sha512-${createHash("sha512").update(tgz).digest("base64")}`);
155
+ expect(d.shasum).toBe(createHash("sha1").update(tgz).digest("hex"));
156
+ });
157
+ });
158
+
159
+ describe("placeholderDocument", () => {
160
+ it("builds the first-publish document with a marked placeholder manifest and matching tarball", () => {
161
+ const doc = placeholderDocument(PKG, { repository: "CLDMV/new-thing", isPrivate: false, license: "Apache-2.0" }, "public", "https://registry.npmjs.org");
162
+ expect(doc["dist-tags"]).toEqual({ latest: PLACEHOLDER_VERSION });
163
+ expect(doc.access).toBe("public");
164
+ const v = doc.versions[PLACEHOLDER_VERSION]!;
165
+ expect(v.scaffoldedBy).toBe(SCAFFOLD_MARKER);
166
+ expect(v.license).toBe("Apache-2.0");
167
+ expect(v.repository).toEqual({ type: "git", url: "git+https://github.com/CLDMV/new-thing.git" });
168
+ expect(v.dist.tarball).toBe(`https://registry.npmjs.org/${PKG}/-/new-thing-${PLACEHOLDER_VERSION}.tgz`);
169
+ const att = doc._attachments[`${PKG}-${PLACEHOLDER_VERSION}.tgz`]!;
170
+ const tgz = Buffer.from(att.data, "base64");
171
+ expect(att.length).toBe(tgz.length);
172
+ expect(v.dist.integrity).toBe(tarballDigests(tgz).integrity);
173
+ const files = untar(tgz);
174
+ expect(JSON.parse(files["package/package.json"]!)).toMatchObject({ name: PKG, version: PLACEHOLDER_VERSION, scaffoldedBy: SCAFFOLD_MARKER });
175
+ expect(files["package/README.md"]).toContain("https://github.com/CLDMV/new-thing");
176
+ // Only the two broker-built files: nothing else ships.
177
+ expect(Object.keys(files)).toEqual(["package/package.json", "package/README.md"]);
178
+ });
179
+
180
+ it("falls back to UNLICENSED when the repo names no license", () => {
181
+ const doc = placeholderDocument(PKG, { repository: "CLDMV/new-thing", isPrivate: true }, "restricted", "https://registry.npmjs.org");
182
+ expect(doc.versions[PLACEHOLDER_VERSION]!.license).toBe("UNLICENSED");
183
+ expect(doc.access).toBe("restricted");
184
+ });
185
+ });
186
+
187
+ describe("npm_scaffold", () => {
188
+ it("advertises a strict schema", () => {
189
+ expect(NPM_SCAFFOLD_SCHEMA.required).toEqual(["owner", "repo", "package", "otp"]);
190
+ expect(NPM_SCAFFOLD_SCHEMA.additionalProperties).toBe(false);
191
+ });
192
+
193
+ it("publishes the placeholder, then adds the trusted publisher on a second call with the next code", async () => {
194
+ const reg = fakeRegistry({ otps: [OTP, OTP2] });
195
+ const first = await npmScaffold(args(), deps(fakeGitHub(), reg));
196
+ expect(first).toMatchObject({ status: "needs_otp", done: ["publish"], remaining: ["trust"], access: "public", repository: "CLDMV/new-thing", version: PLACEHOLDER_VERSION });
197
+ expect(first.message).toMatch(/needs its own one-time password/);
198
+ // The spent code is never sent again: no trust request on the first call.
199
+ expect(reg.calls.filter((c) => c.url.endsWith("/trust"))).toHaveLength(0);
200
+ const put = reg.calls.find((c) => c.method === "PUT")!;
201
+ expect(put.headers["npm-otp"]).toBe(OTP);
202
+ expect(put.body.access).toBe("public");
203
+
204
+ const second = await npmScaffold(args({ otp: OTP2 }), deps(fakeGitHub(), reg));
205
+ expect(second).toMatchObject({ status: "done", done: ["trust"], skipped: ["publish"], workflow: "publish.yml", trustId: "trust-1" });
206
+ expect(second.url).toBe(`https://www.npmjs.com/package/${PKG}`);
207
+ expect(reg.trust).toEqual([{ type: "github", claims: { repository: "CLDMV/new-thing", workflow_ref: { file: "publish.yml" } }, permissions: ["createPackage"] }]);
208
+ expect(reg.calls.find((c) => c.url.endsWith("/trust"))!.headers["npm-otp"]).toBe(OTP2);
209
+ expect(reg.calls.filter((c) => c.method === "PUT")).toHaveLength(1);
210
+ });
211
+
212
+ it("uses restricted access for a private repo", async () => {
213
+ const reg = fakeRegistry({ otps: [OTP] });
214
+ const out = await npmScaffold(args(), deps(fakeGitHub({ private: true }), reg));
215
+ expect(out.access).toBe("restricted");
216
+ expect(reg.calls.find((c) => c.method === "PUT")!.body.access).toBe("restricted");
217
+ });
218
+
219
+ it("returns needs_otp when the trust code is refused, and finishes with a new one", async () => {
220
+ const reg = fakeRegistry({ otps: [OTP, OTP2] });
221
+ await npmScaffold(args(), deps(fakeGitHub(), reg));
222
+ const refused = await npmScaffold(args({ otp: "999999" }), deps(fakeGitHub(), reg));
223
+ expect(refused).toMatchObject({ status: "needs_otp", done: [], skipped: ["publish"], remaining: ["trust"] });
224
+ expect(refused.message).toMatch(/refused the one-time password for the trusted publisher/);
225
+ const done = await npmScaffold(args({ otp: OTP2 }), deps(fakeGitHub(), reg));
226
+ expect(done).toMatchObject({ status: "done", done: ["trust"] });
227
+ });
228
+
229
+ it("returns needs_otp with nothing written when the code is refused before publishing", async () => {
230
+ const reg = fakeRegistry({ otps: [] });
231
+ const out = await npmScaffold(args(), deps(fakeGitHub(), reg));
232
+ expect(out).toMatchObject({ status: "needs_otp", done: [], remaining: ["publish", "trust"] });
233
+ expect(reg.packages.size).toBe(0);
234
+ });
235
+
236
+ const placeholderFor = (repository: string) => ({ name: PKG, versions: placeholderDocument(PKG, { repository, isPrivate: false }, "public", "https://registry.npmjs.org").versions });
237
+ const trustFor = (repository: string, file = "publish.yml") => ({ id: "t-old", type: "github", claims: { repository, workflow_ref: { file } }, permissions: ["createPackage"] });
238
+
239
+ it("treats the same trusted publisher already being configured as done", async () => {
240
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("cldmv/NEW-thing")] });
241
+ const out = await npmScaffold(args(), deps(fakeGitHub(), reg));
242
+ expect(out).toMatchObject({ status: "done", done: [], skipped: ["publish", "trust"], trustId: "t-old" });
243
+ // The list was read without sending the code again.
244
+ expect(reg.calls.find((c) => c.method === "GET" && c.url.endsWith("/trust"))!.headers["npm-otp"]).toBeUndefined();
245
+ });
246
+
247
+ it.each([
248
+ ["another repo", trustFor("CLDMV/other")],
249
+ ["another workflow", trustFor("CLDMV/new-thing", "release.yml")],
250
+ ["another provider", { ...trustFor("CLDMV/new-thing"), type: "gitlab" }]
251
+ ])("refuses when a different trusted publisher exists (%s)", async (_, existing) => {
252
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [existing] });
253
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already has a different trusted publisher: .*Change it at https:\/\/www\.npmjs\.com\/package\/@cldmv\/new-thing\/access/);
254
+ });
255
+
256
+ it("describes an existing trust entry that has no claims", async () => {
257
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [{ id: "t", type: "github" }] });
258
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/different trusted publisher: github undefined \(undefined\)/);
259
+ });
260
+
261
+ it("finishes without a trust id when npm's reply carries none", async () => {
262
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, trustCreatedBody: "[{}]" });
263
+ const out = await npmScaffold(args(), deps(fakeGitHub(), reg));
264
+ expect(out).toMatchObject({ status: "done", done: ["trust"] });
265
+ expect(out).not.toHaveProperty("trustId");
266
+ });
267
+
268
+ it("refuses when npm wants another code to show the existing trusted publisher", async () => {
269
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("CLDMV/new-thing")], trustListNeedsOtp: true });
270
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already has a trusted publisher, and npm needs another one-time password to show which/);
271
+ });
272
+
273
+ it("passes a trust-list failure other than an OTP request through", async () => {
274
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("CLDMV/new-thing")], trustListReply: { status: 500, body: "" } });
275
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/trusted publisher lookup failed \(HTTP 500\)/);
276
+ });
277
+
278
+ it("names an unreadable existing configuration rather than failing obscurely", async () => {
279
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("x")], trustListReply: { status: 200, body: "[]" } });
280
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already has a different trusted publisher: unreadable/);
281
+ });
282
+
283
+ it("refuses to add a trusted publisher for a different repo than the placeholder was made for", async () => {
284
+ const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/other-repo") } });
285
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/placeholder was published for git\+https:\/\/github\.com\/CLDMV\/other-repo\.git, not CLDMV\/new-thing/);
286
+ expect(reg.calls.some((c) => c.url.endsWith("/trust"))).toBe(false);
287
+ });
288
+
289
+ it("refuses a placeholder with no recorded repository", async () => {
290
+ const doc = placeholderFor("CLDMV/new-thing");
291
+ delete (doc.versions[PLACEHOLDER_VERSION] as any).repository;
292
+ await expect(npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ existing: { [PKG]: doc } })))).rejects.toThrow(/published for an unrecorded repository/);
293
+ });
294
+
295
+ it("refuses a repo that GitHub redirects to another owner or name", async () => {
296
+ const gh = fakeGitHub();
297
+ const real = gh.request.getMockImplementation()!;
298
+ gh.request.mockImplementation(async (r: string, p: any) => (r === "GET /repos/{owner}/{repo}" ? { data: { full_name: "someone/new-thing", private: false, default_branch: "master" } } : real(r, p)));
299
+ const reg = fakeRegistry();
300
+ await expect(npmScaffold(args(), deps(gh, reg))).rejects.toThrow(/cldmv\/new-thing redirects to someone\/new-thing \(renamed or transferred\)/);
301
+ expect(reg.fetch).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it("refuses a package that already exists and was not created by npm_scaffold", async () => {
305
+ const reg = fakeRegistry({ existing: { [PKG]: { name: PKG, versions: { "1.0.0": {}, "1.1.0": {} } } } });
306
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already exists on the registry \(2 version\(s\)\) and was not created by npm_scaffold/);
307
+ expect(reg.calls.every((c) => c.method === "GET")).toBe(true);
308
+ });
309
+
310
+ it("refuses an existing single 0.0.0-stage version that lacks the scaffold marker", async () => {
311
+ const reg = fakeRegistry({ existing: { [PKG]: { name: PKG, versions: { [PLACEHOLDER_VERSION]: { name: PKG } } } } });
312
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/was not created by npm_scaffold/);
313
+ });
314
+
315
+ it("refuses a scope that is not configured, before any request", async () => {
316
+ const gh = fakeGitHub();
317
+ const reg = fakeRegistry();
318
+ await expect(npmScaffold(args({ package: "@other/new-thing" }), deps(gh, reg))).rejects.toThrow(/scope @other is not in config\.npm\.scopes/);
319
+ expect(gh.request).not.toHaveBeenCalled();
320
+ expect(reg.fetch).not.toHaveBeenCalled();
321
+ });
322
+
323
+ it.each([
324
+ [{ missing: true }, /repository cldmv\/new-thing not found/],
325
+ [{ archived: true }, /is archived/],
326
+ [{ pkg: null }, /has no package\.json on master/],
327
+ [{ pkgRaw: "{nope" }, /is not valid JSON/],
328
+ [{ pkg: { name: "@cldmv/other" } }, /names "@cldmv\/other", not "@cldmv\/new-thing"/],
329
+ [{ pkg: { name: PKG, private: true } }, /marked "private": true/],
330
+ [{ workflow: false }, /has no \.github\/workflows\/publish\.yml/]
331
+ ] as Array<[RepoOpts, RegExp]>)("refuses when the repo does not match (%o) without touching npm", async (repo, msg) => {
332
+ const reg = fakeRegistry();
333
+ await expect(npmScaffold(args(), deps(fakeGitHub(repo), reg))).rejects.toThrow(msg);
334
+ expect(reg.fetch).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it("treats a package.json that is not a file as missing", async () => {
338
+ await expect(npmScaffold(args(), deps(fakeGitHub({ pkgIsDir: true }), fakeRegistry()))).rejects.toThrow(/has no package\.json/);
339
+ });
340
+
341
+ it("reads repo files that are not base64-encoded", async () => {
342
+ const out = await npmScaffold(args(), deps(fakeGitHub({ plainEncoding: true }), fakeRegistry()));
343
+ expect(out).toMatchObject({ status: "needs_otp", done: ["publish"] });
344
+ });
345
+
346
+ it("falls back to owner/repo and HEAD when GitHub omits full_name and default_branch, and to no license or description", async () => {
347
+ const reg = fakeRegistry();
348
+ const gh = fakeGitHub({ bareRepo: true, pkg: { name: PKG } });
349
+ const out = await npmScaffold(args(), deps(gh, reg));
350
+ expect(out.repository).toBe("cldmv/new-thing");
351
+ const v = reg.calls.find((c) => c.method === "PUT")!.body.versions[PLACEHOLDER_VERSION];
352
+ expect(v.license).toBe("UNLICENSED");
353
+ expect(v.readme).not.toContain("undefined");
354
+ });
355
+
356
+ it("refuses an existing package document that lists no versions", async () => {
357
+ const reg = fakeRegistry({ existing: { [PKG]: { name: PKG } } });
358
+ await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/\(0 version\(s\)\) and was not created by npm_scaffold/);
359
+ });
360
+
361
+ it("honours a custom workflow file name", async () => {
362
+ const reg = fakeRegistry({ otps: [OTP, OTP2] });
363
+ await npmScaffold(args({ workflow: "release.yaml" }), deps(fakeGitHub(), reg));
364
+ const out = await npmScaffold(args({ workflow: "release.yaml", otp: OTP2 }), deps(fakeGitHub(), reg));
365
+ expect(out.workflow).toBe("release.yaml");
366
+ expect(reg.trust[0].claims.workflow_ref.file).toBe("release.yaml");
367
+ });
368
+
369
+ it.each([
370
+ [null, /arguments must be an object/],
371
+ [{ ...args(), extra: 1 }, /unknown argument\(s\): extra/],
372
+ [{ ...args(), owner: "bad/owner" }, /"owner"/],
373
+ [{ ...args(), repo: "../x" }, /"repo"/],
374
+ [{ ...args(), package: "unscoped" }, /"package"/],
375
+ [{ ...args(), package: "@CLDMV/Upper" }, /"package"/],
376
+ [{ ...args(), otp: "12ab56" }, /"otp"/],
377
+ [{ ...args(), otp: 123456 }, /"otp"/],
378
+ [{ ...args(), workflow: "../publish.yml" }, /"workflow"/],
379
+ [{ ...args(), workflow: "publish.sh" }, /"workflow"/]
380
+ ] as Array<[unknown, RegExp]>)("validates arguments strictly (%#)", async (input, msg) => {
381
+ const gh = fakeGitHub();
382
+ await expect(npmScaffold(input, deps(gh, fakeRegistry()))).rejects.toThrow(msg);
383
+ expect(gh.request).not.toHaveBeenCalled();
384
+ });
385
+
386
+ it("never puts the OTP or the token in a result or an error", async () => {
387
+ const ok = await npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ otps: [OTP] })));
388
+ const needs = await npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ otps: [] })));
389
+ const errors: string[] = [];
390
+ for (const reg of [fakeRegistry({ publishStatus: 403 }), fakeRegistry({ lookupStatus: 500 })]) {
391
+ await npmScaffold(args(), deps(fakeGitHub(), reg)).catch((e: Error) => errors.push(e.message));
392
+ }
393
+ await npmScaffold({ ...args(), otp: "12345x" }, deps(fakeGitHub(), fakeRegistry())).catch((e: Error) => errors.push(e.message));
394
+ expect(errors).toHaveLength(3);
395
+ for (const text of [JSON.stringify(ok), JSON.stringify(needs), ...errors]) {
396
+ expect(text).not.toContain(OTP);
397
+ expect(text).not.toContain("12345x");
398
+ expect(text).not.toContain(TOKEN);
399
+ }
400
+ });
401
+
402
+ it("passes GitHub errors other than 404 through unchanged", async () => {
403
+ const boom = Object.assign(new Error("rate limited"), { status: 403 });
404
+ for (const route of ["GET /repos/{owner}/{repo}", "GET /repos/{owner}/{repo}/contents/{path}"]) {
405
+ const gh = fakeGitHub();
406
+ const real = gh.request.getMockImplementation()!;
407
+ gh.request.mockImplementation(async (r: string, p: any) => {
408
+ if (r === route) throw boom;
409
+ return real(r, p);
410
+ });
411
+ await expect(npmScaffold(args(), deps(gh, fakeRegistry()))).rejects.toBe(boom);
412
+ }
413
+ });
414
+
415
+ it("passes a trust failure that is not an OTP refusal through, after the publish", async () => {
416
+ const reg = fakeRegistry({ otps: [OTP, OTP2], trustStatus: 400 });
417
+ await npmScaffold(args(), deps(fakeGitHub(), reg));
418
+ await expect(npmScaffold(args({ otp: OTP2 }), deps(fakeGitHub(), reg))).rejects.toThrow(/trusted publisher setup failed \(HTTP 400\)/);
419
+ expect(reg.packages.has(PKG)).toBe(true);
420
+ });
421
+
422
+ it("surfaces a registry refusal as an HTTP error with npm's message", async () => {
423
+ await expect(npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ publishStatus: 403 })))).rejects.toThrow(/npm: publish failed \(HTTP 403\): You do not have permission to publish/);
424
+ });
425
+ });
426
+
427
+ describe("NpmRegistry", () => {
428
+ const res = (status: number, body: string, headers: Record<string, string> = {}) => ({ status, headers: { get: (n: string) => headers[n] ?? null }, text: async () => body });
429
+
430
+ it("detects an OTP refusal from the body when the header does not say so", async () => {
431
+ const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(401, "This operation requires a one-time password") });
432
+ await expect(reg.publish({}, PKG, OTP)).rejects.toBeInstanceOf(NpmOtpRequired);
433
+ });
434
+
435
+ it("treats a plain 401 as an HTTP error, not an OTP request", async () => {
436
+ const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(401, '{"error":"bad token"}') });
437
+ await expect(reg.publish({}, PKG, OTP)).rejects.toBeInstanceOf(NpmHttpError);
438
+ });
439
+
440
+ it("trims a trailing slash from the registry and reports a non-JSON error body", async () => {
441
+ const seen: string[] = [];
442
+ const reg = new NpmRegistry({ token: TOKEN, registry: "https://npm.example.test/", fetch: async (url) => (seen.push(url), res(502, "Bad Gateway")) });
443
+ await expect(reg.packument(PKG)).rejects.toThrow(/package lookup failed \(HTTP 502\): Bad Gateway/);
444
+ expect(seen).toEqual([`https://npm.example.test/${escapePackageName(PKG)}?write=true`]);
445
+ expect(reg.registry).toBe("https://npm.example.test");
446
+ });
447
+
448
+ it("reads a trust list that is a single object, and refuses one that is not JSON", async () => {
449
+ const one = new NpmRegistry({ token: TOKEN, fetch: async () => res(200, '{"id":"t1","type":"github"}') });
450
+ await expect(one.listTrust(PKG, OTP)).resolves.toEqual([{ id: "t1", type: "github" }]);
451
+ const bad = new NpmRegistry({ token: TOKEN, fetch: async () => res(200, "<html>") });
452
+ await expect(bad.listTrust(PKG)).rejects.toThrow(/trusted publisher lookup failed \(HTTP 200\): response is not JSON/);
453
+ });
454
+
455
+ it("accepts a trust response that is not JSON", async () => {
456
+ const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(204, "") });
457
+ await expect(reg.addGithubTrust(PKG, { repository: "CLDMV/x", file: "publish.yml" }, OTP)).resolves.toEqual({ conflict: false });
458
+ });
459
+
460
+ it("reports an HTTP error with an empty body by status alone", async () => {
461
+ const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(500, "") });
462
+ await expect(reg.packument(PKG)).rejects.toThrow(/^npm: package lookup failed \(HTTP 500\)$/);
463
+ });
464
+
465
+ it("takes the trust config from an object response, and none from an empty or non-object one", async () => {
466
+ const claims = { repository: "CLDMV/x", file: "publish.yml" };
467
+ const one = new NpmRegistry({ token: TOKEN, fetch: async () => res(201, '{"id":"t-9","type":"github"}') });
468
+ await expect(one.addGithubTrust(PKG, claims, OTP)).resolves.toEqual({ config: { id: "t-9", type: "github" }, conflict: false });
469
+ for (const body of ["[]", '"ok"']) {
470
+ const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(201, body) });
471
+ await expect(reg.addGithubTrust(PKG, claims, OTP)).resolves.toEqual({ config: undefined, conflict: false });
472
+ }
473
+ });
474
+
475
+ it("fails a trust setup the registry rejects", async () => {
476
+ const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(400, '{"message":"workflow not found"}') });
477
+ await expect(reg.addGithubTrust(PKG, { repository: "CLDMV/x", file: "publish.yml" }, OTP)).rejects.toThrow(/trusted publisher setup failed \(HTTP 400\): workflow not found/);
478
+ });
479
+ });