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 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.24.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/29808e088ac8fd8374361920bbebab07a4d63ac6",
43
+ "@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/293e9a8ab9d6d91a08e95f4e09c2a0dcad9bc809",
44
44
  "browser-commander": "^0.34.0",
45
- "command-stream": "^2.4.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
@@ -118,6 +119,7 @@ export async function main(args = process.argv.slice(2)) {
118
119
  "secret-name": { type: "string" },
119
120
  "rotate-token": { type: "string" },
120
121
  migrate: { type: "boolean", default: false },
122
+ "delete-token-secrets": { type: "boolean", default: false },
121
123
  "token-expiry-days": { type: "string" },
122
124
  format: { type: "string", default: "text" },
123
125
  verbose: { type: "boolean", default: false },
@@ -273,6 +275,7 @@ export async function main(args = process.argv.slice(2)) {
273
275
  secretName: values["secret-name"],
274
276
  rotateToken,
275
277
  migrate: values.migrate,
278
+ deleteTokenSecrets: values["delete-token-secrets"],
276
279
  tokenExpiryDays,
277
280
  package: values.package,
278
281
  browser: values.browser,
@@ -288,6 +291,7 @@ export async function main(args = process.argv.slice(2)) {
288
291
  publisherEnvironment: values.environment,
289
292
  addPublishJob: values["add-publish-job"],
290
293
  migrate: values.migrate,
294
+ deleteTokenSecrets: values["delete-token-secrets"],
291
295
  manual: values.manual,
292
296
  verifyRelease: values["verify-release"],
293
297
  },
@@ -331,6 +335,7 @@ export async function main(args = process.argv.slice(2)) {
331
335
  const planOptions = {
332
336
  rotateToken,
333
337
  migrate: values.migrate,
338
+ deleteTokenSecrets: values["delete-token-secrets"],
334
339
  ref: values.ref,
335
340
  verifyRelease: values["verify-release"],
336
341
  manual: values.manual,
@@ -375,9 +380,10 @@ export async function main(args = process.argv.slice(2)) {
375
380
  }
376
381
 
377
382
  const plans = buildPlans(inspection, registries, planOptions);
378
- const selected = values.all
379
- ? plans.filter((plan) => plan.package.publishable)
380
- : [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)];
381
387
  outputPlans(selected, values.format);
382
388
  await executePlans(
383
389
  selected,
@@ -394,6 +400,7 @@ export async function main(args = process.argv.slice(2)) {
394
400
  secretName: values["secret-name"],
395
401
  rotateToken,
396
402
  migrate: values.migrate,
403
+ deleteTokenSecrets: values["delete-token-secrets"],
397
404
  quietBrowser: selected.some((plan) =>
398
405
  plan.steps.some((step) =>
399
406
  ["manage-registry-token", "rotate-crates-token"].includes(step.id),
@@ -408,7 +415,7 @@ export async function main(args = process.argv.slice(2)) {
408
415
  verbose: values.verbose,
409
416
  verifyRelease: values["verify-release"],
410
417
  },
411
- values.all,
418
+ values.all || values.migrate,
412
419
  );
413
420
  }
414
421
 
@@ -520,7 +527,14 @@ function outputInspection(inspection, format) {
520
527
  packageInfo.exists_on_registry ? "published" : "not published yet",
521
528
  );
522
529
  }
523
- 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) {
524
538
  details.push("trusted publishing");
525
539
  }
526
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": {
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/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) {
@@ -1,3 +1,4 @@
1
+ import { publishingPolicy } from "./publishing-state.mjs";
1
2
  import { readFile, realpath, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { stripComments, commandPosition } from "./source-code.mjs";
@@ -146,6 +147,76 @@ export function parseWorkflow(contents) {
146
147
  * the trusted publisher names the workflow CI actually publishes from.
147
148
  */
148
149
  export async function detectPublisher(root, workflows, registry) {
150
+ const result = await detectPublisherDetails(root, workflows, registry);
151
+ if (
152
+ ["crates-io", "npm", "pypi"].includes(registry) &&
153
+ (result.workflow || result.candidates.length)
154
+ ) {
155
+ result.policy = await publishingPolicies(root, workflows, registry);
156
+ if (result.policy !== "oidc-with-token-fallback") {
157
+ result.warnings.push(
158
+ `publishing authentication is ${result.policy}; migrate to OIDC with token fallback`,
159
+ );
160
+ }
161
+ }
162
+ return result;
163
+ }
164
+
165
+ async function publishingPolicies(root, workflows, registry) {
166
+ const parsed = new Map(
167
+ workflows.map((workflow) => [
168
+ workflow.name,
169
+ parseWorkflow(workflow.contents),
170
+ ]),
171
+ );
172
+ const policies = [];
173
+ const visit = async (name, callerGrant, seen = new Set()) => {
174
+ if (seen.has(name) || !parsed.has(name)) {
175
+ return;
176
+ }
177
+ const workflow = parsed.get(name);
178
+ const workflowGrant = grantsIdToken(
179
+ childBlock(workflow.header, 0, "permissions"),
180
+ );
181
+ for (const job of workflow.jobs) {
182
+ const own =
183
+ grantsIdToken(
184
+ childBlock(job.lines, childIndent(job.lines), "permissions"),
185
+ ) ?? workflowGrant;
186
+ const grant =
187
+ callerGrant === undefined
188
+ ? (own ?? false)
189
+ : callerGrant && (own ?? true);
190
+ const called = LOCAL_WORKFLOW.exec(
191
+ job.lines.find((line) => LOCAL_WORKFLOW.test(line)) ?? "",
192
+ )?.[1];
193
+ if (called) {
194
+ await visit(called, grant, new Set([...seen, name]));
195
+ } else if (await jobPublishes(root, workflow, job, registry)) {
196
+ policies.push(
197
+ publishingPolicy(
198
+ [[...workflow.header, ...job.lines].join("\n")],
199
+ registry,
200
+ grant,
201
+ ),
202
+ );
203
+ }
204
+ }
205
+ };
206
+ for (const workflow of workflows) {
207
+ if (!onlyCalled(parsed.get(workflow.name).header)) {
208
+ await visit(workflow.name);
209
+ }
210
+ }
211
+ const unique = [...new Set(policies)];
212
+ return unique.length === 1
213
+ ? unique[0]
214
+ : unique.length
215
+ ? "mixed-authentication"
216
+ : "authentication-unverified";
217
+ }
218
+
219
+ async function detectPublisherDetails(root, workflows, registry) {
149
220
  const parsedWorkflows = new Map(
150
221
  workflows.map((workflow) => [
151
222
  workflow.name,
@@ -289,7 +360,7 @@ export function tokenSecrets(workflows, registry) {
289
360
  /** Explains how to replace a long-lived token secret with trusted publishing. */
290
361
  export function tokenSecretWarning(registry, { workflow, secret }) {
291
362
  return TRUSTED_REGISTRIES.includes(registry)
292
- ? `${workflow} reads secrets.${secret}; publish with ${TRUSTED_PUBLISHING_HINTS.get(registry) ?? `${registry} trusted publishing (id-token: write)`} and delete the long-lived token after verification`
363
+ ? `${workflow} reads secrets.${secret}; publish with ${TRUSTED_PUBLISHING_HINTS.get(registry) ?? `${registry} trusted publishing (id-token: write)`} and retain a rotated token as fallback`
293
364
  : `${workflow} reads secrets.${secret}; ensure a scoped expiring publishing token through gh-manager`;
294
365
  }
295
366
 
@@ -459,6 +530,25 @@ function scriptPublishes(lines, registry) {
459
530
  ) {
460
531
  return true;
461
532
  }
533
+ const commands = new Set();
534
+ for (const line of lines) {
535
+ const declaration = /\b(?:const|let|var)\s+(\w+)\s*=/.exec(line);
536
+ if (
537
+ declaration &&
538
+ new RegExp(pattern.source).test(line.slice(declaration.index))
539
+ ) {
540
+ commands.add(declaration[1]);
541
+ }
542
+ if (
543
+ [...commands].some((name) =>
544
+ new RegExp(
545
+ `(?:exec(?:Sync)?|spawn(?:Sync)?|run|system)\\s*\\(\\s*${name}\\b`,
546
+ ).test(line),
547
+ )
548
+ ) {
549
+ return true;
550
+ }
551
+ }
462
552
  const patterns = SCRIPT_PATTERNS.get(registry);
463
553
  if (!patterns) {
464
554
  return false;
@@ -0,0 +1,37 @@
1
+ /** Describe release authentication, independently from the last registry publication. */
2
+ export function publishingPolicy(contents, registry, grantOverride) {
3
+ const text = contents.join("\n");
4
+ const token =
5
+ /secrets\.(?:CARGO_(?:REGISTRY_)?TOKEN|NPM_(?:AUTH_)?TOKEN|NODE_AUTH_TOKEN|PYPI_(?:API_)?TOKEN)/.test(
6
+ text,
7
+ );
8
+ const grant =
9
+ grantOverride ?? /id-token:\s*write|permissions:\s*write-all/.test(text);
10
+ const oidc =
11
+ grant &&
12
+ (registry === "crates-io"
13
+ ? text
14
+ .split(/(?=^[ \t]*- )/m)
15
+ .some(
16
+ (step) =>
17
+ /crates-io-auth-action@/.test(step) &&
18
+ !/\bif:[\s\S]*\bvars\./.test(step),
19
+ )
20
+ : registry === "pypi"
21
+ ? /pypa\/gh-action-pypi-publish@/.test(text)
22
+ : /npm publish|npm run|node .*(?:publish|release)/.test(text));
23
+ const fallback =
24
+ registry === "crates-io"
25
+ ? /outputs\.token\s*\|\|\s*secrets\./.test(text) &&
26
+ /continue-on-error:\s*true/.test(text)
27
+ : /outcome == 'failure'/.test(text) &&
28
+ /continue-on-error:\s*true/.test(text);
29
+ if (oidc && token && fallback) {
30
+ return "oidc-with-token-fallback";
31
+ }
32
+ return oidc
33
+ ? "oidc-only"
34
+ : token
35
+ ? "token-only"
36
+ : "authentication-unverified";
37
+ }