package-registry-manager 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/LICENSE +24 -0
- package/README.md +21 -0
- package/package.json +53 -0
- package/src/approvals.mjs +72 -0
- package/src/auth-urls.mjs +149 -0
- package/src/automation.mjs +124 -0
- package/src/browser-options.mjs +175 -0
- package/src/browser.mjs +123 -0
- package/src/cli.mjs +431 -0
- package/src/containers.mjs +66 -0
- package/src/default-browser.mjs +149 -0
- package/src/discovery.mjs +461 -0
- package/src/flows.mjs +722 -0
- package/src/index.mjs +6 -0
- package/src/model.mjs +50 -0
- package/src/pages.mjs +91 -0
- package/src/plan.mjs +243 -0
- package/src/prerequisites.mjs +323 -0
- package/src/profile.mjs +104 -0
- package/src/publishers.mjs +495 -0
- package/src/registry-state.mjs +158 -0
- package/src/setup.mjs +820 -0
- package/src/skips.mjs +117 -0
- package/src/tokens.mjs +215 -0
- package/src/workflows.mjs +46 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Registries whose trusted publisher is bound to a workflow file. */
|
|
5
|
+
export const TRUSTED_REGISTRIES = Object.freeze(["npm", "crates-io", "pypi"]);
|
|
6
|
+
|
|
7
|
+
const JOB_PATTERNS = new Map([
|
|
8
|
+
[
|
|
9
|
+
"npm",
|
|
10
|
+
/\b(?:npm|pnpm)\s+(?:-r\s+|--recursive\s+)?(?:stage\s+)?publish\b|\byarn\s+npm\s+publish\b|\bchangeset\s+publish\b|changesets\/action\/publish@|JS-DevTools\/npm-publish/,
|
|
11
|
+
],
|
|
12
|
+
[
|
|
13
|
+
"crates-io",
|
|
14
|
+
/\bcargo\s+(?:\+\S+\s+)?(?:workspaces\s+|ws\s+)?publish\b|katyo\/publish-crates/,
|
|
15
|
+
],
|
|
16
|
+
[
|
|
17
|
+
"pypi",
|
|
18
|
+
/pypa\/gh-action-pypi-publish|\btwine\s+upload\b|\b(?:uv|poetry|hatch|flit|pdm)\s+publish\b/,
|
|
19
|
+
],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// Scripts usually pass the subcommand as a separate argument, as in
|
|
23
|
+
// `spawnSync("npm", ["publish"])` or `Command::new("cargo")` followed by
|
|
24
|
+
// `.arg("publish")` within the next few lines.
|
|
25
|
+
const SCRIPT_WINDOW = 3;
|
|
26
|
+
const SCRIPT_PATTERNS = new Map([
|
|
27
|
+
["npm", [/\b(?:npm|pnpm|yarn)\b/, /["'`]publish["'`]/]],
|
|
28
|
+
["crates-io", [/\bcargo\b/, /["'`]publish["'`]/]],
|
|
29
|
+
[
|
|
30
|
+
"pypi",
|
|
31
|
+
[/\b(?:twine|uv|poetry|hatch|flit|pdm)\b/, /["'`](?:upload|publish)["'`]/],
|
|
32
|
+
],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const TOKEN_SECRETS = new Map([
|
|
36
|
+
["npm", ["NPM_TOKEN", "NPM_AUTH_TOKEN"]],
|
|
37
|
+
[
|
|
38
|
+
"crates-io",
|
|
39
|
+
["CARGO_TOKEN", "CARGO_REGISTRY_TOKEN", "CRATES_IO_TOKEN", "CRATES_TOKEN"],
|
|
40
|
+
],
|
|
41
|
+
["pypi", ["PYPI_TOKEN", "PYPI_API_TOKEN", "PYPI_PASSWORD", "TWINE_PASSWORD"]],
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const TRUSTED_PUBLISHING_HINTS = new Map([
|
|
45
|
+
["npm", "npm trusted publishing (`id-token: write` and npm 11.5.1 or newer)"],
|
|
46
|
+
[
|
|
47
|
+
"crates-io",
|
|
48
|
+
"crates.io trusted publishing (`rust-lang/crates-io-auth-action` with `id-token: write`)",
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
"pypi",
|
|
52
|
+
"PyPI trusted publishing (`pypa/gh-action-pypi-publish` with `id-token: write`)",
|
|
53
|
+
],
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const SCRIPT_REFERENCE =
|
|
57
|
+
/^(?:\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)*\.(?:mjs|cjs|js|mts|cts|ts|rs|sh|bash|py)$/;
|
|
58
|
+
const RUN_SCRIPT =
|
|
59
|
+
/\bnpm\s+run(?:-script)?\s+([\w:.-]+)|\b(?:pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/g;
|
|
60
|
+
// `changesets/action` runs its `publish` (v1) or `publish-script` (v2) input.
|
|
61
|
+
const CHANGESETS_PUBLISH = /^\s*publish(?:-script)?\s*:\s*(.+?)\s*$/;
|
|
62
|
+
const LOCAL_WORKFLOW =
|
|
63
|
+
/^\s*uses\s*:\s*["']?\.\/\.github\/workflows\/([\w.-]+\.ya?ml)/;
|
|
64
|
+
const SCRIPT_COMMENT = /^\s*(?:#|\/\/|\/?\*)/;
|
|
65
|
+
const COMMAND_PREFIX =
|
|
66
|
+
/^\s*(?:(?:npx|bunx|exec|sudo|env|time|command|pnpm\s+exec|yarn|\w+=\S*)\s+)*$/;
|
|
67
|
+
const MAX_SCRIPT_BYTES = 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Splits a GitHub Actions workflow into its jobs by indentation, since no YAML
|
|
71
|
+
* parser is bundled. Full-line comments are dropped.
|
|
72
|
+
*/
|
|
73
|
+
export function parseWorkflow(contents) {
|
|
74
|
+
const lines = contents.split(/\r?\n/).filter((line) => !/^\s*#/.test(line));
|
|
75
|
+
const start = lines.findIndex((line) => /^jobs\s*:\s*$/.test(line));
|
|
76
|
+
if (start === -1) {
|
|
77
|
+
return { header: lines, jobs: [] };
|
|
78
|
+
}
|
|
79
|
+
let end = lines.findIndex((line, index) => index > start && /^\S/.test(line));
|
|
80
|
+
end = end === -1 ? lines.length : end;
|
|
81
|
+
const body = lines.slice(start + 1, end);
|
|
82
|
+
const jobIndent = indentOf(body.find((line) => line.trim()) ?? "");
|
|
83
|
+
const jobs = [];
|
|
84
|
+
for (const line of body) {
|
|
85
|
+
const header =
|
|
86
|
+
line.trim() && indentOf(line) === jobIndent
|
|
87
|
+
? /^\s*["']?([\w-]+)["']?\s*:\s*$/.exec(line)
|
|
88
|
+
: null;
|
|
89
|
+
if (header) {
|
|
90
|
+
jobs.push({ name: header[1], lines: [] });
|
|
91
|
+
} else if (jobs.length > 0) {
|
|
92
|
+
jobs.at(-1).lines.push(line);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
header: [...lines.slice(0, start), ...lines.slice(end)],
|
|
97
|
+
jobs,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Finds the workflow jobs that publish to a registry with an OIDC token, so
|
|
103
|
+
* the trusted publisher names the workflow CI actually publishes from.
|
|
104
|
+
*/
|
|
105
|
+
export async function detectPublisher(root, workflows, registry) {
|
|
106
|
+
const parsedWorkflows = new Map(
|
|
107
|
+
workflows.map((workflow) => [
|
|
108
|
+
workflow.name,
|
|
109
|
+
parseWorkflow(workflow.contents),
|
|
110
|
+
]),
|
|
111
|
+
);
|
|
112
|
+
const found = [];
|
|
113
|
+
for (const workflow of workflows) {
|
|
114
|
+
const parsed = parsedWorkflows.get(workflow.name);
|
|
115
|
+
// Registries check the caller's identity, so a workflow that only runs
|
|
116
|
+
// when another workflow calls it can never be the trusted publisher.
|
|
117
|
+
if (onlyCalled(parsed.header)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const workflowGrant = grantsIdToken(
|
|
121
|
+
childBlock(parsed.header, 0, "permissions"),
|
|
122
|
+
);
|
|
123
|
+
for (const job of parsed.jobs) {
|
|
124
|
+
const called = LOCAL_WORKFLOW.exec(
|
|
125
|
+
job.lines.find((line) => LOCAL_WORKFLOW.test(line)) ?? "",
|
|
126
|
+
)?.[1];
|
|
127
|
+
const callee = called ? parsedWorkflows.get(called) : null;
|
|
128
|
+
const publishes = callee
|
|
129
|
+
? await anyJobPublishes(root, callee, registry)
|
|
130
|
+
: await jobPublishes(root, parsed, job, registry);
|
|
131
|
+
if (!publishes) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const indent = childIndent(job.lines);
|
|
135
|
+
found.push({
|
|
136
|
+
workflow: workflow.name,
|
|
137
|
+
job: job.name,
|
|
138
|
+
idToken:
|
|
139
|
+
grantsIdToken(childBlock(job.lines, indent, "permissions")) ??
|
|
140
|
+
workflowGrant ??
|
|
141
|
+
false,
|
|
142
|
+
environment: environmentName(
|
|
143
|
+
childBlock(job.lines, indent, "environment"),
|
|
144
|
+
),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const trusted = found.filter((item) => item.idToken);
|
|
149
|
+
const files = [...new Set(trusted.map((item) => item.workflow))];
|
|
150
|
+
if (files.length > 1) {
|
|
151
|
+
return {
|
|
152
|
+
workflow: null,
|
|
153
|
+
jobs: [],
|
|
154
|
+
environment: null,
|
|
155
|
+
candidates: files,
|
|
156
|
+
warnings: [
|
|
157
|
+
`several workflows publish to ${registry} with id-token: write (${files.join(", ")}); pass --workflow <file> to choose the trusted publisher`,
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (files.length === 1) {
|
|
162
|
+
return described(files[0], trusted);
|
|
163
|
+
}
|
|
164
|
+
const untrusted = [...new Set(found.map((item) => item.workflow))];
|
|
165
|
+
if (untrusted.length === 1) {
|
|
166
|
+
const result = described(untrusted[0], found);
|
|
167
|
+
result.warnings.unshift(
|
|
168
|
+
`${untrusted[0]} publishes to ${registry} from ${jobList(found)} without \`id-token: write\`; add it so trusted publishing can mint a token`,
|
|
169
|
+
);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
workflow: null,
|
|
174
|
+
jobs: [],
|
|
175
|
+
environment: null,
|
|
176
|
+
candidates: untrusted,
|
|
177
|
+
warnings:
|
|
178
|
+
untrusted.length > 1
|
|
179
|
+
? [
|
|
180
|
+
`several workflows publish to ${registry} (${untrusted.join(", ")}) and none grants id-token: write; pass --workflow <file> to choose the trusted publisher`,
|
|
181
|
+
]
|
|
182
|
+
: [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function described(workflow, found) {
|
|
187
|
+
const jobs = found.filter((item) => item.workflow === workflow);
|
|
188
|
+
const environments = [...new Set(jobs.map((item) => item.environment))];
|
|
189
|
+
const warnings = [];
|
|
190
|
+
if (environments.length > 1) {
|
|
191
|
+
warnings.push(
|
|
192
|
+
`the publishing jobs in ${workflow} use different environments (${environments.map((item) => item ?? "none").join(", ")}); pass --environment <name> to choose one`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
workflow,
|
|
197
|
+
jobs: [...new Set(jobs.map((item) => item.job))],
|
|
198
|
+
environment: environments.length === 1 ? environments[0] : null,
|
|
199
|
+
candidates: [],
|
|
200
|
+
warnings,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function jobList(found) {
|
|
205
|
+
return [...new Set(found.map((item) => item.job))]
|
|
206
|
+
.map((job) => `job ${job}`)
|
|
207
|
+
.join(", ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Lists the long-lived registry token secrets that workflows read, which
|
|
212
|
+
* trusted publishing makes unnecessary.
|
|
213
|
+
*/
|
|
214
|
+
export function tokenSecrets(workflows, registry) {
|
|
215
|
+
const names = TOKEN_SECRETS.get(registry) ?? [];
|
|
216
|
+
const found = [];
|
|
217
|
+
for (const workflow of workflows) {
|
|
218
|
+
const lines = workflow.contents
|
|
219
|
+
.split(/\r?\n/)
|
|
220
|
+
.filter((line) => !/^\s*#/.test(line));
|
|
221
|
+
for (const name of names) {
|
|
222
|
+
if (
|
|
223
|
+
lines.some((line) => new RegExp(`\\bsecrets\\.${name}\\b`).test(line))
|
|
224
|
+
) {
|
|
225
|
+
found.push({ workflow: workflow.name, secret: name });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return found;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Explains how to replace a long-lived token secret with trusted publishing. */
|
|
233
|
+
export function tokenSecretWarning(registry, { workflow, secret }) {
|
|
234
|
+
return `${workflow} reads secrets.${secret}; publish with ${TRUSTED_PUBLISHING_HINTS.get(registry)} and delete the long-lived token`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Names of the long-lived token secrets that trusted publishing replaces. */
|
|
238
|
+
export function registryTokenSecrets(registry) {
|
|
239
|
+
return TOKEN_SECRETS.get(registry) ?? [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function anyJobPublishes(root, parsed, registry) {
|
|
243
|
+
for (const job of parsed.jobs) {
|
|
244
|
+
if (await jobPublishes(root, parsed, job, registry)) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function jobPublishes(root, parsed, job, registry) {
|
|
252
|
+
const pattern = JOB_PATTERNS.get(registry);
|
|
253
|
+
if (publishingLine(job.lines, pattern)) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
const directories = workingDirectories([...parsed.header, ...job.lines]);
|
|
257
|
+
const scripts = [];
|
|
258
|
+
for (const script of scriptReferences(job.lines)) {
|
|
259
|
+
const contents = await readInside(root, directories, script);
|
|
260
|
+
const lines = contents === null ? [] : codeLines(contents);
|
|
261
|
+
if (scriptPublishes(lines, registry)) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
scripts.push(...lines);
|
|
265
|
+
}
|
|
266
|
+
const commands = job.lines.some((line) => /changesets\/action@/.test(line))
|
|
267
|
+
? job.lines
|
|
268
|
+
.map((line) => CHANGESETS_PUBLISH.exec(line)?.[1])
|
|
269
|
+
.filter(Boolean)
|
|
270
|
+
.map(unquote)
|
|
271
|
+
: [];
|
|
272
|
+
if (publishingLine(commands, pattern)) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
// A package script may run from the job, its changesets command, or a
|
|
276
|
+
// script, as in `$\`npm run changeset:publish\``.
|
|
277
|
+
for (const line of [...job.lines, ...commands, ...scripts]) {
|
|
278
|
+
for (const match of line.matchAll(RUN_SCRIPT)) {
|
|
279
|
+
const manifest = await readInside(root, directories, "package.json");
|
|
280
|
+
const command =
|
|
281
|
+
manifest === null
|
|
282
|
+
? null
|
|
283
|
+
: packageScript(manifest, match[1] ?? match[2]);
|
|
284
|
+
if (command && publishingLine([command], pattern)) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function onlyCalled(header) {
|
|
293
|
+
const block = childBlock(header, 0, "[\"']?on[\"']?");
|
|
294
|
+
if (!block) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
if (block.inline) {
|
|
298
|
+
return /^\[?\s*workflow_call\s*\]?$/.test(unquote(block.inline));
|
|
299
|
+
}
|
|
300
|
+
const indent = childIndent(block.nested);
|
|
301
|
+
const triggers = block.nested
|
|
302
|
+
.filter((line) => line.trim() && indentOf(line) === indent)
|
|
303
|
+
.map((line) =>
|
|
304
|
+
line
|
|
305
|
+
.trim()
|
|
306
|
+
.replace(/^-\s*/, "")
|
|
307
|
+
.replace(/\s*:.*$/, ""),
|
|
308
|
+
);
|
|
309
|
+
return (
|
|
310
|
+
triggers.length > 0 && triggers.every((item) => item === "workflow_call")
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function publishingLine(lines, pattern) {
|
|
315
|
+
return lines.some(
|
|
316
|
+
(line) => pattern.test(line) && !line.includes("--dry-run"),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function codeLines(contents) {
|
|
321
|
+
return contents
|
|
322
|
+
.split(/\r?\n/)
|
|
323
|
+
.filter(
|
|
324
|
+
(line) => !line.includes("--dry-run") && !SCRIPT_COMMENT.test(line),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scriptPublishes(lines, registry) {
|
|
329
|
+
const pattern = new RegExp(JOB_PATTERNS.get(registry).source, "g");
|
|
330
|
+
if (
|
|
331
|
+
lines.some((line) =>
|
|
332
|
+
[...line.matchAll(pattern)].some((match) =>
|
|
333
|
+
commandPosition(line.slice(0, match.index)),
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
) {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
const [program, subcommand] = SCRIPT_PATTERNS.get(registry);
|
|
340
|
+
return lines.some(
|
|
341
|
+
(line, index) =>
|
|
342
|
+
program.test(line) &&
|
|
343
|
+
lines
|
|
344
|
+
.slice(index, index + SCRIPT_WINDOW)
|
|
345
|
+
.some((nearby) => subcommand.test(nearby)),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A script mentions `cargo publish` in messages and comments too; it runs it
|
|
350
|
+
// only where the command starts a line, a shell command or a string literal.
|
|
351
|
+
function commandPosition(prefix) {
|
|
352
|
+
const segment = prefix.split(/["'`;&|(]/).at(-1);
|
|
353
|
+
return COMMAND_PREFIX.test(segment);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function packageScript(contents, name) {
|
|
357
|
+
try {
|
|
358
|
+
const command = JSON.parse(contents).scripts?.[name];
|
|
359
|
+
return typeof command === "string" ? command : null;
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Lists repository script paths that a job's lines run, such as `node x.mjs`. */
|
|
366
|
+
export function scriptReferences(lines) {
|
|
367
|
+
const references = [];
|
|
368
|
+
for (const line of lines) {
|
|
369
|
+
for (const token of line.split(/[\s"'=;()|&]+/)) {
|
|
370
|
+
if (SCRIPT_REFERENCE.test(token) && !references.includes(token)) {
|
|
371
|
+
references.push(token);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return references;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function workingDirectories(lines) {
|
|
379
|
+
const directories = [""];
|
|
380
|
+
for (const line of lines) {
|
|
381
|
+
const value = /^\s*working-directory\s*:\s*(.+?)\s*$/.exec(line)?.[1];
|
|
382
|
+
const directory = value && unquote(value);
|
|
383
|
+
if (
|
|
384
|
+
directory &&
|
|
385
|
+
!directory.includes("${{") &&
|
|
386
|
+
!directories.includes(directory)
|
|
387
|
+
) {
|
|
388
|
+
directories.push(directory);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return directories;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function readInside(root, directories, file) {
|
|
395
|
+
// Compare real paths with the real root: a checkout reached through a
|
|
396
|
+
// symlink (macOS's /var, a Windows short name) resolves elsewhere.
|
|
397
|
+
const realRoot = await realpath(root).catch(() => null);
|
|
398
|
+
if (!realRoot) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
for (const directory of directories) {
|
|
402
|
+
const candidate = path.resolve(root, directory, file);
|
|
403
|
+
if (!isInside(root, candidate)) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const resolved = await realpath(candidate);
|
|
408
|
+
if (!isInside(realRoot, resolved)) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const metadata = await stat(resolved);
|
|
412
|
+
if (metadata.isFile() && metadata.size <= MAX_SCRIPT_BYTES) {
|
|
413
|
+
return await readFile(resolved, "utf8");
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// A reference that does not exist in the checkout is not a script.
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isInside(root, candidate) {
|
|
423
|
+
const relative = path.relative(root, candidate);
|
|
424
|
+
return (
|
|
425
|
+
relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function indentOf(line) {
|
|
430
|
+
return line.length - line.trimStart().length;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function childIndent(lines) {
|
|
434
|
+
const first = lines.find((line) => line.trim());
|
|
435
|
+
return first === undefined ? 0 : indentOf(first);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function childBlock(lines, indent, key) {
|
|
439
|
+
const expression = new RegExp(`^\\s*${key}\\s*:\\s*(.*)$`);
|
|
440
|
+
const start = lines.findIndex(
|
|
441
|
+
(line) => indentOf(line) === indent && expression.test(line),
|
|
442
|
+
);
|
|
443
|
+
if (start === -1) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const nested = [];
|
|
447
|
+
for (const line of lines.slice(start + 1)) {
|
|
448
|
+
if (line.trim() && indentOf(line) <= indent) {
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
nested.push(line);
|
|
452
|
+
}
|
|
453
|
+
return { inline: stripComment(expression.exec(lines[start])[1]), nested };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function grantsIdToken(block) {
|
|
457
|
+
if (!block) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
const inline = unquote(block.inline);
|
|
461
|
+
if (inline) {
|
|
462
|
+
return (
|
|
463
|
+
inline === "write-all" ||
|
|
464
|
+
(inline.startsWith("{") && /\bid-token\s*:\s*['"]?write\b/.test(inline))
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
return block.nested.some((line) =>
|
|
468
|
+
/^\s*id-token\s*:\s*['"]?write\b/.test(line),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function environmentName(block) {
|
|
473
|
+
if (!block) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
let value = block.inline;
|
|
477
|
+
if (value.startsWith("{")) {
|
|
478
|
+
value = /\bname\s*:\s*([^,}]+)/.exec(value)?.[1] ?? "";
|
|
479
|
+
} else if (!value) {
|
|
480
|
+
value =
|
|
481
|
+
block.nested
|
|
482
|
+
.map((line) => /^\s*name\s*:\s*(.+)$/.exec(line)?.[1])
|
|
483
|
+
.find(Boolean) ?? "";
|
|
484
|
+
}
|
|
485
|
+
value = unquote(stripComment(value));
|
|
486
|
+
return value && !value.includes("${{") ? value : null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function stripComment(value) {
|
|
490
|
+
return value.replace(/\s+#.*$/, "").trim();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function unquote(value) {
|
|
494
|
+
return value.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
495
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** Identifies the tool to registry APIs, as crates.io asks of clients. */
|
|
2
|
+
export const USER_AGENT =
|
|
3
|
+
"package-registry-manager (+https://github.com/link-foundation/package-registry-manager)";
|
|
4
|
+
|
|
5
|
+
const ENDPOINTS = {
|
|
6
|
+
npm: ["PACKAGE_REGISTRY_MANAGER_NPM_REGISTRY", "https://registry.npmjs.org"],
|
|
7
|
+
"crates-io": [
|
|
8
|
+
"PACKAGE_REGISTRY_MANAGER_CRATES_IO_API",
|
|
9
|
+
"https://crates.io/api/v1",
|
|
10
|
+
],
|
|
11
|
+
pypi: ["PACKAGE_REGISTRY_MANAGER_PYPI_API", "https://pypi.org"],
|
|
12
|
+
"docker-hub": [
|
|
13
|
+
"PACKAGE_REGISTRY_MANAGER_DOCKER_HUB_API",
|
|
14
|
+
"https://hub.docker.com/v2",
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Returns the registry API base URL, honoring test overrides. */
|
|
19
|
+
export function registryEndpoint(registry, env = process.env) {
|
|
20
|
+
const [variable, fallback] = ENDPOINTS[registry];
|
|
21
|
+
return trimTrailingSlashes(env[variable] || fallback);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A loop instead of /\/+$/, which backtracks quadratically on long runs of "/".
|
|
25
|
+
function trimTrailingSlashes(value) {
|
|
26
|
+
let end = value.length;
|
|
27
|
+
while (end > 0 && value[end - 1] === "/") {
|
|
28
|
+
end -= 1;
|
|
29
|
+
}
|
|
30
|
+
return value.slice(0, end);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns the public URL that answers whether a package exists. */
|
|
34
|
+
export function registryStateUrl(packageInfo, env = process.env) {
|
|
35
|
+
const { registry, name } = packageInfo;
|
|
36
|
+
if (!ENDPOINTS[registry]) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const base = registryEndpoint(registry, env);
|
|
40
|
+
switch (registry) {
|
|
41
|
+
case "npm":
|
|
42
|
+
return `${base}/${npmName(name)}/latest`;
|
|
43
|
+
case "crates-io":
|
|
44
|
+
return `${base}/crates/${encodeURIComponent(name)}`;
|
|
45
|
+
case "pypi":
|
|
46
|
+
return `${base}/pypi/${encodeURIComponent(name)}/json`;
|
|
47
|
+
default: {
|
|
48
|
+
const [namespace, repository] = name.split("/");
|
|
49
|
+
return `${base}/namespaces/${encodeURIComponent(namespace)}/repositories/${encodeURIComponent(repository)}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Encodes an npm package name for a registry document URL. */
|
|
55
|
+
export function npmName(name) {
|
|
56
|
+
return name.startsWith("@") ? `@${encodeURIComponent(name.slice(1))}` : name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Looks up whether each publishable package already exists and whether its
|
|
61
|
+
* latest release was published through trusted publishing. Unknown state is
|
|
62
|
+
* left unset so plans keep every conditional step.
|
|
63
|
+
*/
|
|
64
|
+
export async function probeRegistryState(inspection, options = {}) {
|
|
65
|
+
const probed = structuredClone(inspection);
|
|
66
|
+
await Promise.all(
|
|
67
|
+
probed.packages.map(async (packageInfo) => {
|
|
68
|
+
if (!packageInfo.publishable) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const state = await probePackage(packageInfo, options);
|
|
72
|
+
if (state.exists !== undefined) {
|
|
73
|
+
packageInfo.exists_on_registry = state.exists;
|
|
74
|
+
}
|
|
75
|
+
if (state.trusted !== undefined) {
|
|
76
|
+
packageInfo.trusted_publishing = state.trusted;
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
return probed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Probes one package; returns `{ exists, trusted, version }` with unknown
|
|
85
|
+
* fields unset.
|
|
86
|
+
*/
|
|
87
|
+
export async function probePackage(packageInfo, options = {}) {
|
|
88
|
+
const env = options.env ?? process.env;
|
|
89
|
+
const url = registryStateUrl(packageInfo, env);
|
|
90
|
+
if (!url) {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
const document = await getJson(url, options);
|
|
94
|
+
if (document === undefined) {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
if (document === null) {
|
|
98
|
+
return packageInfo.registry === "docker-hub"
|
|
99
|
+
? { exists: false }
|
|
100
|
+
: { exists: false, trusted: false };
|
|
101
|
+
}
|
|
102
|
+
switch (packageInfo.registry) {
|
|
103
|
+
case "npm":
|
|
104
|
+
return {
|
|
105
|
+
exists: true,
|
|
106
|
+
trusted: Boolean(document._npmUser?.trustedPublisher),
|
|
107
|
+
version: document.version,
|
|
108
|
+
};
|
|
109
|
+
case "crates-io":
|
|
110
|
+
return {
|
|
111
|
+
exists: true,
|
|
112
|
+
trusted: (document.versions ?? []).some((item) =>
|
|
113
|
+
Boolean(item.trustpub_data),
|
|
114
|
+
),
|
|
115
|
+
};
|
|
116
|
+
case "pypi":
|
|
117
|
+
return { exists: true, trusted: await pypiProvenance(document, options) };
|
|
118
|
+
default:
|
|
119
|
+
return { exists: true };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function pypiProvenance(document, options) {
|
|
124
|
+
const file = document.urls?.[0]?.filename;
|
|
125
|
+
const version = document.info?.version;
|
|
126
|
+
if (!file || !version) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const base = registryEndpoint("pypi", options.env ?? process.env);
|
|
130
|
+
const url = `${base}/integrity/${encodeURIComponent(document.info.name)}/${encodeURIComponent(version)}/${encodeURIComponent(file)}/provenance`;
|
|
131
|
+
return Boolean(await getJson(url, options));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fetches a JSON document: returns the parsed body, `null` for 404, or
|
|
136
|
+
* `undefined` when the state cannot be determined.
|
|
137
|
+
*/
|
|
138
|
+
export async function getJson(url, options = {}) {
|
|
139
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
140
|
+
try {
|
|
141
|
+
const response = await fetcher(url, {
|
|
142
|
+
headers: { accept: "application/json", "user-agent": USER_AGENT },
|
|
143
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
144
|
+
});
|
|
145
|
+
if (options.verbose) {
|
|
146
|
+
console.error(`GET ${url} -> ${response.status}`);
|
|
147
|
+
}
|
|
148
|
+
if (response.status === 404) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return response.ok ? await response.json() : undefined;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (options.verbose) {
|
|
154
|
+
console.error(`GET ${url} failed: ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|