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 +3 -3
- package/src/discovery.mjs +2 -1
- package/src/manifest-proposal.mjs +201 -0
- package/src/plan.mjs +17 -1
- package/src/registry-state.mjs +85 -7
- package/src/repository-identity.mjs +317 -0
- package/src/repository-repair.mjs +375 -0
- package/src/setup-batch.mjs +1 -0
- package/src/setup.mjs +29 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "package-registry-manager",
|
|
3
|
-
"version": "0.22.
|
|
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.
|
|
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,
|
|
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
|
}
|
package/src/registry-state.mjs
CHANGED
|
@@ -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
|
-
|
|
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: (
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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
|
+
}
|
package/src/setup-batch.mjs
CHANGED
|
@@ -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 =
|
|
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",
|