package-registry-manager 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/package.json +3 -3
- package/src/automation.mjs +12 -5
- package/src/browser.mjs +2 -2
- package/src/cli-main.mjs +20 -6
- package/src/crates-api.mjs +9 -2
- package/src/discovery.mjs +6 -1
- package/src/github.mjs +34 -1
- package/src/migration-cleanup.mjs +31 -9
- package/src/organization-scan.mjs +37 -11
- package/src/organization-setup.mjs +7 -5
- package/src/plan.mjs +12 -4
- package/src/publishers.mjs +91 -1
- package/src/publishing-state.mjs +37 -0
- package/src/registry-state.mjs +60 -23
- package/src/request-policy.mjs +27 -0
- package/src/setup.mjs +19 -3
- package/src/source-code.mjs +2 -0
- package/src/tokens.mjs +4 -2
- package/src/webdriver-automation.mjs +3 -2
- package/src/workflow-fallback.mjs +125 -0
- package/src/workflow-migration.mjs +135 -76
- package/src/workflow-proposal.mjs +36 -7
package/src/registry-state.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cratesPermit, delay, retryDelay } from "./request-policy.mjs";
|
|
1
2
|
import {
|
|
2
3
|
compareRepositories,
|
|
3
4
|
identityCommand,
|
|
@@ -118,6 +119,14 @@ export async function probeRegistryState(inspection, options = {}) {
|
|
|
118
119
|
if (state.trusted !== undefined) {
|
|
119
120
|
packageInfo.trusted_publishing = state.trusted;
|
|
120
121
|
}
|
|
122
|
+
for (const key of [
|
|
123
|
+
"registry_http_status",
|
|
124
|
+
"publisher_settings_reason",
|
|
125
|
+
]) {
|
|
126
|
+
if (state[key] !== undefined) {
|
|
127
|
+
packageInfo[key] = state[key];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
121
130
|
const evidence = { ...packageInfo, ...state };
|
|
122
131
|
if (state.configured_publishers !== undefined) {
|
|
123
132
|
packageInfo.configured_publishers = state.configured_publishers;
|
|
@@ -184,9 +193,16 @@ export async function probePackage(packageInfo, options = {}) {
|
|
|
184
193
|
if (!url) {
|
|
185
194
|
return {};
|
|
186
195
|
}
|
|
187
|
-
|
|
196
|
+
let httpStatus;
|
|
197
|
+
const document = await getJson(url, {
|
|
198
|
+
...options,
|
|
199
|
+
onError: (error) => {
|
|
200
|
+
httpStatus = error.http_status;
|
|
201
|
+
options.onError?.(error);
|
|
202
|
+
},
|
|
203
|
+
});
|
|
188
204
|
if (document === undefined) {
|
|
189
|
-
return {};
|
|
205
|
+
return httpStatus ? { registry_http_status: httpStatus } : {};
|
|
190
206
|
}
|
|
191
207
|
if (document === null) {
|
|
192
208
|
return packageInfo.registry === "docker-hub"
|
|
@@ -212,11 +228,15 @@ export async function probePackage(packageInfo, options = {}) {
|
|
|
212
228
|
(document.versions ?? []).find(
|
|
213
229
|
(item) => item.num === document.crate?.max_version,
|
|
214
230
|
) ?? document.versions?.[0];
|
|
215
|
-
const settings =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
231
|
+
const settings = options.cratesPublisherSettings
|
|
232
|
+
? await getJson(
|
|
233
|
+
`${registryEndpoint("crates-io", env)}/trusted_publishing/github_configs?crate=${encodeURIComponent(packageInfo.name)}`,
|
|
234
|
+
options,
|
|
235
|
+
)
|
|
236
|
+
: undefined;
|
|
219
237
|
return {
|
|
238
|
+
publisher_settings_reason:
|
|
239
|
+
"publisher settings require an authenticated crates.io session; public provenance is independent",
|
|
220
240
|
exists: true,
|
|
221
241
|
trusted: Boolean(latest?.trustpub_data),
|
|
222
242
|
provenance_repositories: publisherIdentities(latest?.trustpub_data).map(
|
|
@@ -257,25 +277,42 @@ async function pypiProvenance(document, options) {
|
|
|
257
277
|
*/
|
|
258
278
|
export async function getJson(url, options = {}) {
|
|
259
279
|
const fetcher = options.fetch ?? globalThis.fetch;
|
|
280
|
+
const crates = new URL(url).hostname === "crates.io";
|
|
281
|
+
const attempts = crates ? 4 : 1;
|
|
260
282
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
if (!response.ok) {
|
|
272
|
-
options.onError?.({
|
|
273
|
-
stage: "registry",
|
|
274
|
-
message: `registry lookup returned HTTP ${response.status}`,
|
|
283
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
284
|
+
// Test fetches do not contact the service. An injected limiter can exercise pacing.
|
|
285
|
+
if (crates) {
|
|
286
|
+
await (options.requestLimiter ?? cratesPermit)();
|
|
287
|
+
}
|
|
288
|
+
const response = await fetcher(url, {
|
|
289
|
+
headers: { accept: "application/json", "user-agent": USER_AGENT },
|
|
290
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
275
291
|
});
|
|
276
|
-
|
|
292
|
+
if (options.verbose) {
|
|
293
|
+
console.error(
|
|
294
|
+
`GET ${url} -> ${response.status} (attempt ${attempt + 1})`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if ([403, 429].includes(response.status) && attempt + 1 < attempts) {
|
|
298
|
+
const wait = retryDelay(response.headers?.get("retry-after"), attempt);
|
|
299
|
+
await response.body?.cancel();
|
|
300
|
+
await (options.sleep ?? delay)(wait);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (response.status === 404) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
options.onError?.({
|
|
308
|
+
stage: "registry",
|
|
309
|
+
http_status: response.status,
|
|
310
|
+
message: `registry lookup returned HTTP ${response.status}`,
|
|
311
|
+
});
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
return await response.json();
|
|
277
315
|
}
|
|
278
|
-
return await response.json();
|
|
279
316
|
} catch (error) {
|
|
280
317
|
options.onError?.({
|
|
281
318
|
stage: "registry",
|
|
@@ -284,6 +321,6 @@ export async function getJson(url, options = {}) {
|
|
|
284
321
|
if (options.verbose) {
|
|
285
322
|
console.error(`GET ${url} failed: ${error.message}`);
|
|
286
323
|
}
|
|
287
|
-
return undefined;
|
|
288
324
|
}
|
|
325
|
+
return undefined;
|
|
289
326
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A queue shared by every client in this process. Request starts are >=1s apart. */
|
|
2
|
+
export function createRequestLimiter({ now = Date.now, sleep = delay } = {}) {
|
|
3
|
+
let queue = Promise.resolve();
|
|
4
|
+
let next = 0;
|
|
5
|
+
return () => {
|
|
6
|
+
const permit = queue.then(async () => {
|
|
7
|
+
const wait = next - now();
|
|
8
|
+
if (wait > 0) {
|
|
9
|
+
await sleep(wait);
|
|
10
|
+
}
|
|
11
|
+
next = now() + 1000;
|
|
12
|
+
});
|
|
13
|
+
queue = permit.catch(() => {});
|
|
14
|
+
return permit;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
export const cratesPermit = createRequestLimiter();
|
|
19
|
+
|
|
20
|
+
/** Retry-After supports delay seconds and HTTP dates. Never retry early. */
|
|
21
|
+
export function retryDelay(value, attempt, now = Date.now()) {
|
|
22
|
+
const seconds = value !== null && value !== undefined ? Number(value) : NaN;
|
|
23
|
+
const until = Number.isFinite(seconds)
|
|
24
|
+
? seconds * 1000
|
|
25
|
+
: Date.parse(value) - now;
|
|
26
|
+
return Math.max(1000 * 2 ** attempt, Number.isFinite(until) ? until : 0);
|
|
27
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -695,23 +695,30 @@ export class SetupSession {
|
|
|
695
695
|
}
|
|
696
696
|
return;
|
|
697
697
|
}
|
|
698
|
-
await (await this.
|
|
698
|
+
await (await this.visiblePage()).goto(url);
|
|
699
699
|
}
|
|
700
700
|
|
|
701
701
|
/**
|
|
702
702
|
* The automated page, launched once. The crates.io sign-in step offers
|
|
703
703
|
* `--browser-import default|auto` itself, once it knows a sign-in is missing.
|
|
704
704
|
*/
|
|
705
|
-
async automatedPage(
|
|
705
|
+
async automatedPage(
|
|
706
|
+
browser = this.options.browserOptions,
|
|
707
|
+
quiet = false,
|
|
708
|
+
headless = false,
|
|
709
|
+
) {
|
|
706
710
|
quiet ||= this.options.quietBrowser;
|
|
707
711
|
if (!this.automation) {
|
|
708
712
|
const domains = this.options.domains ?? signInDomains(this.plan.registry);
|
|
709
|
-
this.automation = await
|
|
713
|
+
this.automation = await (
|
|
714
|
+
this.options.connectAutomation ?? connectAutomation
|
|
715
|
+
)({
|
|
710
716
|
browser: await launchBrowser(browser, domains, {
|
|
711
717
|
deferred: this.plan.registry === "crates-io",
|
|
712
718
|
verbose: quiet ? false : this.options.verbose,
|
|
713
719
|
...this.options.importDiscovery,
|
|
714
720
|
}),
|
|
721
|
+
headless,
|
|
715
722
|
profile: this.options.browserProfile,
|
|
716
723
|
verbose: quiet ? false : this.options.verbose,
|
|
717
724
|
domains,
|
|
@@ -720,6 +727,15 @@ export class SetupSession {
|
|
|
720
727
|
return this.automation;
|
|
721
728
|
}
|
|
722
729
|
|
|
730
|
+
/** Bring authentication and approval work into a visible browser. */
|
|
731
|
+
async visiblePage() {
|
|
732
|
+
if (this.automation?.headless) {
|
|
733
|
+
await this.automation.close();
|
|
734
|
+
this.automation = null;
|
|
735
|
+
}
|
|
736
|
+
return this.automatedPage();
|
|
737
|
+
}
|
|
738
|
+
|
|
723
739
|
/** Relaunches the automated browser with other options, such as an import. */
|
|
724
740
|
async reconnect(browser) {
|
|
725
741
|
await this.automation?.close();
|
package/src/source-code.mjs
CHANGED
|
@@ -54,6 +54,8 @@ export function commandPosition(prefix) {
|
|
|
54
54
|
segment,
|
|
55
55
|
) ||
|
|
56
56
|
/(?:\$|shell)\s*`\s*$/.test(segment) ||
|
|
57
|
+
/^(?:export\s+)?\w+=`\s*$/.test(segment) ||
|
|
58
|
+
/\$\(\s*$/.test(segment) ||
|
|
57
59
|
/(?:exec(?:Sync)?|spawn(?:Sync)?|run(?:_command)?|system|check_call|check_output|Popen)\s*\(\s*["'`]\s*$/.test(
|
|
58
60
|
segment,
|
|
59
61
|
)
|
package/src/tokens.mjs
CHANGED
|
@@ -81,11 +81,13 @@ export function reportTokenSecrets(output, packageInfo) {
|
|
|
81
81
|
const { inUse, leftover } = auditRegistryTokens(output, packageInfo);
|
|
82
82
|
for (const name of inUse) {
|
|
83
83
|
console.log(
|
|
84
|
-
` ${name} is still read by a workflow;
|
|
84
|
+
` ${name} is still read by a workflow; retained for authentication fallback.`,
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
for (const name of leftover) {
|
|
88
|
-
console.log(
|
|
88
|
+
console.log(
|
|
89
|
+
` ${name} is unused by workflows; retained unless cleanup is explicitly requested.`,
|
|
90
|
+
);
|
|
89
91
|
}
|
|
90
92
|
return leftover;
|
|
91
93
|
}
|
|
@@ -36,7 +36,7 @@ export function bidiCookie(cookie) {
|
|
|
36
36
|
|
|
37
37
|
/** Launches the selected installed Firefox variant and exposes the common page API. */
|
|
38
38
|
export async function connectWebDriverAutomation(
|
|
39
|
-
{ browser, profile, verbose, domains },
|
|
39
|
+
{ browser, profile, verbose, domains, headless = false },
|
|
40
40
|
dependencies = {},
|
|
41
41
|
) {
|
|
42
42
|
// Validation happens before profile creation, including programmatic callers.
|
|
@@ -65,7 +65,7 @@ export async function connectWebDriverAutomation(
|
|
|
65
65
|
browser: "firefox",
|
|
66
66
|
executablePath,
|
|
67
67
|
userDataDir: profile,
|
|
68
|
-
headless
|
|
68
|
+
headless,
|
|
69
69
|
bidi: true,
|
|
70
70
|
verbose,
|
|
71
71
|
});
|
|
@@ -101,6 +101,7 @@ export async function connectWebDriverAutomation(
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
return {
|
|
104
|
+
headless,
|
|
104
105
|
goto: (url) => connection.page.goto(url),
|
|
105
106
|
evaluate: (script) => connection.page.evaluate(script),
|
|
106
107
|
close: () => connection.close(),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** OIDC runs first. A separate retry receives credentials only after that attempt fails. */
|
|
2
|
+
export function fallbackSteps(
|
|
3
|
+
step,
|
|
4
|
+
registry,
|
|
5
|
+
names,
|
|
6
|
+
level,
|
|
7
|
+
index,
|
|
8
|
+
removeCredentials,
|
|
9
|
+
) {
|
|
10
|
+
const prefix = " ".repeat(level + 2);
|
|
11
|
+
const originalId = step
|
|
12
|
+
.map((line) => /^\s*(?:-\s*)?id:\s*([\w-]+)/.exec(line)?.[1])
|
|
13
|
+
.find(Boolean);
|
|
14
|
+
const id = originalId ?? `prm_oidc_${index + 1}`;
|
|
15
|
+
let oidc = removeCredentials(
|
|
16
|
+
step,
|
|
17
|
+
new Set([...names, ...(registry === "pypi" ? ["password", "user"] : [])]),
|
|
18
|
+
);
|
|
19
|
+
oidc = oidc.filter((line) => !/^\s*continue-on-error:/.test(line));
|
|
20
|
+
if (!originalId) {
|
|
21
|
+
oidc.push(`${prefix}id: ${id}`);
|
|
22
|
+
}
|
|
23
|
+
oidc.push(`${prefix}continue-on-error: true`);
|
|
24
|
+
const title = oidc.findIndex((line) => /^\s*(?:-\s*)?name:/.test(line));
|
|
25
|
+
if (title >= 0) {
|
|
26
|
+
oidc[title] = appendTitle(oidc[title], "OIDC");
|
|
27
|
+
} else {
|
|
28
|
+
oidc.push(`${prefix}name: Publish through OIDC`);
|
|
29
|
+
}
|
|
30
|
+
// Normalize a dash-prefixed id/condition so removing it keeps a valid step.
|
|
31
|
+
const normalized = [...step];
|
|
32
|
+
if (/^\s*-\s+(?:if|id|continue-on-error):/.test(normalized[0])) {
|
|
33
|
+
normalized[0] = normalized[0].replace(/- /, " ");
|
|
34
|
+
const next = normalized.findIndex(
|
|
35
|
+
(line, index) => index > 0 && line.search(/\S/) === level + 2,
|
|
36
|
+
);
|
|
37
|
+
if (next >= 0) {
|
|
38
|
+
normalized[next] =
|
|
39
|
+
`${" ".repeat(level)}- ${normalized[next].trimStart()}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const fallback = normalized.filter(
|
|
43
|
+
(line) => !/^\s*(?:if|id|continue-on-error):/.test(line),
|
|
44
|
+
);
|
|
45
|
+
// Remove a multiline condition together with its continuation lines.
|
|
46
|
+
const condition = step.findIndex((line) => /^\s*if:/.test(line));
|
|
47
|
+
if (condition >= 0 && /if:\s*[>|]/.test(step[condition])) {
|
|
48
|
+
for (
|
|
49
|
+
let i = condition + 1;
|
|
50
|
+
i < step.length && step[i].search(/\S/) > level + 2;
|
|
51
|
+
i++
|
|
52
|
+
) {
|
|
53
|
+
const at = fallback.indexOf(step[i]);
|
|
54
|
+
if (at >= 0) {
|
|
55
|
+
fallback.splice(at, 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const fallbackTitle = fallback.findIndex((line) =>
|
|
60
|
+
/^\s*(?:-\s*)?name:/.test(line),
|
|
61
|
+
);
|
|
62
|
+
if (fallbackTitle >= 0) {
|
|
63
|
+
fallback[fallbackTitle] = appendTitle(
|
|
64
|
+
fallback[fallbackTitle],
|
|
65
|
+
"token fallback",
|
|
66
|
+
);
|
|
67
|
+
} else {
|
|
68
|
+
fallback.push(`${prefix}name: Publish with token fallback`);
|
|
69
|
+
}
|
|
70
|
+
fallback.push(
|
|
71
|
+
`${prefix}if: steps.${id}.outcome == 'failure'`,
|
|
72
|
+
`${prefix}id: ${id}_fallback`,
|
|
73
|
+
);
|
|
74
|
+
if (registry === "pypi") {
|
|
75
|
+
const cleaned = removeCredentials(fallback, new Set(["password", "user"]));
|
|
76
|
+
const withIndex = cleaned.findIndex((line) => /^\s*with:\s*$/.test(line));
|
|
77
|
+
const password = `${prefix} password: \${{ secrets.PYPI_API_TOKEN || secrets.PYPI_TOKEN }}`;
|
|
78
|
+
if (withIndex < 0) {
|
|
79
|
+
cleaned.push(`${prefix}with:`, password);
|
|
80
|
+
} else {
|
|
81
|
+
cleaned.splice(withIndex + 1, 0, password);
|
|
82
|
+
}
|
|
83
|
+
return [oidc, ["", ...cleaned]];
|
|
84
|
+
}
|
|
85
|
+
const env = (lines, value) => {
|
|
86
|
+
const updated = removeCredentials(
|
|
87
|
+
lines,
|
|
88
|
+
new Set([...names, "NODE_AUTH_TOKEN", "NPM_CONFIG_PROVENANCE"]),
|
|
89
|
+
);
|
|
90
|
+
const at = updated.findIndex((line) => /^\s*env:\s*$/.test(line));
|
|
91
|
+
const entries = [...new Set(["NODE_AUTH_TOKEN", ...names])].map(
|
|
92
|
+
(name) => `${prefix} ${name}: ${value}`,
|
|
93
|
+
);
|
|
94
|
+
entries.push(`${prefix} NPM_CONFIG_PROVENANCE: 'true'`);
|
|
95
|
+
if (at < 0) {
|
|
96
|
+
updated.push(`${prefix}env:`, ...entries);
|
|
97
|
+
} else {
|
|
98
|
+
updated.splice(at + 1, 0, ...entries);
|
|
99
|
+
}
|
|
100
|
+
return updated;
|
|
101
|
+
};
|
|
102
|
+
oidc = env(oidc, "''");
|
|
103
|
+
return [
|
|
104
|
+
oidc,
|
|
105
|
+
[
|
|
106
|
+
"",
|
|
107
|
+
...env(
|
|
108
|
+
fallback,
|
|
109
|
+
"${{ secrets.NPM_TOKEN || secrets.NODE_AUTH_TOKEN || secrets.NPM_AUTH_TOKEN }}",
|
|
110
|
+
),
|
|
111
|
+
],
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function appendTitle(line, suffix) {
|
|
116
|
+
const value = line.slice(line.indexOf("name:") + "name:".length).trimStart();
|
|
117
|
+
const key = line.slice(0, line.length - value.length);
|
|
118
|
+
const quote = value[0];
|
|
119
|
+
return (
|
|
120
|
+
key +
|
|
121
|
+
((quote === "'" || quote === '"') && value.endsWith(quote)
|
|
122
|
+
? `${value.slice(0, -1)} (${suffix})${quote}`
|
|
123
|
+
: `${value} (${suffix})`)
|
|
124
|
+
);
|
|
125
|
+
}
|