package-registry-manager 0.23.1 → 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/CHANGELOG.md +8 -1
- package/README.md +7 -0
- package/package.json +4 -4
- package/src/automation.mjs +12 -5
- package/src/browser.mjs +2 -2
- package/src/cli-main.mjs +35 -12
- package/src/crates-api.mjs +10 -3
- package/src/discovery.mjs +6 -1
- package/src/flows.mjs +2 -2
- 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 +60 -5
- 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/CHANGELOG.md
CHANGED
|
@@ -24,9 +24,16 @@ currently shares its release version with the Rust implementation.
|
|
|
24
24
|
variables and secret, `packages: write`, and package linking (#5).
|
|
25
25
|
- `setup --browser default|automated` chooses where sign-in and approval URLs
|
|
26
26
|
open; `default`, the default, uses the user's default browser (#9).
|
|
27
|
+
- An npm first publish stops before the sign-in when the packed tarball
|
|
28
|
+
contains repository development directories such as `.github/` or `tests/`,
|
|
29
|
+
which a package.json without a `files` allowlist lets in.
|
|
27
30
|
|
|
28
31
|
### Changed
|
|
29
32
|
|
|
33
|
+
- `setup` stays signed in by default: the npm session token and the crates.io
|
|
34
|
+
sign-in in the automated profile are kept, so the next run (npm: within two
|
|
35
|
+
hours) skips the sign-in. `--sign-out` signs out at the end instead;
|
|
36
|
+
`--keep-session` is still accepted and is now the default.
|
|
30
37
|
- npm web-auth URLs (`npm login`, `npm publish` 2FA, `npm trust`) open in the
|
|
31
38
|
user's default browser (`open`, `xdg-open`, or the Windows URL handler),
|
|
32
39
|
where they are usually already signed in. The automated browser profile is
|
|
@@ -35,7 +42,7 @@ currently shares its release version with the Rust implementation.
|
|
|
35
42
|
outside any repository (`~/Library/Application Support` on macOS,
|
|
36
43
|
`$XDG_STATE_HOME` or `~/.local/state` on Linux, `%LOCALAPPDATA%` on Windows,
|
|
37
44
|
each followed by `package-registry-manager/browser-profile`) (#8).
|
|
38
|
-
- `browser-commander` is required at `^0.
|
|
45
|
+
- `browser-commander` is required at `^0.34.0`, its latest release (#28). A
|
|
39
46
|
launch still names its channel (`chrome` by default), so the upstream switch
|
|
40
47
|
of channel-less launches to Chrome for Testing does not change which browser
|
|
41
48
|
opens.
|
package/README.md
CHANGED
|
@@ -70,3 +70,10 @@ local `--offline` commands keep local scope. Both CLIs' GitHub dependency needs
|
|
|
70
70
|
Node.js 22.12 or newer and npm; JavaScript checks the runtime before loading it.
|
|
71
71
|
|
|
72
72
|
See the [bootstrap, wrapper and credential guide](../docs/registry-setup.md) for branch/PR setup, dual npm names, registry policies and integration limits.
|
|
73
|
+
|
|
74
|
+
Migration tries trusted publishing first and retains token fallback and expiry
|
|
75
|
+
metadata by default. `--delete-token-secrets` explicitly opts into cleanup after
|
|
76
|
+
OIDC publication and verification that no workflow still uses the credentials.
|
|
77
|
+
Saved crates.io API sessions run headlessly; sign-in opens a visible window only
|
|
78
|
+
when user action is needed. Organization migration skips and reports scan errors
|
|
79
|
+
while continuing for clean repositories.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "package-registry-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Inspect repositories and guide secure package-registry setup",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"audit": "node scripts/npm-audit.mjs"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/
|
|
44
|
-
"browser-commander": "^0.
|
|
45
|
-
"command-stream": "^2.
|
|
43
|
+
"@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/293e9a8ab9d6d91a08e95f4e09c2a0dcad9bc809",
|
|
44
|
+
"browser-commander": "^0.34.0",
|
|
45
|
+
"command-stream": "^2.6.0",
|
|
46
46
|
"playwright": "^1.63.0",
|
|
47
47
|
"selenium-webdriver": "^4.50.0"
|
|
48
48
|
},
|
package/src/automation.mjs
CHANGED
|
@@ -29,14 +29,20 @@ export function relayExtensionDirectory() {
|
|
|
29
29
|
* the `domains` import scope only the cookies of `domains`, the registry's
|
|
30
30
|
* sign-in domains, are imported.
|
|
31
31
|
*/
|
|
32
|
-
export function launchOptions(
|
|
32
|
+
export function launchOptions(
|
|
33
|
+
browser,
|
|
34
|
+
profile,
|
|
35
|
+
verbose = false,
|
|
36
|
+
domains = [],
|
|
37
|
+
headless = false,
|
|
38
|
+
) {
|
|
33
39
|
const options = {
|
|
34
40
|
engine:
|
|
35
41
|
findBrowserSource(browser.channel)?.controlProtocol === "cdp"
|
|
36
42
|
? "playwright"
|
|
37
43
|
: "selenium",
|
|
38
44
|
channel: browser.channel,
|
|
39
|
-
headless
|
|
45
|
+
headless,
|
|
40
46
|
verbose,
|
|
41
47
|
};
|
|
42
48
|
if (browser.executable) {
|
|
@@ -82,7 +88,7 @@ export function extensionInstructions(directory) {
|
|
|
82
88
|
* `evaluate`, `close`, and, for a launched profile, `clearCookies(domains)`.
|
|
83
89
|
*/
|
|
84
90
|
export async function connectAutomation(
|
|
85
|
-
{ browser, profile, verbose = false, domains = [] },
|
|
91
|
+
{ browser, profile, verbose = false, domains = [], headless = false },
|
|
86
92
|
dependencies = {},
|
|
87
93
|
) {
|
|
88
94
|
if (browser.attach?.mode === "extension") {
|
|
@@ -90,7 +96,7 @@ export async function connectAutomation(
|
|
|
90
96
|
}
|
|
91
97
|
if (findBrowserSource(browser.channel)?.controlProtocol === "bidi") {
|
|
92
98
|
return connectWebDriverAutomation(
|
|
93
|
-
{ browser, profile, verbose, domains },
|
|
99
|
+
{ browser, profile, verbose, domains, headless },
|
|
94
100
|
dependencies,
|
|
95
101
|
);
|
|
96
102
|
}
|
|
@@ -99,8 +105,9 @@ export async function connectAutomation(
|
|
|
99
105
|
}
|
|
100
106
|
const connection = await (
|
|
101
107
|
dependencies.launchRealBrowser ?? launchRealBrowser
|
|
102
|
-
)(launchOptions(browser, profile, verbose, domains));
|
|
108
|
+
)(launchOptions(browser, profile, verbose, domains, headless));
|
|
103
109
|
return {
|
|
110
|
+
headless,
|
|
104
111
|
goto: (url) => connection.page.goto(url),
|
|
105
112
|
evaluate: (script) => connection.page.evaluate(script),
|
|
106
113
|
close: () => connection.close(),
|
package/src/browser.mjs
CHANGED
|
@@ -42,10 +42,10 @@ export function openerSucceeded(file, code) {
|
|
|
42
42
|
* Starts an opener through command-stream without a shell and settles once it
|
|
43
43
|
* exits, or after `graceMs` while it keeps running in the background.
|
|
44
44
|
*/
|
|
45
|
-
export function detachedRunner(graceMs = OPENER_GRACE_MS) {
|
|
45
|
+
export function detachedRunner(graceMs = OPENER_GRACE_MS, spawnChild = spawn) {
|
|
46
46
|
return (file, args) =>
|
|
47
47
|
new Promise((resolve, reject) => {
|
|
48
|
-
const child =
|
|
48
|
+
const child = spawnChild(file, args, {
|
|
49
49
|
detached: process.platform !== "win32",
|
|
50
50
|
stdio: "ignore",
|
|
51
51
|
shell: false,
|
package/src/cli-main.mjs
CHANGED
|
@@ -36,7 +36,8 @@ Global options:
|
|
|
36
36
|
--org <org> | --user <login> Scan repositories through gh-manager
|
|
37
37
|
--secret-name <template> Secret name; supports {REGISTRY}, {REPO}, {ORG}
|
|
38
38
|
--rotate-token <secret> Explicitly replace a legacy crates.io token
|
|
39
|
-
--migrate Convert
|
|
39
|
+
--migrate Convert jobs to OIDC with token fallback, then verify releases
|
|
40
|
+
--delete-token-secrets Opt in to verified unused token and expiry cleanup
|
|
40
41
|
--token-expiry-days <N> Read browser token inventory in account scans
|
|
41
42
|
--format <text|json> Output format (default: text)
|
|
42
43
|
--offline Do not look up packages on registries
|
|
@@ -68,10 +69,12 @@ Setup options:
|
|
|
68
69
|
--manual crates.io: create, use, and revoke the
|
|
69
70
|
first-publish token by hand instead of
|
|
70
71
|
through the crates.io API
|
|
71
|
-
--
|
|
72
|
-
session
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
--sign-out Sign out at the end of setup: npm logout
|
|
73
|
+
for the npm session this run signed in, and
|
|
74
|
+
removal of the crates.io sign-in it brought
|
|
75
|
+
into the automated profile. Without it the
|
|
76
|
+
next run reuses both sessions; npm ends its
|
|
77
|
+
session token after two hours
|
|
75
78
|
--browser <default|automated> Open sign-in and approval pages in your
|
|
76
79
|
default browser, or in the automated profile;
|
|
77
80
|
npm reuses the automated profile
|
|
@@ -116,6 +119,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
116
119
|
"secret-name": { type: "string" },
|
|
117
120
|
"rotate-token": { type: "string" },
|
|
118
121
|
migrate: { type: "boolean", default: false },
|
|
122
|
+
"delete-token-secrets": { type: "boolean", default: false },
|
|
119
123
|
"token-expiry-days": { type: "string" },
|
|
120
124
|
format: { type: "string", default: "text" },
|
|
121
125
|
verbose: { type: "boolean", default: false },
|
|
@@ -132,6 +136,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
132
136
|
yes: { type: "boolean", default: false },
|
|
133
137
|
"no-browser": { type: "boolean", default: false },
|
|
134
138
|
"open-with": { type: "string" },
|
|
139
|
+
"sign-out": { type: "boolean", default: false },
|
|
135
140
|
"keep-session": { type: "boolean", default: false },
|
|
136
141
|
manual: { type: "boolean", default: false },
|
|
137
142
|
browser: { type: "string", default: "default" },
|
|
@@ -249,6 +254,9 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
249
254
|
if (values["open-with"] !== undefined && values["no-browser"]) {
|
|
250
255
|
throw new Error("--open-with and --no-browser are mutually exclusive");
|
|
251
256
|
}
|
|
257
|
+
if (values["sign-out"] && values["keep-session"]) {
|
|
258
|
+
throw new Error("--sign-out and --keep-session are mutually exclusive");
|
|
259
|
+
}
|
|
252
260
|
if (!browserOptions.attach) {
|
|
253
261
|
browserOptions.import ??= { browser: "auto", profile: null };
|
|
254
262
|
}
|
|
@@ -267,6 +275,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
267
275
|
secretName: values["secret-name"],
|
|
268
276
|
rotateToken,
|
|
269
277
|
migrate: values.migrate,
|
|
278
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
270
279
|
tokenExpiryDays,
|
|
271
280
|
package: values.package,
|
|
272
281
|
browser: values.browser,
|
|
@@ -276,12 +285,13 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
276
285
|
defaultBrowserProfile({ channel: browserOptions.channel }),
|
|
277
286
|
),
|
|
278
287
|
openWith: nonEmpty(values["open-with"], "--open-with"),
|
|
279
|
-
keepSession: values["
|
|
288
|
+
keepSession: !values["sign-out"],
|
|
280
289
|
planOptions: {
|
|
281
290
|
workflow: values.workflow,
|
|
282
291
|
publisherEnvironment: values.environment,
|
|
283
292
|
addPublishJob: values["add-publish-job"],
|
|
284
293
|
migrate: values.migrate,
|
|
294
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
285
295
|
manual: values.manual,
|
|
286
296
|
verifyRelease: values["verify-release"],
|
|
287
297
|
},
|
|
@@ -325,6 +335,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
325
335
|
const planOptions = {
|
|
326
336
|
rotateToken,
|
|
327
337
|
migrate: values.migrate,
|
|
338
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
328
339
|
ref: values.ref,
|
|
329
340
|
verifyRelease: values["verify-release"],
|
|
330
341
|
manual: values.manual,
|
|
@@ -364,11 +375,15 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
364
375
|
if (values["open-with"] !== undefined && values["no-browser"]) {
|
|
365
376
|
throw new Error("--open-with and --no-browser are mutually exclusive");
|
|
366
377
|
}
|
|
378
|
+
if (values["sign-out"] && values["keep-session"]) {
|
|
379
|
+
throw new Error("--sign-out and --keep-session are mutually exclusive");
|
|
380
|
+
}
|
|
367
381
|
|
|
368
382
|
const plans = buildPlans(inspection, registries, planOptions);
|
|
369
|
-
const selected =
|
|
370
|
-
|
|
371
|
-
|
|
383
|
+
const selected =
|
|
384
|
+
values.all || (values.migrate && !values.package)
|
|
385
|
+
? plans.filter((plan) => plan.package.publishable)
|
|
386
|
+
: [selectPlan(plans, values.package)];
|
|
372
387
|
outputPlans(selected, values.format);
|
|
373
388
|
await executePlans(
|
|
374
389
|
selected,
|
|
@@ -381,10 +396,11 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
381
396
|
yes: values.yes,
|
|
382
397
|
noBrowser: values["no-browser"],
|
|
383
398
|
openWith: nonEmpty(values["open-with"], "--open-with"),
|
|
384
|
-
keepSession: values["
|
|
399
|
+
keepSession: !values["sign-out"],
|
|
385
400
|
secretName: values["secret-name"],
|
|
386
401
|
rotateToken,
|
|
387
402
|
migrate: values.migrate,
|
|
403
|
+
deleteTokenSecrets: values["delete-token-secrets"],
|
|
388
404
|
quietBrowser: selected.some((plan) =>
|
|
389
405
|
plan.steps.some((step) =>
|
|
390
406
|
["manage-registry-token", "rotate-crates-token"].includes(step.id),
|
|
@@ -399,7 +415,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
399
415
|
verbose: values.verbose,
|
|
400
416
|
verifyRelease: values["verify-release"],
|
|
401
417
|
},
|
|
402
|
-
values.all,
|
|
418
|
+
values.all || values.migrate,
|
|
403
419
|
);
|
|
404
420
|
}
|
|
405
421
|
|
|
@@ -511,7 +527,14 @@ function outputInspection(inspection, format) {
|
|
|
511
527
|
packageInfo.exists_on_registry ? "published" : "not published yet",
|
|
512
528
|
);
|
|
513
529
|
}
|
|
514
|
-
if (packageInfo.
|
|
530
|
+
if (packageInfo.registry_http_status) {
|
|
531
|
+
details.push(
|
|
532
|
+
`registry state unknown (HTTP ${packageInfo.registry_http_status})`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
if (packageInfo.publishing_policy === "oidc-with-token-fallback") {
|
|
536
|
+
details.push("trusted publishing + token fallback");
|
|
537
|
+
} else if (packageInfo.trusted_publishing) {
|
|
515
538
|
details.push("trusted publishing");
|
|
516
539
|
}
|
|
517
540
|
process.stdout.write(
|
package/src/crates-api.mjs
CHANGED
|
@@ -350,11 +350,15 @@ export async function runCratesApiStep(session, step) {
|
|
|
350
350
|
}
|
|
351
351
|
const crate = plan.package.name;
|
|
352
352
|
const domains = signInDomains(plan.registry);
|
|
353
|
-
let page = await session.automatedPage();
|
|
353
|
+
let page = await session.automatedPage(undefined, false, true);
|
|
354
354
|
switch (step.id) {
|
|
355
355
|
case "crates-sign-in": {
|
|
356
356
|
await page.goto(CRATES_IO_URL);
|
|
357
|
-
|
|
357
|
+
const saved = await readSession(page);
|
|
358
|
+
if (!saved.signedIn || !saved.emailVerified) {
|
|
359
|
+
page = await session.visiblePage();
|
|
360
|
+
}
|
|
361
|
+
if (!saved.signedIn) {
|
|
358
362
|
// Whether imported or signed in by hand, the sign-in is this run's.
|
|
359
363
|
conditions.add("crates-signed-in");
|
|
360
364
|
if (await offerSignInImport(session, domains)) {
|
|
@@ -362,6 +366,9 @@ export async function runCratesApiStep(session, step) {
|
|
|
362
366
|
}
|
|
363
367
|
}
|
|
364
368
|
await signIn(page, options);
|
|
369
|
+
console.log(
|
|
370
|
+
"Using your crates.io session to register trusted publishers; no action needed while API requests run.",
|
|
371
|
+
);
|
|
365
372
|
return;
|
|
366
373
|
}
|
|
367
374
|
case "first-publish": {
|
|
@@ -407,7 +414,7 @@ export async function runCratesApiStep(session, step) {
|
|
|
407
414
|
case "crates-sign-out":
|
|
408
415
|
if (options.keepSession) {
|
|
409
416
|
console.log(
|
|
410
|
-
`Keeping the ${domains.join(" / ")} sign-in in the automated profile
|
|
417
|
+
`Keeping the ${domains.join(" / ")} sign-in in the automated profile; the next run reuses it. No token is kept.`,
|
|
411
418
|
);
|
|
412
419
|
} else if (page.clearCookies) {
|
|
413
420
|
await page.clearCookies(domains);
|
package/src/discovery.mjs
CHANGED
|
@@ -246,6 +246,9 @@ function withPublisher(item, workflows, publishers) {
|
|
|
246
246
|
return item;
|
|
247
247
|
}
|
|
248
248
|
const result = { ...item };
|
|
249
|
+
if (publisher.policy) {
|
|
250
|
+
result.publishing_policy = publisher.policy;
|
|
251
|
+
}
|
|
249
252
|
if (publisher.workflow) {
|
|
250
253
|
result.workflow = publisher.workflow;
|
|
251
254
|
}
|
|
@@ -266,7 +269,9 @@ function withPublisher(item, workflows, publishers) {
|
|
|
266
269
|
`no workflow publishes ${item.name} to ${item.registry}; CI releases will not reach it`,
|
|
267
270
|
]
|
|
268
271
|
: []),
|
|
269
|
-
...secrets
|
|
272
|
+
...secrets
|
|
273
|
+
.filter(() => publisher.policy !== "oidc-with-token-fallback")
|
|
274
|
+
.map((secret) => tokenSecretWarning(item.registry, secret)),
|
|
270
275
|
];
|
|
271
276
|
if (warnings.length > 0) {
|
|
272
277
|
result.warnings = [...(item.warnings ?? []), ...warnings];
|
package/src/flows.mjs
CHANGED
|
@@ -477,7 +477,7 @@ export function npmFlow(packageInfo, context) {
|
|
|
477
477
|
"sign-out",
|
|
478
478
|
"Sign out of npm",
|
|
479
479
|
"command",
|
|
480
|
-
"
|
|
480
|
+
"With --sign-out, revoke the session token created by the web login now; otherwise the next run reuses it and npm ends it after two hours.",
|
|
481
481
|
{ command: command("npm", ["logout"]), when: "tool-signed-in" },
|
|
482
482
|
),
|
|
483
483
|
removeWorktreeStep(),
|
|
@@ -555,7 +555,7 @@ function apiCratesFlow(packageInfo, context) {
|
|
|
555
555
|
"crates-sign-out",
|
|
556
556
|
"Sign the automated browser out of crates.io",
|
|
557
557
|
"api",
|
|
558
|
-
"
|
|
558
|
+
"With --sign-out, remove the sign-in this run brought into the automated profile; otherwise the next run reuses it. No token outlives the run either way.",
|
|
559
559
|
{ when: "crates-signed-in" },
|
|
560
560
|
),
|
|
561
561
|
removeWorktreeStep(),
|
package/src/github.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { delay, retryDelay } from "./request-policy.mjs";
|
|
1
2
|
import {
|
|
2
3
|
createRestClient,
|
|
3
4
|
resolveToken,
|
|
@@ -15,7 +16,9 @@ export function githubServices(options = {}) {
|
|
|
15
16
|
"sign in to GitHub with gh auth login before scanning or setting secrets",
|
|
16
17
|
);
|
|
17
18
|
}
|
|
18
|
-
const rest =
|
|
19
|
+
const rest =
|
|
20
|
+
options.rest ??
|
|
21
|
+
createRestClient({ token, fetch: retryingGithubFetch(options) });
|
|
19
22
|
const log = {
|
|
20
23
|
debug: (message) => {
|
|
21
24
|
if (options.verbose) {
|
|
@@ -41,3 +44,33 @@ export function githubServices(options = {}) {
|
|
|
41
44
|
},
|
|
42
45
|
};
|
|
43
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Retry rate-limited reads before gh-manager consumes response headers. */
|
|
49
|
+
export function retryingGithubFetch(options = {}) {
|
|
50
|
+
return async (url, init = {}) => {
|
|
51
|
+
const fetcher = options.githubFetch ?? globalThis.fetch;
|
|
52
|
+
for (let attempt = 0; ; attempt++) {
|
|
53
|
+
const response = await fetcher(url, init);
|
|
54
|
+
if (
|
|
55
|
+
![403, 429].includes(response.status) ||
|
|
56
|
+
attempt >= 3 ||
|
|
57
|
+
!["GET", "HEAD"].includes(init.method ?? "GET")
|
|
58
|
+
) {
|
|
59
|
+
return response;
|
|
60
|
+
}
|
|
61
|
+
const reset =
|
|
62
|
+
Number(response.headers?.get("x-ratelimit-reset")) * 1000 - Date.now();
|
|
63
|
+
const wait = Math.max(
|
|
64
|
+
retryDelay(response.headers?.get("retry-after"), attempt),
|
|
65
|
+
Number.isFinite(reset) ? reset : 0,
|
|
66
|
+
);
|
|
67
|
+
if (options.verbose) {
|
|
68
|
+
console.error(
|
|
69
|
+
`GitHub read rate limited (HTTP ${response.status}); retry ${attempt + 1} in ${wait}ms`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
await response.body?.cancel();
|
|
73
|
+
await (options.sleep ?? delay)(wait);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -36,11 +36,22 @@ export function publishedThroughOidc(job, log, registry) {
|
|
|
36
36
|
) && /Published\s+\S+\s+v\d/.test(log)
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
|
+
if (steps.some((step) => /token fallback/i.test(step.name))) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
39
42
|
if (registry === "npm") {
|
|
40
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
steps.some((step) => /OIDC/i.test(step.name)) &&
|
|
45
|
+
/^.*\+\s+(?:@[^/\s]+\/)?[^@\s]+@\d/m.test(log)
|
|
46
|
+
);
|
|
41
47
|
}
|
|
42
48
|
if (registry === "pypi") {
|
|
43
|
-
return
|
|
49
|
+
return (
|
|
50
|
+
steps.some(
|
|
51
|
+
(step) =>
|
|
52
|
+
/prm_oidc|OIDC/i.test(step.name) && !/fallback/.test(step.name),
|
|
53
|
+
) && /Successfully uploaded|View at:\s*https:\/\/pypi\.org/.test(log)
|
|
54
|
+
);
|
|
44
55
|
}
|
|
45
56
|
return false;
|
|
46
57
|
}
|
|
@@ -80,13 +91,6 @@ export async function cleanupMigratedSecrets(session) {
|
|
|
80
91
|
];
|
|
81
92
|
const current = await defaultWorkflows(github, slug);
|
|
82
93
|
const stillRead = names.filter((name) => readsSecret(current.files, name));
|
|
83
|
-
if (stillRead.length) {
|
|
84
|
-
session.outcome = { status: "migration-required" };
|
|
85
|
-
console.log(
|
|
86
|
-
`${slug} still reads ${stillRead.join(", ")}; merge the conversion PR before cleanup.`,
|
|
87
|
-
);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
94
|
const workflow = plan.trusted_publisher?.workflow ?? plan.package.workflow;
|
|
91
95
|
const runs = await github.runs.list(slug, {
|
|
92
96
|
workflow,
|
|
@@ -124,6 +128,24 @@ export async function cleanupMigratedSecrets(session) {
|
|
|
124
128
|
);
|
|
125
129
|
return;
|
|
126
130
|
}
|
|
131
|
+
if (!options.deleteTokenSecrets) {
|
|
132
|
+
session.outcome = {
|
|
133
|
+
status: "fallback-retained",
|
|
134
|
+
oidc_verified: true,
|
|
135
|
+
fallback_secrets: stillRead,
|
|
136
|
+
};
|
|
137
|
+
console.log(
|
|
138
|
+
`${slug}: OIDC publication verified; token secrets (${stillRead.join(", ") || "none referenced"}) and expiry metadata retained for fallback. Use --delete-token-secrets only after removing every consumer.`,
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (stillRead.length) {
|
|
143
|
+
session.outcome = { status: "migration-required" };
|
|
144
|
+
console.log(
|
|
145
|
+
`${slug} still reads ${stillRead.join(", ")}; merge the conversion PR before cleanup.`,
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
127
149
|
const deletions = [];
|
|
128
150
|
for (const name of names) {
|
|
129
151
|
const manager = github.secrets({ repo: slug });
|
|
@@ -52,6 +52,13 @@ export function packageFindings(inspection) {
|
|
|
52
52
|
manifest: item.manifest,
|
|
53
53
|
};
|
|
54
54
|
const findings = [];
|
|
55
|
+
if (item.exists_on_registry === undefined && item.registry_http_status) {
|
|
56
|
+
findings.push({
|
|
57
|
+
...common,
|
|
58
|
+
type: "registry-state-unknown",
|
|
59
|
+
evidence: { http_status: item.registry_http_status },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
55
62
|
if (item.exists_on_registry === false) {
|
|
56
63
|
findings.push({ ...common, type: "unpublished" });
|
|
57
64
|
}
|
|
@@ -267,14 +274,24 @@ async function releaseHealth(github, item, findings, onError) {
|
|
|
267
274
|
}
|
|
268
275
|
const runs = [];
|
|
269
276
|
for (const event of ["push", "workflow_dispatch"]) {
|
|
270
|
-
|
|
271
|
-
|
|
277
|
+
let listed;
|
|
278
|
+
try {
|
|
279
|
+
listed = await github.runs.list(item.repository, {
|
|
272
280
|
workflow,
|
|
273
281
|
branch: item.default_branch,
|
|
274
282
|
event,
|
|
275
283
|
limit: 1,
|
|
276
|
-
})
|
|
277
|
-
)
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!/404/.test(error.message)) {
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
listed = await github.runs.list(item.repository, {
|
|
290
|
+
workflow,
|
|
291
|
+
event,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
runs.push(...listed);
|
|
278
295
|
}
|
|
279
296
|
const latest = runs
|
|
280
297
|
.filter(
|
|
@@ -304,10 +321,17 @@ async function releaseHealth(github, item, findings, onError) {
|
|
|
304
321
|
evidence,
|
|
305
322
|
};
|
|
306
323
|
findings.push(finding);
|
|
307
|
-
evidence.matches = await github.runs
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
324
|
+
evidence.matches = await github.runs
|
|
325
|
+
.logs(latest.id, {
|
|
326
|
+
repo: item.repository,
|
|
327
|
+
grep: RELEASE_FAILURE_PATTERNS,
|
|
328
|
+
})
|
|
329
|
+
.catch((error) => {
|
|
330
|
+
if (/GitHub log download (?:404|410)/.test(error.message)) {
|
|
331
|
+
return { status: "logs-expired", matches: [] };
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
});
|
|
311
335
|
} catch (error) {
|
|
312
336
|
onError({ stage: "release", workflow, message: error.message });
|
|
313
337
|
}
|
|
@@ -351,9 +375,11 @@ export function renderScan(scan) {
|
|
|
351
375
|
const status =
|
|
352
376
|
pkg.exists_on_registry === undefined
|
|
353
377
|
? "registry state unknown"
|
|
354
|
-
: pkg.
|
|
355
|
-
? "
|
|
356
|
-
:
|
|
378
|
+
: pkg.publishing_policy === "oidc-with-token-fallback"
|
|
379
|
+
? "trusted publishing + token fallback"
|
|
380
|
+
: pkg.trusted_publishing === true
|
|
381
|
+
? "published with trusted publishing"
|
|
382
|
+
: "published; trusted publishing unknown";
|
|
357
383
|
rows.push(`${repo.repository}\t${pkg.registry}\t${pkg.name}\t${status}`);
|
|
358
384
|
}
|
|
359
385
|
}
|
|
@@ -144,8 +144,13 @@ export async function accountCommand(command, options, outputPlans) {
|
|
|
144
144
|
if (errors.length) {
|
|
145
145
|
console.error(renderScan({ repositories: errors, findings: [] }));
|
|
146
146
|
}
|
|
147
|
-
if (options.migrate
|
|
148
|
-
|
|
147
|
+
if (options.migrate) {
|
|
148
|
+
items = items.filter((item) => !item.error);
|
|
149
|
+
for (const item of errors) {
|
|
150
|
+
console.error(
|
|
151
|
+
`Skipping migration for ${item.repository}: ${item.error}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
149
154
|
}
|
|
150
155
|
const packages = items.flatMap((item) => item.inspection.packages);
|
|
151
156
|
const environment = await (options.probeEnvironment ?? probeEnvironment)({
|
|
@@ -247,9 +252,6 @@ export async function accountCommand(command, options, outputPlans) {
|
|
|
247
252
|
"some repositories could not be prepared; see scan errors",
|
|
248
253
|
);
|
|
249
254
|
}
|
|
250
|
-
if (options.migrate && scan.repositories.some((item) => item.error)) {
|
|
251
|
-
throw new Error("migration requires a complete account scan");
|
|
252
|
-
}
|
|
253
255
|
}
|
|
254
256
|
const secretRepositories = {};
|
|
255
257
|
for (const plan of plans) {
|
package/src/plan.mjs
CHANGED
|
@@ -75,7 +75,9 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
75
75
|
if (
|
|
76
76
|
(options.migrate || options.addPublishJob) &&
|
|
77
77
|
["crates-io", "npm", "pypi"].includes(packageInfo.registry) &&
|
|
78
|
-
packageInfo.
|
|
78
|
+
(packageInfo.publishing_policy
|
|
79
|
+
? packageInfo.publishing_policy !== "oidc-with-token-fallback"
|
|
80
|
+
: packageInfo.token_secrets?.length)
|
|
79
81
|
) {
|
|
80
82
|
return basePlan(inspection, packageInfo, [
|
|
81
83
|
{
|
|
@@ -84,7 +86,7 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
84
86
|
kind: "manual",
|
|
85
87
|
confirm: true,
|
|
86
88
|
description:
|
|
87
|
-
"Convert existing publishing jobs and reusable callers to OIDC, preserve the release commands and environment, and
|
|
89
|
+
"Convert existing publishing jobs and reusable callers to OIDC, preserve the release commands and environment, and retain token secrets as fallback. Review and merge the PR, then re-run setup --migrate to register trust and verify an OIDC release while retaining fallback secrets by default.",
|
|
88
90
|
},
|
|
89
91
|
]);
|
|
90
92
|
}
|
|
@@ -241,6 +243,12 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
241
243
|
: steps.filter((item) => !BOOTSTRAP_CONDITIONS.has(item.when)),
|
|
242
244
|
);
|
|
243
245
|
}
|
|
246
|
+
if (!options.deleteTokenSecrets) {
|
|
247
|
+
steps = steps.filter(
|
|
248
|
+
(step) =>
|
|
249
|
+
!["confirm-oidc-cleanup", "delete-token-secret"].includes(step.id),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
244
252
|
if (options.migrate) {
|
|
245
253
|
steps = steps.filter(
|
|
246
254
|
(step) =>
|
|
@@ -252,11 +260,11 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
252
260
|
);
|
|
253
261
|
steps.push({
|
|
254
262
|
id: "cleanup-migrated-secrets",
|
|
255
|
-
title: "Verify OIDC publication and
|
|
263
|
+
title: "Verify OIDC publication and report fallback secrets",
|
|
256
264
|
kind: "api",
|
|
257
265
|
confirm: true,
|
|
258
266
|
description:
|
|
259
|
-
"Read the default-branch workflows and successful publishing steps at its current commit.
|
|
267
|
+
"Read the default-branch workflows and successful publishing steps at its current commit. Retain token secrets and expiry metadata for fallback by default. With --delete-token-secrets, delete repository secrets only after verification and proof of non-use; delete organization secrets only after a complete fresh scan proves no remaining consumer.",
|
|
260
268
|
});
|
|
261
269
|
}
|
|
262
270
|
if (options.ref) {
|