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 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.33.2`, its latest release (#28). A
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.23.1",
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/29808e088ac8fd8374361920bbebab07a4d63ac6",
44
- "browser-commander": "^0.33.2",
45
- "command-stream": "^2.4.0",
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
  },
@@ -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(browser, profile, verbose = false, domains = []) {
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: false,
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 = spawn(file, args, {
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 token jobs in PRs, then verify OIDC and clean up secrets
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
- --keep-session Stay signed in after setup: npm keeps its
72
- session token in npm's user configuration
73
- until npm logout; the shared browser profile
74
- keeps registry sessions across packages
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["keep-session"],
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 = values.all
370
- ? plans.filter((plan) => plan.package.publishable)
371
- : [selectPlan(plans, values.package)];
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["keep-session"],
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.trusted_publishing) {
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(
@@ -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
- if (!(await readSession(page)).signedIn) {
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 (--keep-session); the next run reuses it. No token is kept.`,
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.map((secret) => tokenSecretWarning(item.registry, secret)),
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
- "Revoke the session token created by the web login.",
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
- "Remove the sign-in this run brought into the automated profile; --keep-session keeps it. No token outlives the run either way.",
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 = options.rest ?? createRestClient({ token });
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 /^.*\+\s+(?:@[^/\s]+\/)?[^@\s]+@\d/m.test(log);
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 /Successfully uploaded|View at:\s*https:\/\/pypi\.org/.test(log);
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
- runs.push(
271
- ...(await github.runs.list(item.repository, {
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.logs(latest.id, {
308
- repo: item.repository,
309
- grep: RELEASE_FAILURE_PATTERNS,
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.trusted_publishing === true
355
- ? "published with trusted publishing"
356
- : "published; trusted publishing unknown";
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 && errors.length) {
148
- throw new Error("migration requires a complete account scan");
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.token_secrets?.length
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 remove legacy token wiring. Review and merge the PR, then re-run setup --migrate to register trust and verify an OIDC release before deleting secrets.",
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 clean up legacy secrets",
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. Delete repository token secrets only after verification; delete organization secrets only after a complete fresh scan proves no remaining consumer.",
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) {