package-registry-manager 0.22.6 → 0.22.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.22.6",
3
+ "version": "0.22.7",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "provenance": true
29
29
  },
30
30
  "scripts": {
31
- "syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs && node --check src/python.mjs && node --check src/setup-batch.mjs && node --check src/workflow-proposal.mjs",
31
+ "syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs && node --check src/python.mjs && node --check src/setup-batch.mjs && node --check src/workflow-proposal.mjs && node --check src/repository-identity.mjs && node --check src/repository-repair.mjs && node --check src/manifest-proposal.mjs",
32
32
  "lint": "eslint src test scripts --max-warnings 0",
33
33
  "lint:fix": "eslint src test scripts --fix --max-warnings 0",
34
34
  "format": "prettier --write .",
@@ -40,7 +40,7 @@
40
40
  "audit": "node scripts/npm-audit.mjs"
41
41
  },
42
42
  "dependencies": {
43
- "browser-commander": "^0.26.2",
43
+ "browser-commander": "^0.26.3",
44
44
  "command-stream": "^2.0.0",
45
45
  "playwright": "^1.63.0",
46
46
  "selenium-webdriver": "^4.50.0"
package/src/discovery.mjs CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { CONTAINER_FILES, containerPackages } from "./containers.mjs";
5
5
  import { packageWorkflows, auditWrappers } from "./package-coverage.mjs";
6
6
  import { REGISTRIES } from "./model.mjs";
7
+ import { inspectManifestRepositories } from "./repository-identity.mjs";
7
8
  import {
8
9
  TRUSTED_REGISTRIES,
9
10
  detectPublisher,
@@ -214,7 +215,7 @@ export async function inspectRepository(repository, options = {}) {
214
215
  left.manifest.localeCompare(right.manifest),
215
216
  );
216
217
  }
217
- return inspection;
218
+ return inspectManifestRepositories(inspection);
218
219
  }
219
220
 
220
221
  /**
@@ -0,0 +1,201 @@
1
+ import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { exec } from "command-stream";
5
+ import {
6
+ githubSlug,
7
+ manifestUrls,
8
+ repositorySlug,
9
+ } from "./repository-identity.mjs";
10
+
11
+ function runner(options) {
12
+ return (
13
+ options.run ??
14
+ (async (program, args, cwd = options.repository) => {
15
+ const result = await exec(program, args, {
16
+ cwd,
17
+ env: process.env,
18
+ capture: true,
19
+ mirror: false,
20
+ stdin: "ignore",
21
+ });
22
+ if (result.code !== 0) {
23
+ throw new Error(
24
+ `${program} failed while preparing the manifest repository proposal`,
25
+ );
26
+ }
27
+ return String(result.stdout ?? "").trim();
28
+ })
29
+ );
30
+ }
31
+
32
+ function safeManifest(manifest) {
33
+ if (
34
+ !manifest ||
35
+ manifest
36
+ .split("/")
37
+ .some((part) => !part || part === "." || part === "..") ||
38
+ path.isAbsolute(manifest) ||
39
+ manifest.includes("\\")
40
+ ) {
41
+ throw new Error("unsafe manifest path for repository repair");
42
+ }
43
+ }
44
+
45
+ /** Change only stale repository values; preserve npm's repository.directory. */
46
+ export function manifestRepositoryProposal(contents, manifest, slug) {
47
+ if (!githubSlug(slug)) {
48
+ throw new Error("invalid canonical repository");
49
+ }
50
+ const urls = manifestUrls(contents, manifest);
51
+ const stale = urls.filter(
52
+ (url) =>
53
+ githubSlug(url) && githubSlug(url).toLowerCase() !== slug.toLowerCase(),
54
+ );
55
+ if (!stale.length) {
56
+ return contents;
57
+ }
58
+ const url = `https://github.com/${slug}`;
59
+ if (manifest.endsWith("package.json")) {
60
+ const document = JSON.parse(contents.replace(/^\uFEFF/, ""));
61
+ if (typeof document.repository === "string") {
62
+ document.repository = url;
63
+ } else {
64
+ document.repository.url = `git+${url}.git`;
65
+ }
66
+ return `${JSON.stringify(document, null, 2)}\n`;
67
+ }
68
+ let proposal = contents;
69
+ for (const old of stale) {
70
+ proposal = proposal.replaceAll(old, old.replace(githubSlug(old), slug));
71
+ }
72
+ return proposal;
73
+ }
74
+
75
+ /** Check remote metadata before retrying; the original run may use an old SHA. */
76
+ export async function verifyRemoteManifest(plan, options, reference) {
77
+ safeManifest(plan.package.manifest);
78
+ const run = runner(options);
79
+ const slug = repositorySlug(plan.repository);
80
+ const ref =
81
+ reference ??
82
+ (await run("gh", ["api", `repos/${slug}`, "--jq", ".default_branch"]));
83
+ if (!ref || ref.startsWith("-") || /[\s:]/.test(ref)) {
84
+ throw new Error("invalid remote manifest reference");
85
+ }
86
+ await run("git", ["fetch", "origin", ref]);
87
+ const contents = await run("git", [
88
+ "show",
89
+ `FETCH_HEAD:${plan.package.manifest}`,
90
+ ]);
91
+ return (
92
+ manifestRepositoryProposal(contents, plan.package.manifest, slug) ===
93
+ contents
94
+ );
95
+ }
96
+
97
+ /** Offer a reviewed branch and draft PR from the current remote default branch. */
98
+ export async function offerManifestRepository(plan, options) {
99
+ const run = runner(options);
100
+ const slug = repositorySlug(plan.repository);
101
+ const manifest = plan.package.manifest;
102
+ safeManifest(manifest);
103
+ const remote = githubSlug(await run("git", ["remote", "get-url", "origin"]));
104
+ if (!remote) {
105
+ throw new Error(
106
+ "origin must be a GitHub repository for the manifest proposal",
107
+ );
108
+ }
109
+ const canonical = await run("gh", [
110
+ "api",
111
+ `repos/${remote}`,
112
+ "--jq",
113
+ ".full_name",
114
+ ]);
115
+ if (canonical.toLowerCase() !== slug.toLowerCase()) {
116
+ throw new Error("origin must resolve to the repository being repaired");
117
+ }
118
+ const base = await run("gh", [
119
+ "api",
120
+ `repos/${slug}`,
121
+ "--jq",
122
+ ".default_branch",
123
+ ]);
124
+ if (!base || base.startsWith("-") || /[\s:]/.test(base)) {
125
+ throw new Error("invalid default branch");
126
+ }
127
+ await run("git", ["fetch", "origin", base]);
128
+ const contents = await run("git", ["show", `FETCH_HEAD:${manifest}`]);
129
+ const proposal = manifestRepositoryProposal(contents, manifest, slug);
130
+ if (proposal === contents) {
131
+ return undefined;
132
+ }
133
+ console.log(`Proposed ${manifest}:\n${proposal}`);
134
+ if (
135
+ !options.yes &&
136
+ !/^y(?:es)?$/i.test(
137
+ await options.prompt(
138
+ "Create a branch and draft pull request with this repository URL fix? [y/N] ",
139
+ ),
140
+ )
141
+ ) {
142
+ return { status: "blocked" };
143
+ }
144
+ const branch = `prm/repository-url-${Date.now()}`;
145
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "prm-manifest-"));
146
+ const checkout = path.join(temporary, "checkout");
147
+ let created = false;
148
+ try {
149
+ await run("git", ["worktree", "add", "-b", branch, checkout, "FETCH_HEAD"]);
150
+ created = true;
151
+ const target = path.join(checkout, manifest);
152
+ const root = await realpath(checkout);
153
+ const physical = await realpath(target);
154
+ if (
155
+ !physical.startsWith(`${root}${path.sep}`) ||
156
+ physical !== path.join(root, manifest)
157
+ ) {
158
+ throw new Error(
159
+ "manifest must stay inside the proposal worktree without symlinks",
160
+ );
161
+ }
162
+ const current = await readFile(target, "utf8");
163
+ const updated = manifestRepositoryProposal(current, manifest, slug);
164
+ await writeFile(target, updated);
165
+ await run("git", ["add", "--", manifest], checkout);
166
+ await run(
167
+ "git",
168
+ ["commit", "-m", "Fix manifest repository URL after repository transfer"],
169
+ checkout,
170
+ );
171
+ await run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], checkout);
172
+ const url = await run(
173
+ "gh",
174
+ [
175
+ "pr",
176
+ "create",
177
+ "--repo",
178
+ slug,
179
+ "--base",
180
+ base,
181
+ "--head",
182
+ branch,
183
+ "--draft",
184
+ "--title",
185
+ "Fix manifest repository URL after repository transfer",
186
+ "--body",
187
+ `Update ${manifest} to name ${slug}. Review and merge this change, then re-run package-registry-manager setup to verify the repository metadata and retry the release.`,
188
+ ],
189
+ checkout,
190
+ );
191
+ console.log(
192
+ `Review and merge ${url}, then re-run setup. Release retries are paused.`,
193
+ );
194
+ return { status: "manifest-pr", url };
195
+ } finally {
196
+ if (created) {
197
+ await run("git", ["worktree", "remove", "--force", checkout]);
198
+ }
199
+ await rm(temporary, { recursive: true, force: true });
200
+ }
201
+ }
package/src/plan.mjs CHANGED
@@ -13,6 +13,7 @@ import { pagesSteps } from "./pages.mjs";
13
13
  import { planPrerequisites } from "./prerequisites.mjs";
14
14
  import { TRUSTED_REGISTRIES } from "./publishers.mjs";
15
15
  import { applyPython } from "./python.mjs";
16
+ import { repairSteps } from "./repository-repair.mjs";
16
17
 
17
18
  const FLOWS = new Map([
18
19
  ["npm", npmFlow],
@@ -193,6 +194,14 @@ function flowPlan(inspection, packageInfo, options) {
193
194
  steps = [];
194
195
  } else if (mode === "attach") {
195
196
  steps = steps.filter((item) => !BOOTSTRAP_CONDITIONS.has(item.when));
197
+ } else if (mode === "repair") {
198
+ steps = repairSteps(
199
+ packageInfo,
200
+ context,
201
+ packageInfo.exists_on_registry !== true
202
+ ? steps
203
+ : steps.filter((item) => !BOOTSTRAP_CONDITIONS.has(item.when)),
204
+ );
196
205
  }
197
206
  if (options.ref) {
198
207
  const ref = bootstrapRef(options.ref);
@@ -239,10 +248,17 @@ function flowPlan(inspection, packageInfo, options) {
239
248
 
240
249
  /**
241
250
  * Chooses `bootstrap` for a package missing from its registry, `attach` for
242
- * one without trusted publishing, and `complete` when nothing is left to do.
251
+ * one without trusted publishing, `repair` for stale or unverified repository
252
+ * identities, and `complete` when nothing is left to do.
243
253
  * Unknown registry state leaves the mode unset.
244
254
  */
245
255
  export function planMode(packageInfo) {
256
+ if (
257
+ packageInfo.repository_mismatches?.length ||
258
+ packageInfo.publisher_settings_verified === false
259
+ ) {
260
+ return "repair";
261
+ }
246
262
  if (packageInfo.exists_on_registry === false) {
247
263
  return "bootstrap";
248
264
  }
@@ -1,3 +1,13 @@
1
+ import {
2
+ compareRepositories,
3
+ identityCommand,
4
+ inspectManifestRepositories,
5
+ provenanceRepositories,
6
+ publisherIdentities,
7
+ publisherMatches,
8
+ resolveRepository,
9
+ } from "./repository-identity.mjs";
10
+
1
11
  /** Identifies the tool to registry APIs, as crates.io asks of clients. */
2
12
  export const USER_AGENT =
3
13
  "package-registry-manager (+https://github.com/link-foundation/package-registry-manager)";
@@ -63,6 +73,8 @@ export function npmName(name) {
63
73
  */
64
74
  export async function probeRegistryState(inspection, options = {}) {
65
75
  const probed = structuredClone(inspection);
76
+ await resolveRepository(probed, options);
77
+ await inspectManifestRepositories(probed);
66
78
  await Promise.all(
67
79
  probed.packages.map(async (packageInfo) => {
68
80
  if (!packageInfo.publishable) {
@@ -75,6 +87,46 @@ export async function probeRegistryState(inspection, options = {}) {
75
87
  if (state.trusted !== undefined) {
76
88
  packageInfo.trusted_publishing = state.trusted;
77
89
  }
90
+ const evidence = { ...packageInfo, ...state };
91
+ if (state.configured_publishers !== undefined) {
92
+ packageInfo.configured_publishers = state.configured_publishers;
93
+ }
94
+ if (state.provenance_repositories?.length) {
95
+ packageInfo.provenance_repositories = state.provenance_repositories;
96
+ }
97
+ if (packageInfo.registry === "npm" && state.exists) {
98
+ const output = await identityCommand(
99
+ "npm",
100
+ ["trust", "list", packageInfo.name, "--browser=false"],
101
+ { ...options, repository: inspection.repository.root },
102
+ );
103
+ if (output !== undefined) {
104
+ evidence.configured_publishers = publisherIdentities(output);
105
+ packageInfo.configured_publishers = evidence.configured_publishers;
106
+ }
107
+ }
108
+ compareRepositories(packageInfo, probed.repository, evidence);
109
+ if (
110
+ state.trusted &&
111
+ ["npm", "crates-io", "pypi"].includes(packageInfo.registry)
112
+ ) {
113
+ const expected = {
114
+ organization: probed.repository.github_owner,
115
+ repository: probed.repository.github_repository,
116
+ workflow: packageInfo.workflow,
117
+ environment: packageInfo.environment,
118
+ };
119
+ packageInfo.publisher_settings_verified = Boolean(
120
+ evidence.configured_publishers?.some((publisher) =>
121
+ publisherMatches(publisher, expected),
122
+ ),
123
+ );
124
+ if (!packageInfo.publisher_settings_verified) {
125
+ (packageInfo.warnings ??= []).push(
126
+ "configured trusted publisher could not be verified; setup must check the repository, workflow and environment in registry settings",
127
+ );
128
+ }
129
+ }
78
130
  }),
79
131
  );
80
132
  return probed;
@@ -100,21 +152,47 @@ export async function probePackage(packageInfo, options = {}) {
100
152
  : { exists: false, trusted: false };
101
153
  }
102
154
  switch (packageInfo.registry) {
103
- case "npm":
155
+ case "npm": {
156
+ const attestations = document.dist?.attestations?.url;
157
+ const provenance = attestations
158
+ ? await getJson(attestations, options)
159
+ : undefined;
160
+ const sources = provenanceRepositories(provenance);
104
161
  return {
105
162
  exists: true,
106
163
  trusted: Boolean(document._npmUser?.trustedPublisher),
107
164
  version: document.version,
165
+ ...(sources.length ? { provenance_repositories: sources } : {}),
108
166
  };
109
- case "crates-io":
167
+ }
168
+ case "crates-io": {
169
+ const latest =
170
+ (document.versions ?? []).find(
171
+ (item) => item.num === document.crate?.max_version,
172
+ ) ?? document.versions?.[0];
173
+ const settings = await getJson(
174
+ `${registryEndpoint("crates-io", env)}/trusted_publishing/github_configs?crate=${encodeURIComponent(packageInfo.name)}`,
175
+ options,
176
+ );
110
177
  return {
111
178
  exists: true,
112
- trusted: (document.versions ?? []).some((item) =>
113
- Boolean(item.trustpub_data),
179
+ trusted: Boolean(latest?.trustpub_data),
180
+ provenance_repositories: publisherIdentities(latest?.trustpub_data).map(
181
+ (publisher) => publisher.repository,
114
182
  ),
183
+ ...(Array.isArray(settings?.github_configs) && !settings.meta?.next_page
184
+ ? { configured_publishers: publisherIdentities(settings) }
185
+ : {}),
115
186
  };
116
- case "pypi":
117
- return { exists: true, trusted: await pypiProvenance(document, options) };
187
+ }
188
+ case "pypi": {
189
+ const provenance = await pypiProvenance(document, options);
190
+ return {
191
+ exists: true,
192
+ trusted: Boolean(provenance),
193
+ provenance_repositories: provenanceRepositories(provenance),
194
+ };
195
+ }
118
196
  default:
119
197
  return { exists: true };
120
198
  }
@@ -128,7 +206,7 @@ async function pypiProvenance(document, options) {
128
206
  }
129
207
  const base = registryEndpoint("pypi", options.env ?? process.env);
130
208
  const url = `${base}/integrity/${encodeURIComponent(document.info.name)}/${encodeURIComponent(version)}/${encodeURIComponent(file)}/provenance`;
131
- return Boolean(await getJson(url, options));
209
+ return getJson(url, options);
132
210
  }
133
211
 
134
212
  /**
@@ -0,0 +1,317 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { ANSI } from "./auth-urls.mjs";
6
+
7
+ const execute = promisify(execFile);
8
+ const SLUG = /^[a-z\d_.-]+\/[a-z\d_.-]+$/i;
9
+
10
+ /** Normalize GitHub HTTPS, SSH, npm shorthand and provenance source URLs. */
11
+ export function githubSlug(value) {
12
+ if (typeof value !== "string") {
13
+ return null;
14
+ }
15
+ let text = value
16
+ .trim()
17
+ .replace(/^git\+/, "")
18
+ .replace(/^github:/, "");
19
+ text = text
20
+ .replace(/^git@github\.com:/, "https://github.com/")
21
+ .replace(/^ssh:\/\/git@github\.com\//, "https://github.com/");
22
+ if (text.startsWith("https://github.com/")) {
23
+ text = text
24
+ .slice(19)
25
+ .split(/[?#]/)[0]
26
+ .split("/")
27
+ .slice(0, 2)
28
+ .join("/")
29
+ .split("@")[0];
30
+ }
31
+ text = text.replace(/\.git\/?$/, "");
32
+ return SLUG.test(text) ? text : null;
33
+ }
34
+
35
+ export function repositorySlug(repository) {
36
+ return repository.github_owner && repository.github_repository
37
+ ? `${repository.github_owner}/${repository.github_repository}`
38
+ : null;
39
+ }
40
+
41
+ /** Read only declared repository metadata, preserving all PyPI source URLs. */
42
+ export function manifestUrls(contents, manifest) {
43
+ contents = contents.replace(/^\uFEFF/, "");
44
+ if (manifest.endsWith("package.json")) {
45
+ let metadata;
46
+ try {
47
+ metadata = JSON.parse(contents).repository;
48
+ } catch {
49
+ return [];
50
+ }
51
+ return [typeof metadata === "string" ? metadata : metadata?.url].filter(
52
+ Boolean,
53
+ );
54
+ }
55
+ let table = "";
56
+ let inlineUrls = null;
57
+ const urls = [];
58
+ for (const line of contents.split(/\r?\n/)) {
59
+ const header = /^\s*\[([^\]]+)\]/.exec(line);
60
+ if (header) {
61
+ table = header[1].trim();
62
+ }
63
+ if (
64
+ ["project", "tool.poetry"].includes(table) &&
65
+ /^\s*urls\s*=\s*\{/.test(line)
66
+ ) {
67
+ inlineUrls = line.slice(line.indexOf("{") + 1);
68
+ } else if (inlineUrls !== null) {
69
+ inlineUrls += `\n${line}`;
70
+ }
71
+ if (inlineUrls?.includes("}")) {
72
+ for (const field of inlineUrls
73
+ .slice(0, inlineUrls.indexOf("}"))
74
+ .matchAll(/(?:[\w-]+|"[^"]+"|'[^']+')\s*=\s*(["'])(.*?)\1/g)) {
75
+ urls.push(field[2]);
76
+ }
77
+ inlineUrls = null;
78
+ }
79
+ const field =
80
+ /^\s*([\w-]+|"[^"]+"|'[^']+')\s*=\s*(["'])(.*?)\2\s*(?:#.*)?$/.exec(line);
81
+ if (!field) {
82
+ continue;
83
+ }
84
+ const key = field[1].replaceAll(/["']/g, "").toLowerCase();
85
+ if (
86
+ (table === "package" || table === "tool.poetry") &&
87
+ key === "repository"
88
+ ) {
89
+ urls.push(field[3]);
90
+ }
91
+ if (["project.urls", "tool.poetry.urls"].includes(table)) {
92
+ urls.push(field[3]);
93
+ }
94
+ }
95
+ return urls;
96
+ }
97
+
98
+ /** Findings name the evidence source; provenance is never treated as configuration. */
99
+ export function compareRepositories(packageInfo, repository, evidence = {}) {
100
+ const slug = repositorySlug(repository);
101
+ if (!slug) {
102
+ return packageInfo;
103
+ }
104
+ const pairs = [
105
+ ...(evidence.manifest_urls ?? []).map((url) => [
106
+ "manifest",
107
+ githubSlug(url),
108
+ ]),
109
+ ...(evidence.provenance_repositories ?? []).map((url) => [
110
+ "provenance",
111
+ githubSlug(url),
112
+ ]),
113
+ ...(evidence.configured_publishers ?? []).map((publisher) => [
114
+ "trusted publisher",
115
+ publisher.repository,
116
+ ]),
117
+ ];
118
+ const findings = pairs
119
+ .filter(([, old]) => old && old.toLowerCase() !== slug.toLowerCase())
120
+ .map(([source, old]) => ({ source, repository: old }));
121
+ packageInfo.repository_mismatches = [
122
+ ...new Map(
123
+ findings.map((finding) => [
124
+ `${finding.source}:${finding.repository.toLowerCase()}`,
125
+ finding,
126
+ ]),
127
+ ).values(),
128
+ ];
129
+ if (!packageInfo.repository_mismatches.length) {
130
+ delete packageInfo.repository_mismatches;
131
+ }
132
+ const warnings = (packageInfo.warnings ?? []).filter(
133
+ (warning) => !warning.startsWith("repository mismatch:"),
134
+ );
135
+ for (const finding of packageInfo.repository_mismatches ?? []) {
136
+ warnings.push(
137
+ `repository mismatch: ${finding.source} still names ${finding.repository}; repository being set up is ${slug}`,
138
+ );
139
+ }
140
+ if (warnings.length) {
141
+ packageInfo.warnings = warnings;
142
+ } else {
143
+ delete packageInfo.warnings;
144
+ }
145
+ return packageInfo;
146
+ }
147
+
148
+ /** Inspect local metadata even offline; retain URLs only when a fix is needed. */
149
+ export async function inspectManifestRepositories(inspection) {
150
+ for (const packageInfo of inspection.packages) {
151
+ if (!["npm", "crates-io", "pypi"].includes(packageInfo.registry)) {
152
+ continue;
153
+ }
154
+ const contents = await readFile(
155
+ path.join(inspection.repository.root, packageInfo.manifest),
156
+ "utf8",
157
+ ).catch(() => null);
158
+ if (contents === null) {
159
+ continue;
160
+ }
161
+ const urls = manifestUrls(contents, packageInfo.manifest);
162
+ compareRepositories(packageInfo, inspection.repository, {
163
+ ...packageInfo,
164
+ manifest_urls: urls,
165
+ });
166
+ if (
167
+ packageInfo.repository_mismatches?.some(
168
+ (finding) => finding.source === "manifest",
169
+ )
170
+ ) {
171
+ packageInfo.manifest_urls = urls;
172
+ }
173
+ }
174
+ return inspection;
175
+ }
176
+
177
+ /** Run read-only identity queries without opening a browser or exposing auth output. */
178
+ export async function identityCommand(program, args, options = {}) {
179
+ if (options.runIdentity) {
180
+ return options.runIdentity(program, args);
181
+ }
182
+ try {
183
+ const { stdout } = await execute(program, args, {
184
+ cwd: options.repository,
185
+ timeout: 10_000,
186
+ maxBuffer: 2 * 1024 * 1024,
187
+ env: options.env ?? process.env,
188
+ });
189
+ return stdout;
190
+ } catch {
191
+ if (options.verbose) {
192
+ console.error(`${program} repository identity lookup unavailable`);
193
+ }
194
+ return undefined;
195
+ }
196
+ }
197
+
198
+ /** Resolve redirects through GitHub's canonical full_name before comparison. */
199
+ export async function resolveRepository(inspection, options = {}) {
200
+ const slug = repositorySlug(inspection.repository);
201
+ if (!slug) {
202
+ return;
203
+ }
204
+ const output = await identityCommand(
205
+ "gh",
206
+ ["api", `repos/${slug}`, "--jq", ".full_name"],
207
+ options,
208
+ );
209
+ const canonical = githubSlug(output);
210
+ if (canonical) {
211
+ [
212
+ inspection.repository.github_owner,
213
+ inspection.repository.github_repository,
214
+ ] = canonical.split("/");
215
+ }
216
+ }
217
+
218
+ /** Convert npm JSON/human output and crates.io/PyPI settings to public identities. */
219
+ export function publisherIdentities(document) {
220
+ if (typeof document === "string") {
221
+ try {
222
+ return publisherIdentities(JSON.parse(document));
223
+ } catch {
224
+ document = document.replaceAll(ANSI, "");
225
+ const blocks = document
226
+ .split(/(?=^\s*type:)/m)
227
+ .filter((block) => /^\s*type:/m.test(block));
228
+ if (blocks.length > 1) {
229
+ return blocks.flatMap(publisherIdentities);
230
+ }
231
+ const values = {};
232
+ for (const line of document.replaceAll(ANSI, "").split(/\r?\n/)) {
233
+ const field =
234
+ /^\s*(id|type|repository|file|environment):\s*(\S.*)$/.exec(line);
235
+ if (field) {
236
+ values[field[1]] = field[2].trim();
237
+ }
238
+ }
239
+ return publisherIdentities(values);
240
+ }
241
+ }
242
+ if (Array.isArray(document)) {
243
+ return document.flatMap(publisherIdentities);
244
+ }
245
+ if (!document || typeof document !== "object") {
246
+ return [];
247
+ }
248
+ if (document.type && document.type !== "github") {
249
+ return [];
250
+ }
251
+ for (const key of ["github_configs", "publishers", "trustedPublishers"]) {
252
+ if (Array.isArray(document[key])) {
253
+ return publisherIdentities(document[key]);
254
+ }
255
+ }
256
+ const repository = githubSlug(
257
+ document.claims?.repository ??
258
+ document.repository ??
259
+ (document.repository_owner && document.repository_name
260
+ ? `${document.repository_owner}/${document.repository_name}`
261
+ : null),
262
+ );
263
+ if (!repository) {
264
+ return [];
265
+ }
266
+ return [
267
+ {
268
+ ...(document.id !== undefined ? { id: String(document.id) } : {}),
269
+ repository,
270
+ workflow:
271
+ document.claims?.workflow_ref?.file ??
272
+ document.workflow_filename ??
273
+ document.workflow ??
274
+ document.file ??
275
+ null,
276
+ environment: document.claims?.environment ?? document.environment ?? null,
277
+ },
278
+ ];
279
+ }
280
+
281
+ export function publisherMatches(publisher, expected) {
282
+ return (
283
+ publisher.repository.toLowerCase() ===
284
+ `${expected.organization}/${expected.repository}`.toLowerCase() &&
285
+ publisher.workflow === expected.workflow &&
286
+ (publisher.environment ?? null) === (expected.environment ?? null)
287
+ );
288
+ }
289
+
290
+ /** Extract source identity from npm SLSA envelopes and PyPI publisher bundles. */
291
+ export function provenanceRepositories(document) {
292
+ const urls = [];
293
+ for (const bundle of document?.attestation_bundles ?? []) {
294
+ if (bundle.publisher?.kind === "GitHub") {
295
+ urls.push(bundle.publisher.repository);
296
+ }
297
+ }
298
+ for (const attestation of document?.attestations ?? []) {
299
+ const payload = attestation.bundle?.dsseEnvelope?.payload;
300
+ if (typeof payload !== "string" || payload.length > 2 * 1024 * 1024) {
301
+ continue;
302
+ }
303
+ try {
304
+ const statement = JSON.parse(
305
+ Buffer.from(payload, "base64").toString("utf8"),
306
+ );
307
+ const predicate = statement.predicate;
308
+ urls.push(
309
+ predicate?.invocation?.configSource?.uri,
310
+ predicate?.buildDefinition?.externalParameters?.workflow?.repository,
311
+ );
312
+ } catch {
313
+ /* Malformed provenance leaves source identity unknown. */
314
+ }
315
+ }
316
+ return [...new Set(urls.map(githubSlug).filter(Boolean))];
317
+ }
@@ -0,0 +1,375 @@
1
+ import { pageFetchScript } from "./crates-api.mjs";
2
+ import {
3
+ publisherIdentities,
4
+ publisherMatches,
5
+ repositorySlug,
6
+ } from "./repository-identity.mjs";
7
+ import { offerManifestRepository } from "./manifest-proposal.mjs";
8
+
9
+ /** Insert transfer repairs before any release retry or secret cleanup. */
10
+ export function repairSteps(packageInfo, context, steps) {
11
+ const ids = new Set(["verify-trusted-publisher", "rerun-release"]);
12
+ const result = steps.filter((step) => !ids.has(step.id));
13
+ if (
14
+ packageInfo.registry !== "npm" &&
15
+ !context.manual &&
16
+ packageInfo.exists_on_registry === true
17
+ ) {
18
+ const before = result.findIndex((step) =>
19
+ ["configure-trusted-publisher", "attach-trusted-publisher"].includes(
20
+ step.id,
21
+ ),
22
+ );
23
+ result.splice(Math.max(0, before), 0, {
24
+ id: "check-repository-publisher",
25
+ title: "Read the current repository's configured publisher",
26
+ kind: "check",
27
+ description:
28
+ "Check the repository, workflow and environment in authenticated settings before attaching a replacement.",
29
+ });
30
+ }
31
+ const repairs = [
32
+ {
33
+ id: "verify-repository-publisher",
34
+ title: "Verify the new repository's trusted publisher",
35
+ kind: "check",
36
+ description:
37
+ "Read registry settings and require the new repository, workflow and environment before removing old trust.",
38
+ },
39
+ {
40
+ id: "remove-old-publisher",
41
+ title: "Remove the old repository's trusted publisher",
42
+ kind: "api",
43
+ description:
44
+ "Remove only publishers naming the old repository, after verifying the replacement; confirm their removal in registry settings.",
45
+ confirm: true,
46
+ },
47
+ ];
48
+ if (
49
+ packageInfo.repository_mismatches?.some(
50
+ (finding) => finding.source === "manifest",
51
+ )
52
+ ) {
53
+ repairs.push({
54
+ id: "fix-manifest-repository",
55
+ title: "Offer the manifest repository URL fix in a reviewed pull request",
56
+ kind: "api",
57
+ description:
58
+ "Create a branch and draft pull request for the repository URL. Pause release retries until it is merged.",
59
+ confirm: true,
60
+ });
61
+ }
62
+ repairs.push({
63
+ id: "rerun-release",
64
+ title: "Re-run failed release jobs after repository repairs",
65
+ kind: "check",
66
+ description:
67
+ "Verify the remote default-branch manifests name the current repository before retrying failed release jobs.",
68
+ });
69
+ if (packageInfo.exists_on_registry !== true) {
70
+ const manifestFix = repairs.find(
71
+ (step) => step.id === "fix-manifest-repository",
72
+ );
73
+ if (manifestFix) {
74
+ result.unshift(manifestFix);
75
+ repairs.splice(repairs.indexOf(manifestFix), 1);
76
+ }
77
+ }
78
+ const insertion = result.findLastIndex((step) =>
79
+ ["configure-trusted-publisher", "attach-trusted-publisher"].includes(
80
+ step.id,
81
+ ),
82
+ );
83
+ result.splice(insertion + 1, 0, ...repairs);
84
+ return result;
85
+ }
86
+
87
+ async function npmPublishers(session) {
88
+ const step = session.plan.steps.find((item) => item.id === "check-trust");
89
+ const result = await session.runProcess(step, false);
90
+ if (result.code !== 0) {
91
+ throw new Error(
92
+ "could not read npm trusted publishers; repository repair stopped",
93
+ );
94
+ }
95
+ return publisherIdentities(result.stdout);
96
+ }
97
+
98
+ async function cratesPublishers(session) {
99
+ const page = await session.automatedPage();
100
+ const publishers = [];
101
+ let query = `?crate=${encodeURIComponent(session.plan.package.name)}`;
102
+ const seen = new Set();
103
+ while (query) {
104
+ if (!query.startsWith("?") || seen.has(query)) {
105
+ throw new Error("invalid crates.io publisher pagination");
106
+ }
107
+ seen.add(query);
108
+ const response = await page.evaluate(
109
+ pageFetchScript(
110
+ "GET",
111
+ `/api/v1/trusted_publishing/github_configs${query}`,
112
+ ),
113
+ );
114
+ if (
115
+ response.status !== 200 ||
116
+ !Array.isArray(response.body?.github_configs)
117
+ ) {
118
+ throw new Error("could not read crates.io trusted publishers");
119
+ }
120
+ publishers.push(...publisherIdentities(response.body));
121
+ query = response.body.meta?.next_page;
122
+ }
123
+ return publishers;
124
+ }
125
+
126
+ /** PyPI exposes configured publishers only in the authenticated settings page. */
127
+ export const PYPI_PUBLISHERS_SCRIPT = `(() => [...document.querySelectorAll(".table--publisher-list tbody tr")].flatMap(row => {
128
+ const details = row.querySelector("small");
129
+ const link = details?.querySelector('a[href^="https://github.com/"]');
130
+ const remove = row.querySelector('a[href^="#remove-publisher-"]');
131
+ if (!link || !remove) return [];
132
+ const field = label => {
133
+ const heading = [...details.querySelectorAll("b")].find(item => item.textContent.trim() === label);
134
+ if (!heading) return undefined;
135
+ let value = "";
136
+ for (let node = heading.nextSibling; node && node.nodeName !== "BR"; node = node.nextSibling) {
137
+ if (node.nodeName === "I") return null;
138
+ value += node.textContent;
139
+ }
140
+ return value.trim() || null;
141
+ };
142
+ const workflow = field("Workflow:");
143
+ const environment = field("Environment name:");
144
+ if (!workflow || environment === undefined) return [];
145
+ return [{ id: remove.getAttribute("href").slice(18), repository: link.href, workflow, environment }];
146
+ }))()`;
147
+
148
+ /** Submit the site's existing removal form, retaining its hidden CSRF fields. */
149
+ export function pypiRemovalScript(id) {
150
+ return `(async () => {
151
+ const input = [...document.querySelectorAll('input[name="publisher_id"]')].find(input => input.value === ${JSON.stringify(id)});
152
+ const form = input?.closest("form");
153
+ if (!form || form.method.toLowerCase() !== "post" || form.action !== location.href.split("?")[0]) throw new Error("PyPI publisher removal form unavailable");
154
+ const response = await fetch(form.action, { method: "POST", body: new FormData(form), credentials: "same-origin" });
155
+ return { status: response.status };
156
+ })()`;
157
+ }
158
+
159
+ async function pypiPublishers(session) {
160
+ const url = `https://pypi.org/manage/project/${encodeURIComponent(session.plan.package.name)}/settings/publishing/`;
161
+ await session.open(url, true);
162
+ await session.prompt(
163
+ "Sign in and show the project's trusted publishers, then press Enter...",
164
+ );
165
+ const rows = await session.automation?.evaluate(PYPI_PUBLISHERS_SCRIPT);
166
+ if (!Array.isArray(rows)) {
167
+ throw new Error("could not read PyPI trusted publishers");
168
+ }
169
+ return publisherIdentities(rows);
170
+ }
171
+
172
+ async function configuredPublishers(session) {
173
+ switch (session.plan.registry) {
174
+ case "npm":
175
+ return npmPublishers(session);
176
+ case "crates-io":
177
+ return cratesPublishers(session);
178
+ default:
179
+ return pypiPublishers(session);
180
+ }
181
+ }
182
+
183
+ async function verifyPublisher(session) {
184
+ const publishers = await configuredPublishers(session);
185
+ if (
186
+ !publishers.some((publisher) =>
187
+ publisherMatches(publisher, session.plan.trusted_publisher),
188
+ )
189
+ ) {
190
+ throw new Error(
191
+ "registry settings do not list the new repository's workflow and environment; old publishers were kept",
192
+ );
193
+ }
194
+ session.repositoryPublishers = publishers;
195
+ session.conditions.add("repository-publisher-verified");
196
+ session.conditions.delete("trust-missing");
197
+ return publishers;
198
+ }
199
+
200
+ async function removeOldPublishers(session) {
201
+ if (!session.conditions.has("repository-publisher-verified")) {
202
+ throw new Error(
203
+ "verify the replacement publisher before removing old trust",
204
+ );
205
+ }
206
+ const publishers = await verifyPublisher(session);
207
+ const current = repositorySlug(session.plan.repository).toLowerCase();
208
+ const old = new Set(
209
+ publishers
210
+ .filter((publisher) => publisher.repository.toLowerCase() !== current)
211
+ .map((publisher) => publisher.repository.toLowerCase()),
212
+ );
213
+ for (const publisher of publishers.filter((item) =>
214
+ old.has(item.repository.toLowerCase()),
215
+ )) {
216
+ if (!publisher.id || !/^[\w-]+$/.test(publisher.id)) {
217
+ throw new Error("the old publisher has no usable registry identifier");
218
+ }
219
+ if (
220
+ !session.options.yes &&
221
+ !/^y(?:es)?$/i.test(
222
+ await session.prompt(
223
+ `Remove trusted publisher ${publisher.repository} (${publisher.workflow})? [y/N] `,
224
+ ),
225
+ )
226
+ ) {
227
+ throw new Error("old publisher removal declined; release retry stopped");
228
+ }
229
+ if (session.plan.registry === "npm") {
230
+ const command = session.plan.steps.find(
231
+ (step) => step.id === "check-trust",
232
+ ).command;
233
+ const args = [...command.args];
234
+ args[args.indexOf("list")] = "revoke";
235
+ args.push("--id", publisher.id, "--yes");
236
+ const result = await session.runProcess(
237
+ { command: { program: command.program, args } },
238
+ true,
239
+ );
240
+ if (result.code !== 0) {
241
+ throw new Error("npm did not revoke the old publisher");
242
+ }
243
+ } else if (session.plan.registry === "crates-io") {
244
+ const page = await session.automatedPage();
245
+ const result = await page.evaluate(
246
+ pageFetchScript(
247
+ "DELETE",
248
+ `/api/v1/trusted_publishing/github_configs/${publisher.id}`,
249
+ ),
250
+ );
251
+ if (result.status < 200 || result.status >= 300) {
252
+ throw new Error("crates.io did not remove the old publisher");
253
+ }
254
+ } else {
255
+ const result = await session.automation.evaluate(
256
+ pypiRemovalScript(publisher.id),
257
+ );
258
+ if (result.status < 200 || result.status >= 300) {
259
+ throw new Error("PyPI did not remove the old publisher");
260
+ }
261
+ }
262
+ }
263
+ if (
264
+ (await verifyPublisher(session)).some((publisher) =>
265
+ old.has(publisher.repository.toLowerCase()),
266
+ )
267
+ ) {
268
+ throw new Error(
269
+ "the old repository still has a trusted publisher; release retry stopped",
270
+ );
271
+ }
272
+ }
273
+
274
+ /** Execute repairs; a manifest PR pauses setup for review and merge. */
275
+ export async function runRepositoryRepair(session, step) {
276
+ if (step.id === "check-repository-publisher") {
277
+ const matches = (await configuredPublishers(session)).some((publisher) =>
278
+ publisherMatches(publisher, session.plan.trusted_publisher),
279
+ );
280
+ if (matches) {
281
+ session.conditions.delete("trust-missing");
282
+ } else {
283
+ session.conditions.add("trust-missing");
284
+ }
285
+ return;
286
+ }
287
+ if (step.id === "verify-repository-publisher") {
288
+ return verifyPublisher(session);
289
+ }
290
+ if (step.id === "remove-old-publisher") {
291
+ return removeOldPublishers(session);
292
+ }
293
+ if (step.id === "fix-manifest-repository") {
294
+ const result = await offerManifestRepository(session.plan, {
295
+ ...session.options,
296
+ prompt: (message) => session.prompt(message),
297
+ });
298
+ if (result) {
299
+ session.outcome = result;
300
+ }
301
+ return;
302
+ }
303
+ // A failed historical run checks out its original SHA. Require its manifests
304
+ // to match, or dispatch a fresh run from the corrected default branch.
305
+ if (!session.conditions.has("repository-publisher-verified")) {
306
+ throw new Error("verify repository repairs before retrying release jobs");
307
+ }
308
+ const expected = repositorySlug(session.plan.repository);
309
+ const result = await session.capture({
310
+ command: {
311
+ program: "gh",
312
+ args: [
313
+ "run",
314
+ "list",
315
+ "--repo",
316
+ expected,
317
+ "--workflow",
318
+ session.plan.trusted_publisher.workflow,
319
+ "--limit",
320
+ "1",
321
+ "--json",
322
+ "databaseId,conclusion,headSha",
323
+ ],
324
+ },
325
+ cwd: ".",
326
+ });
327
+ if (result.code !== 0) {
328
+ throw new Error("could not inspect release jobs after repository repair");
329
+ }
330
+ const [run] = JSON.parse(result.stdout || "[]");
331
+ if (run?.conclusion !== "failure") {
332
+ return;
333
+ }
334
+ const { verifyRemoteManifest } = await import("./manifest-proposal.mjs");
335
+ const corrected =
336
+ Boolean(run.headSha) &&
337
+ (await verifyRemoteManifest(session.plan, session.options, run.headSha));
338
+ if (
339
+ !corrected &&
340
+ !(await verifyRemoteManifest(session.plan, session.options))
341
+ ) {
342
+ throw new Error(
343
+ "merge the manifest repository URL fix before retrying release jobs",
344
+ );
345
+ }
346
+ const command = corrected
347
+ ? {
348
+ program: "gh",
349
+ args: [
350
+ "run",
351
+ "rerun",
352
+ String(run.databaseId),
353
+ "--repo",
354
+ expected,
355
+ "--failed",
356
+ ],
357
+ }
358
+ : {
359
+ program: "gh",
360
+ args: [
361
+ "workflow",
362
+ "run",
363
+ session.plan.trusted_publisher.workflow,
364
+ "--repo",
365
+ expected,
366
+ ],
367
+ };
368
+ await session.command({
369
+ ...step,
370
+ kind: "command",
371
+ command,
372
+ cwd: ".",
373
+ confirm: true,
374
+ });
375
+ }
@@ -108,6 +108,7 @@ export async function executePlans(plans, options, summary = true) {
108
108
  registry: plan.registry,
109
109
  package: plan.package.name,
110
110
  status: plan.steps.length === 0 ? "complete" : "configured",
111
+ ...session.outcome,
111
112
  });
112
113
  if (plan.trusted_publisher?.workflow) {
113
114
  console.log(`\n${oidcReleaseNote(plan.trusted_publisher.workflow)}`);
package/src/setup.mjs CHANGED
@@ -33,6 +33,11 @@ import { packageDirectory } from "./plan.mjs";
33
33
  import { twoFactorMode } from "./prerequisites.mjs";
34
34
  import { getJson, probePackage } from "./registry-state.mjs";
35
35
  import { launchBrowser, signInDomains } from "./sign-in-import.mjs";
36
+ import { runRepositoryRepair } from "./repository-repair.mjs";
37
+ import {
38
+ publisherIdentities,
39
+ publisherMatches,
40
+ } from "./repository-identity.mjs";
36
41
  import { reportTokenSecrets, verifyTokenRevoked } from "./tokens.mjs";
37
42
 
38
43
  export { defaultBrowserProfile } from "./profile.mjs";
@@ -64,7 +69,7 @@ export class SetupSession {
64
69
  if (plan.package.exists_on_registry === false) {
65
70
  this.conditions.add("package-missing");
66
71
  }
67
- if (plan.package.trusted_publishing !== true) {
72
+ if (plan.package.trusted_publishing !== true || plan.mode === "repair") {
68
73
  this.conditions.add("trust-missing");
69
74
  }
70
75
  this.values = {};
@@ -80,6 +85,9 @@ export class SetupSession {
80
85
  } else if (!step.when || this.conditions.has(step.when)) {
81
86
  this.log(`==> ${step.title}`);
82
87
  await this.runStep(step);
88
+ if (this.outcome) {
89
+ break;
90
+ }
83
91
  } else if (this.options.verbose) {
84
92
  console.error(`skip ${step.id}: condition ${step.when} does not hold`);
85
93
  }
@@ -148,6 +156,17 @@ export class SetupSession {
148
156
  }
149
157
 
150
158
  async runStep(step) {
159
+ if (
160
+ [
161
+ "check-repository-publisher",
162
+ "verify-repository-publisher",
163
+ "remove-old-publisher",
164
+ "fix-manifest-repository",
165
+ ].includes(step.id) ||
166
+ (step.id === "rerun-release" && this.plan.mode === "repair")
167
+ ) {
168
+ return runRepositoryRepair(this, step);
169
+ }
151
170
  switch (step.kind) {
152
171
  case "check":
153
172
  if (step.id === "check-name-policy") {
@@ -206,7 +225,7 @@ export class SetupSession {
206
225
  );
207
226
  }
208
227
  toggle(this.conditions, "package-missing", !state.exists);
209
- if (state.trusted === true) {
228
+ if (state.trusted === true && this.plan.mode !== "repair") {
210
229
  this.conditions.delete("trust-missing");
211
230
  }
212
231
  this.values.previous_version = state.version;
@@ -236,7 +255,13 @@ export class SetupSession {
236
255
  return;
237
256
  case "check-trust":
238
257
  case "verify-trusted-publisher": {
239
- const trusted = result.code === 0 && listsTrustedPublisher(output);
258
+ const trusted =
259
+ result.code === 0 &&
260
+ (this.plan.mode === "repair"
261
+ ? publisherIdentities(output).some((publisher) =>
262
+ publisherMatches(publisher, this.plan.trusted_publisher),
263
+ )
264
+ : listsTrustedPublisher(output));
240
265
  toggle(this.conditions, "trust-missing", !trusted);
241
266
  if (step.id === "verify-trusted-publisher" && !trusted) {
242
267
  throw new Error(
@@ -717,6 +742,7 @@ export class SetupSession {
717
742
  const { command, cwd } = this.prepare(step);
718
743
  const result = await exec(command.program, command.args, {
719
744
  cwd,
745
+ env: process.env,
720
746
  capture: true,
721
747
  mirror: this.options.verbose,
722
748
  stdin: "ignore",