package-registry-manager 0.25.1 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/0b3ceb1590be7eb0340ae088730698b5ae9bebfc",
44
- "browser-commander": "^0.34.0",
44
+ "browser-commander": "^0.34.1",
45
45
  "command-stream": "^2.6.0",
46
46
  "playwright": "^1.63.0",
47
47
  "selenium-webdriver": "^4.50.0"
@@ -22,9 +22,44 @@ export async function cppDistribution(
22
22
  }
23
23
  }
24
24
  const project =
25
- /\bproject\s*\(\s*([\w.-]+)\s+VERSION\s+(\d+(?:\.\d+)+)/i.exec(cmake);
25
+ /\bproject\s*\(\s*([\w.-]+)\s+VERSION\s+(\d+(?:\.\d+)+|\$\{(\w+)\})/i.exec(
26
+ cmake,
27
+ );
26
28
  let name = project?.[1];
27
- let version = project?.[2];
29
+ let version = project?.[3] ? null : project?.[2];
30
+ let versionSource = null;
31
+ if (project?.[3]) {
32
+ // `file(READ "${CMAKE_CURRENT_SOURCE_DIR}/VERSION" VAR)` is resolved by
33
+ // reading that one sibling file; nothing in the CMake file is executed.
34
+ const read = new RegExp(
35
+ `\\bfile\\s*\\(\\s*READ\\s+"?\\$\\{CMAKE_CURRENT_(?:SOURCE|LIST)_DIR\\}/([\\w.-]+)"?\\s+${project[3]}\\s*\\)`,
36
+ "i",
37
+ ).exec(cmake);
38
+ const literal = new RegExp(
39
+ `\\bset\\s*\\(\\s*${project[3]}\\s+"?(\\d+(?:\\.\\d+)+)"?\\s*\\)`,
40
+ "i",
41
+ ).exec(cmake);
42
+ if (read) {
43
+ try {
44
+ const value = (
45
+ await readFile(path.join(path.dirname(manifestPath), read[1]), "utf8")
46
+ ).trim();
47
+ if (/^\d+(?:\.\d+)+$/.test(value)) {
48
+ version = value;
49
+ versionSource = path.posix.join(
50
+ path.posix.dirname(manifest),
51
+ read[1],
52
+ );
53
+ }
54
+ } catch (error) {
55
+ if (error.code !== "ENOENT") {
56
+ throw error;
57
+ }
58
+ }
59
+ } else if (literal) {
60
+ version = literal[1];
61
+ }
62
+ }
28
63
  const registry =
29
64
  filename === "vcpkg.json"
30
65
  ? "vcpkg"
@@ -62,9 +97,11 @@ export async function cppDistribution(
62
97
  manifest,
63
98
  publishable: true,
64
99
  distribution: {
65
- version_source: project
66
- ? path.posix.join(path.posix.dirname(manifest), "CMakeLists.txt")
67
- : manifest,
100
+ version_source:
101
+ versionSource ??
102
+ (project
103
+ ? path.posix.join(path.posix.dirname(manifest), "CMakeLists.txt")
104
+ : manifest),
68
105
  provider_status: "unverified",
69
106
  ...(registry === "conan"
70
107
  ? {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Registries without public OIDC publishing: the exact credential set a
3
+ * workflow needs, which secrets it already references, and where values may
4
+ * live. Values are only ever held in memory or written to encrypted GitHub
5
+ * secrets; nothing here rotates, revokes or deletes an existing credential.
6
+ */
7
+ export const CREDENTIAL_SETS = Object.freeze({
8
+ "maven-central": {
9
+ oidc: "unsupported",
10
+ oidc_note:
11
+ "Sonatype Central Portal has no OIDC trusted publishing; publish with a Central user-token pair and a GPG signing key.",
12
+ namespace: "verified Central Portal namespace matching the groupId",
13
+ roles: [
14
+ {
15
+ role: "central-username",
16
+ candidates: [
17
+ "CENTRAL_USERNAME",
18
+ "MAVEN_CENTRAL_USERNAME",
19
+ "OSSRH_USERNAME",
20
+ "SONATYPE_USERNAME",
21
+ "MAVEN_USERNAME",
22
+ ],
23
+ default: "CENTRAL_USERNAME",
24
+ },
25
+ {
26
+ role: "central-token",
27
+ candidates: [
28
+ "CENTRAL_TOKEN",
29
+ "MAVEN_CENTRAL_TOKEN",
30
+ "MAVEN_CENTRAL_PASSWORD",
31
+ "OSSRH_TOKEN",
32
+ "OSSRH_PASSWORD",
33
+ "SONATYPE_TOKEN",
34
+ "MAVEN_PASSWORD",
35
+ ],
36
+ default: "CENTRAL_TOKEN",
37
+ },
38
+ {
39
+ role: "gpg-private-key",
40
+ candidates: ["GPG_PRIVATE_KEY", "MAVEN_GPG_PRIVATE_KEY", "SIGNING_KEY"],
41
+ default: "GPG_PRIVATE_KEY",
42
+ },
43
+ {
44
+ role: "gpg-passphrase",
45
+ candidates: [
46
+ "GPG_PASSPHRASE",
47
+ "MAVEN_GPG_PASSPHRASE",
48
+ "SIGNING_PASSWORD",
49
+ ],
50
+ default: "GPG_PASSPHRASE",
51
+ },
52
+ ],
53
+ },
54
+ packagist: {
55
+ oidc: "unsupported",
56
+ oidc_note:
57
+ "Public packagist.org has no OIDC publishing (Private Packagist OIDC is a separate product); releases come from tags via the GitHub hook or the update-package API.",
58
+ roles: [
59
+ {
60
+ role: "packagist-username",
61
+ candidates: ["PACKAGIST_USERNAME"],
62
+ default: "PACKAGIST_USERNAME",
63
+ optional: true,
64
+ },
65
+ {
66
+ role: "packagist-api-token",
67
+ candidates: ["PACKAGIST_TOKEN", "PACKAGIST_API_TOKEN"],
68
+ default: "PACKAGIST_TOKEN",
69
+ optional: true,
70
+ },
71
+ ],
72
+ },
73
+ });
74
+
75
+ const SECRET = /\bsecrets\.([A-Za-z_][A-Za-z0-9_]*)\b/g;
76
+
77
+ /** Structured requirements resolved against the secrets the workflows read. */
78
+ export function credentialRequirements(registry, workflows = []) {
79
+ const set = CREDENTIAL_SETS[registry];
80
+ if (!set) {
81
+ return null;
82
+ }
83
+ const referenced = new Set(
84
+ workflows.flatMap((workflow) =>
85
+ [...workflow.contents.replace(/^\s*#.*$/gm, "").matchAll(SECRET)].map(
86
+ (match) => match[1],
87
+ ),
88
+ ),
89
+ );
90
+ return {
91
+ oidc: set.oidc,
92
+ oidc_note: set.oidc_note,
93
+ ...(set.namespace ? { namespace: set.namespace } : {}),
94
+ storage: "memory-or-encrypted-github-secret",
95
+ secrets: set.roles.map((role) => {
96
+ const found = role.candidates.find((name) => referenced.has(name));
97
+ return {
98
+ role: role.role,
99
+ secret: found ?? role.default,
100
+ referenced: Boolean(found),
101
+ ...(role.optional ? { optional: true } : {}),
102
+ };
103
+ }),
104
+ };
105
+ }
package/src/discovery.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { credentialRequirements } from "./credential-requirements.mjs";
1
2
  import { auditConfig, applyAuditConfig } from "./audit-config.mjs";
2
3
  import { cppDistribution } from "./cpp-distribution.mjs";
3
4
  import { markIdentityConflicts } from "./identity-conflicts.mjs";
@@ -9,6 +10,7 @@ import { packageWorkflows, auditWrappers } from "./package-coverage.mjs";
9
10
  import { REGISTRIES } from "./model.mjs";
10
11
  import { inspectManifestRepositories } from "./repository-identity.mjs";
11
12
  import {
13
+ RELEASE_EVIDENCE,
12
14
  TRUSTED_REGISTRIES,
13
15
  detectPublisher,
14
16
  tokenSecretWarning,
@@ -198,10 +200,10 @@ export async function inspectRepository(repository, options = {}) {
198
200
  }
199
201
  }
200
202
  }
201
- publishers.set(
202
- "maven-central",
203
- await detectPublisher(root, workflows, "maven-central"),
204
- );
203
+ for (const registry of SCOPED_REGISTRIES) {
204
+ publishers.set(registry, await detectPublisher(root, workflows, registry));
205
+ }
206
+ canonicalComposer(parsed);
205
207
  const containerFiles = dockerfiles
206
208
  .map((item) => relativePath(root, item))
207
209
  .filter((item) => !ignored(item) && kept(item, true));
@@ -237,8 +239,9 @@ export async function inspectRepository(repository, options = {}) {
237
239
  )
238
240
  .map(async (item) => {
239
241
  const scoped =
240
- counts.get(item.registry) > 1 &&
241
- TRUSTED_REGISTRIES.includes(item.registry)
242
+ (counts.get(item.registry) > 1 &&
243
+ TRUSTED_REGISTRIES.includes(item.registry)) ||
244
+ SCOPED_REGISTRIES.includes(item.registry)
242
245
  ? new Map([
243
246
  [
244
247
  item.registry,
@@ -250,10 +253,37 @@ export async function inspectRepository(repository, options = {}) {
250
253
  ],
251
254
  ])
252
255
  : publishers;
253
- return withPublisher(item, workflows, scoped);
256
+ return withPublisher(
257
+ item,
258
+ workflows,
259
+ item.publisher_scope
260
+ ? new Map([
261
+ [
262
+ item.registry,
263
+ await detectPublisher(
264
+ root,
265
+ packageWorkflows(workflows, {
266
+ ...item,
267
+ manifest: item.publisher_scope,
268
+ }),
269
+ item.registry,
270
+ ),
271
+ ],
272
+ ])
273
+ : scoped,
274
+ );
254
275
  }),
255
276
  )
256
277
  )
278
+ .filter(
279
+ (item) =>
280
+ !internalWorkload(item) ||
281
+ config.publish?.includes(item.manifest) ||
282
+ skip(
283
+ item.manifest,
284
+ `internal ${internalWorkload(item)} workload: no workflow publishes it and no source decision lists it under "publish"`,
285
+ ),
286
+ )
257
287
  .concat(
258
288
  containerPackages(containerFiles, workflows, coordinates),
259
289
  chromePackages,
@@ -306,6 +336,47 @@ function releaseWorkflow(publishers) {
306
336
  return null;
307
337
  }
308
338
 
339
+ /** Registries whose publication evidence is scoped to one manifest. */
340
+ const SCOPED_REGISTRIES = ["maven-central", "packagist", "go-modules", "cmake"];
341
+
342
+ /** Benchmark/fixture manifests are internal unless a workflow releases them. */
343
+ function internalWorkload(item) {
344
+ if (item.workflow) {
345
+ return null;
346
+ }
347
+ const segment = item.manifest
348
+ .split("/")
349
+ .slice(0, -1)
350
+ .find((part) => /^(?:bench(?:mark)?(?:es|s)?|perf)$/i.test(part));
351
+ return segment ? "benchmark" : null;
352
+ }
353
+
354
+ /**
355
+ * Packagist reads only the repository-root composer.json, so a same-name
356
+ * descriptor in a subdirectory is a development copy of that distribution.
357
+ * The root keeps the copy's declared version and the copy's publishing job.
358
+ */
359
+ function canonicalComposer(parsed) {
360
+ const composer = parsed.filter(
361
+ (item) => item?.registry === "packagist" && !item.discovery_skip,
362
+ );
363
+ const root = composer.find((item) => item.manifest === "composer.json");
364
+ if (!root) {
365
+ return;
366
+ }
367
+ for (const copy of composer.filter(
368
+ (item) => item !== root && item.name === root.name,
369
+ )) {
370
+ copy.discovery_skip = `Packagist reads only the repository-root composer.json; ${copy.manifest} is a development descriptor of ${root.name}`;
371
+ copy.publishable = false;
372
+ if (!root.version && copy.version) {
373
+ root.version = copy.version;
374
+ root.version_source = copy.manifest;
375
+ }
376
+ root.publisher_scope ??= copy.manifest;
377
+ }
378
+ }
379
+
309
380
  function withPublisher(item, workflows, publishers) {
310
381
  const publisher = item.publishable ? publishers.get(item.registry) : null;
311
382
  if (!publisher) {
@@ -321,16 +392,32 @@ function withPublisher(item, workflows, publishers) {
321
392
  if (publisher.jobs.length > 0) {
322
393
  result.workflow_jobs = publisher.jobs;
323
394
  }
395
+ if (publisher.callees?.length) {
396
+ // Reusable workflows publish under the caller's identity; the callee
397
+ // jobs are kept so setup and recovery act on the actual publishing job.
398
+ result.workflow_callees = publisher.callees;
399
+ }
400
+ if (publisher.gate) {
401
+ result.authentication_gate = publisher.gate;
402
+ }
324
403
  if (publisher.environment) {
325
404
  result.environment = publisher.environment;
326
405
  }
327
406
  if (publisher.candidates.length > 1) {
328
407
  result.workflow_candidates = publisher.candidates;
329
408
  }
409
+ const credentials = credentialRequirements(item.registry, workflows);
410
+ if (credentials) {
411
+ result.credential_requirements = credentials;
412
+ }
330
413
  const secrets = tokenSecrets(workflows, item.registry);
331
414
  const warnings = [
332
415
  ...publisher.warnings,
333
- ...(!publisher.workflow && publisher.candidates.length === 0
416
+ // Tags and registry hooks release Go modules, Packagist packages and
417
+ // CMake archives without a workflow, so only evidence is reported.
418
+ ...(!publisher.workflow &&
419
+ publisher.candidates.length === 0 &&
420
+ !RELEASE_EVIDENCE.has(item.registry)
334
421
  ? [
335
422
  `no workflow publishes ${item.name} to ${item.registry}; CI releases will not reach it`,
336
423
  ]
@@ -399,13 +486,22 @@ async function parseManifest(manifestPath, manifest, root) {
399
486
  capture(contents, /\bversion\s*=\s*['"]([^'"]+)/),
400
487
  manifest,
401
488
  );
402
- case "go.mod":
403
- return packageInfo(
489
+ case "go.mod": {
490
+ const item = packageInfo(
404
491
  "go-modules",
405
492
  capture(contents, /^\s*module\s+(\S+)/m) ?? "unknown-go-module",
406
- null,
493
+ await versionFile(manifestPath),
407
494
  manifest,
408
495
  );
496
+ if (item.version) {
497
+ item.version = `v${item.version.replace(/^v/, "")}`;
498
+ item.version_source = path.posix.join(
499
+ path.posix.dirname(manifest),
500
+ "VERSION",
501
+ );
502
+ }
503
+ return item;
504
+ }
409
505
  case "pom.xml":
410
506
  return parseMaven(contents, manifest);
411
507
  case "build.gradle":
@@ -541,11 +637,26 @@ function parseDotnet(contents, manifestPath, manifest) {
541
637
  xmlTag(contents, "PackageId") ??
542
638
  xmlTag(contents, "AssemblyName") ??
543
639
  path.basename(manifestPath, path.extname(manifestPath)),
544
- xmlTag(contents, "PackageVersion") ?? xmlTag(contents, "Version"),
640
+ dotnetVersion(contents),
545
641
  manifest,
546
642
  );
547
643
  }
548
644
 
645
+ function dotnetVersion(contents) {
646
+ const literal = (tag) => {
647
+ const value = xmlTag(contents, tag);
648
+ return value && !value.includes("$(") ? value : null;
649
+ };
650
+ const explicit = literal("PackageVersion") ?? literal("Version");
651
+ if (explicit) {
652
+ return explicit;
653
+ }
654
+ // MSBuild derives Version from VersionPrefix and VersionSuffix.
655
+ const prefix = literal("VersionPrefix");
656
+ const suffix = literal("VersionSuffix");
657
+ return prefix ? (suffix ? `${prefix}-${suffix}` : prefix) : null;
658
+ }
659
+
549
660
  function parseMaven(contents, manifest) {
550
661
  const artifact = xmlTag(contents, "artifactId") ?? "unknown-maven-artifact";
551
662
  const group = xmlTag(contents, "groupId");
@@ -623,6 +734,21 @@ function packageInfo(
623
734
  return result;
624
735
  }
625
736
 
737
+ /** A plain sibling VERSION file, as release tooling for Go/C++ often keeps. */
738
+ export async function versionFile(manifestPath) {
739
+ try {
740
+ const value = (
741
+ await readManifest(path.join(path.dirname(manifestPath), "VERSION"))
742
+ ).trim();
743
+ return /^v?\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?$/.test(value) ? value : null;
744
+ } catch (error) {
745
+ if (error.code === "ENOENT") {
746
+ return null;
747
+ }
748
+ throw error;
749
+ }
750
+ }
751
+
626
752
  // Editors on Windows may save a byte order mark; npm and Cargo accept it.
627
753
  async function readManifest(manifest) {
628
754
  return (await readFile(manifest, "utf8")).replace(/^\uFEFF/, "");
@@ -1,3 +1,4 @@
1
+ import { credentialRequirements } from "./credential-requirements.mjs";
1
2
  import { credentialSteps } from "./credential-cycle.mjs";
2
3
  import { tokenSecretSteps } from "./tokens.mjs";
3
4
 
@@ -76,20 +77,100 @@ export function tokenFlow(item) {
76
77
  ]
77
78
  : [];
78
79
  if (item.registry === "maven-central") {
80
+ const group = /^([\w.-]+):/.exec(item.name ?? "")?.[1];
79
81
  steps.push({
80
82
  id: "verify-namespace",
81
- title: "Verify a Central namespace",
83
+ title: `Verify the Central namespace${group ? ` ${group}` : ""}`,
82
84
  kind: "browser",
83
85
  description:
84
- "Sign in to the Central Portal and verify the namespace used by the package coordinates.",
86
+ "Sign in to the Central Portal and verify the namespace used by the package coordinates. Publishing stays blocked until the namespace reads back as verified.",
85
87
  url: "https://central.sonatype.com/publishing/namespaces",
88
+ ...(group ? { namespace: group } : {}),
89
+ state: "manual-prerequisite",
86
90
  });
91
+ return steps.concat(mavenCredentialSteps(item));
87
92
  }
88
93
  return steps.concat(
89
94
  credentialSteps(item.registry, item.token_secrets?.[0] ?? item.registry),
90
95
  );
91
96
  }
92
97
 
98
+ /**
99
+ * Central publishing needs a provider-specific pair plus signing material; no
100
+ * OIDC is claimed. Missing secrets are requested, present ones are kept, and
101
+ * nothing is rotated or revoked.
102
+ */
103
+ export function mavenCredentialSteps(item) {
104
+ const requirements =
105
+ item.credential_requirements ?? credentialRequirements("maven-central");
106
+ const pick = (...roles) =>
107
+ requirements.secrets.filter((entry) => roles.includes(entry.role));
108
+ const describe = (entries) =>
109
+ entries.map((entry) => entry.secret).join(" + ");
110
+ const pair = pick("central-username", "central-token");
111
+ const signing = pick("gpg-private-key", "gpg-passphrase");
112
+ return [
113
+ {
114
+ id: "ensure-central-credential-pair",
115
+ title: `Ensure the Central user-token pair ${describe(pair)}`,
116
+ kind: "api",
117
+ confirm: true,
118
+ secrets: pair,
119
+ oidc: "unsupported",
120
+ description: `${requirements.oidc_note} Generate a Central Portal user token (username + password pair) only when ${describe(pair)} is missing, hold the values in memory, and store them as encrypted GitHub secrets through gh-manager. Existing secrets are kept; nothing is rotated or revoked.`,
121
+ url: "https://central.sonatype.com/usertoken",
122
+ },
123
+ {
124
+ id: "ensure-signing-key",
125
+ title: `Ensure the signing prerequisites ${describe(signing)}`,
126
+ kind: "manual",
127
+ confirm: true,
128
+ secrets: signing,
129
+ state: "manual-prerequisite",
130
+ description:
131
+ "Maven Central rejects unsigned artifacts. Provide an armored GPG private key whose public key is on a public keyserver, and its passphrase, as encrypted GitHub secrets; keys are never written to disk or printed.",
132
+ },
133
+ ];
134
+ }
135
+
136
+ /** Public Packagist: tags plus hook or update API; no OIDC, explicit states otherwise. */
137
+ export function packagistSteps(item) {
138
+ const requirements =
139
+ item.credential_requirements ?? credentialRequirements("packagist");
140
+ const api = requirements.secrets;
141
+ const referenced = api.every((entry) => entry.referenced);
142
+ return [
143
+ {
144
+ id: "verify-packagist-package",
145
+ title: "Read back the Packagist package and exact stable tag",
146
+ kind: "check",
147
+ description: `Read https://repo.packagist.org/p2/${item.name}.json and require the exact stable version ${item.version ?? "(unresolved)"} from a tag; dev branches do not count.`,
148
+ url: `https://packagist.org/packages/${item.name}`,
149
+ },
150
+ {
151
+ id: "packagist-github-hook",
152
+ title: "Confirm the Packagist GitHub integration",
153
+ kind: "manual",
154
+ state: "manual-prerequisite",
155
+ description:
156
+ 'Packagist updates on tag pushes through its GitHub hook/app integration. GitHub does not expose that installation to this tool, so confirm it on the package page (no "not auto-updated" warning).',
157
+ url: `https://packagist.org/packages/${item.name}`,
158
+ },
159
+ {
160
+ id: "packagist-update-api",
161
+ title: referenced
162
+ ? `Keep the update-package API path ${api.map((entry) => entry.secret).join(" + ")}`
163
+ : "Packagist update-package API is not configured",
164
+ kind: "manual",
165
+ state: referenced ? "configured" : "unsupported-without-credentials",
166
+ secrets: api,
167
+ oidc: "unsupported",
168
+ description: `${requirements.oidc_note} ${referenced ? "The workflow calls the update-package API; keep both secrets as encrypted GitHub secrets. Nothing is rotated or revoked." : "Rely on the GitHub hook, or add PACKAGIST_USERNAME and PACKAGIST_TOKEN (Packagist profile API token) as encrypted GitHub secrets for the update-package API."}`,
169
+ url: "https://packagist.org/profile/",
170
+ },
171
+ ];
172
+ }
173
+
93
174
  /** NuGet uses explicit account policy and preserves provided fallback credentials. */
94
175
  export function nugetFlow(item, context) {
95
176
  return [
package/src/nuget-ci.mjs CHANGED
@@ -3,6 +3,37 @@ import { repositorySlug } from "./repository-identity.mjs";
3
3
  import { AUTH_FAILURE_PATTERNS } from "./publishing-policy.mjs";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
+ /** Job names (or reusable-call segments) whose skip would hide the release. */
7
+ export function requiredJobs(pkg = {}) {
8
+ return [
9
+ ...(pkg.workflow_jobs ?? []),
10
+ ...(pkg.workflow_callees ?? []).map((chain) => chain.split(" -> ").at(-1)),
11
+ ].filter(Boolean);
12
+ }
13
+
14
+ /**
15
+ * Failed or cancelled jobs always block. A skipped job blocks only when it is a
16
+ * required release job (or when the required set is unknown); PR-only reviews
17
+ * and unchanged-component jobs legitimately skip on a push to main.
18
+ */
19
+ export function unmetJobs(jobs, required) {
20
+ const isRequired = (job) =>
21
+ !required.length ||
22
+ job.name.split(" / ").some((segment) => required.includes(segment.trim()));
23
+ const unmet = jobs.filter(
24
+ (job) =>
25
+ !["success", "skipped"].includes(job.conclusion) ||
26
+ (job.conclusion === "skipped" && isRequired(job)),
27
+ );
28
+ if (
29
+ required.length &&
30
+ !jobs.some((job) => isRequired(job) && job.conclusion === "success")
31
+ ) {
32
+ unmet.push({ name: required.join(", "), conclusion: "missing" });
33
+ }
34
+ return unmet;
35
+ }
36
+
6
37
  /** Fresh main-revision CI, actual method and exact public consumers are separate gates. */
7
38
  export async function verifyNugetRelease(session) {
8
39
  const github = session.options.github ?? githubServices(session.options);
@@ -116,7 +147,7 @@ export async function verifyNugetRelease(session) {
116
147
  run.status !== "completed" ||
117
148
  run.conclusion !== "success" ||
118
149
  !jobs.length ||
119
- jobs.some((job) => job.conclusion !== "success")
150
+ unmetJobs(jobs, requiredJobs(session.plan.package)).length
120
151
  ) {
121
152
  throw new Error(
122
153
  `NuGet current-main pipeline has incomplete gates: ${run.html_url}`,
@@ -1,5 +1,99 @@
1
1
  import { nugetWorkflowSteps } from "./nuget-workflow.mjs";
2
2
 
3
+ export const NUGET_PREREQUISITE_EDIT =
4
+ "Split the NuGet publish step so one step runs only `dotnet nuget push <artifact glob> --api-key ... --source https://api.nuget.org/v3/index.json` (optionally behind a `should_publish` guard, `${A:-$B}` key selection, a key-presence check and `published=` outputs); pack, tests and any other command must stay in earlier steps.";
5
+
6
+ function prerequisite(reason) {
7
+ const error = new Error(
8
+ `${reason}. Prerequisite edit: ${NUGET_PREREQUISITE_EDIT}`,
9
+ );
10
+ error.code = "nuget-prerequisite-edit";
11
+ error.prerequisite = NUGET_PREREQUISITE_EDIT;
12
+ return error;
13
+ }
14
+
15
+ /**
16
+ * Recognize the guarded multiline push used by links-notation style workflows.
17
+ * Every line must be the guard, key selection, presence check, notices,
18
+ * `published=` outputs or one nuget.org push; anything else is not migrated.
19
+ */
20
+ export function guardedNugetPush(step) {
21
+ const start = step.findIndex((line) =>
22
+ /^\s*-?\s*run:\s*\|[-+]?\s*$/.test(line),
23
+ );
24
+ if (start < 0) {
25
+ return null;
26
+ }
27
+ const level = step[start].search(/\S/);
28
+ const body = [];
29
+ for (const line of step.slice(start + 1)) {
30
+ if (line.trim() && line.search(/\S/) <= level) {
31
+ break;
32
+ }
33
+ body.push(line.trim());
34
+ }
35
+ const joined = body.join("\n").replace(/\\\n\s*/g, " ");
36
+ const lines = joined
37
+ .split("\n")
38
+ .filter((line) => line && !line.startsWith("#"));
39
+ let guard = null;
40
+ let push = null;
41
+ let block = null;
42
+ for (const line of lines) {
43
+ const opened =
44
+ /^if \[ "\$\{\{\s*([\w.-]+)\s*\}\}" != "true" \]; then$/.exec(line);
45
+ if (opened && !block && !guard) {
46
+ guard = opened[1];
47
+ block = "guard";
48
+ continue;
49
+ }
50
+ if (/^if \[ -z "\$\w+" \]; then$/.test(line) && !block) {
51
+ block = "missing";
52
+ continue;
53
+ }
54
+ if (line === "fi" && block) {
55
+ block = null;
56
+ continue;
57
+ }
58
+ if (block === "guard" && /^exit 0$/.test(line)) {
59
+ continue;
60
+ }
61
+ if (block === "missing" && /^exit 1$/.test(line)) {
62
+ continue;
63
+ }
64
+ if (
65
+ /^set -[euxo pipefail]+$/.test(line) ||
66
+ /^\w+="\$\{\w+:-\$\w+\}"$/.test(line) ||
67
+ /^echo "[^"`$]*(?:\$\{\{[^}]*\}\}[^"`$]*)*"(?: >> "\$GITHUB_OUTPUT")?$/.test(
68
+ line,
69
+ )
70
+ ) {
71
+ continue;
72
+ }
73
+ const command =
74
+ /^dotnet nuget push (?:"([^"]+)"|'([^']+)'|(\S+))(.*)$/.exec(line);
75
+ if (command && !push && !block) {
76
+ push = command;
77
+ continue;
78
+ }
79
+ throw prerequisite(
80
+ `NuGet publish script contains an unreviewed command: ${line}`,
81
+ );
82
+ }
83
+ if (!push || block) {
84
+ throw prerequisite(
85
+ "NuGet multiline step has no single recognizable artifact push",
86
+ );
87
+ }
88
+ return {
89
+ guard,
90
+ glob: push[1] ?? push[2] ?? push[3],
91
+ options: push[4],
92
+ start,
93
+ level,
94
+ };
95
+ }
96
+
3
97
  /** Preserve validation, build, artifact and step conditions when replacing a direct push. */
4
98
  export function convertNugetStep(step, packages, index) {
5
99
  if (packages.length !== 1) {
@@ -7,13 +101,17 @@ export function convertNugetStep(step, packages, index) {
7
101
  "NuGet migration requires a package-specific publishing step; ambiguous multi-package globs need explicit review",
8
102
  );
9
103
  }
104
+ const guarded = guardedNugetPush(step);
105
+ if (guarded) {
106
+ return convertGuarded(step, packages, index, guarded);
107
+ }
10
108
  const run = step.find((line) => /^\s*-?\s*run:/.test(line));
11
109
  const push =
12
110
  /^\s*-?\s*run:\s*dotnet\s+nuget\s+push\s+(?:"([^"\n]+)"|'([^'\n]+)'|([^\s]+))\s*(.*)$/.exec(
13
111
  run ?? "",
14
112
  );
15
113
  if (!push || /[;\n`]|\$\(/.test(push.slice(1).join(""))) {
16
- throw new Error(
114
+ throw prerequisite(
17
115
  "NuGet script or multiline push requires a reviewed artifact-only publishing command; validation/build commands cannot be retried",
18
116
  );
19
117
  }
@@ -96,3 +194,82 @@ export function convertNugetStep(step, packages, index) {
96
194
  }
97
195
  return lines;
98
196
  }
197
+
198
+ /** Guard -> OIDC login -> controller push of the same artifacts -> original outputs. */
199
+ function convertGuarded(step, packages, index, guarded) {
200
+ if (
201
+ /--source\s+(?!["']?https:\/\/api\.nuget\.org\/v3\/index\.json)/.test(
202
+ guarded.options,
203
+ )
204
+ ) {
205
+ throw prerequisite(
206
+ "NuGet policy migration requires the explicit nuget.org destination",
207
+ );
208
+ }
209
+ if (!/^[\w./*-]+$/.test(guarded.glob)) {
210
+ throw prerequisite("NuGet artifact glob must be a literal job-local path");
211
+ }
212
+ const prefix = " ".repeat(step[0].search(/\S/));
213
+ const header = step.slice(0, guarded.start);
214
+ const name =
215
+ /-\s*name:\s*(.+)$/.exec(step[0])?.[1] ?? "Publish NuGet package";
216
+ const id = header
217
+ .map((line) => /^\s*id:\s*(\S+)/.exec(line)?.[1])
218
+ .find(Boolean);
219
+ const condition = header
220
+ .map((line) => /^\s*if:\s*(.+)$/.exec(line)?.[1])
221
+ .find(Boolean)
222
+ ?.replace(/^\$\{\{\s*|\s*\}\}$/g, "");
223
+ const secrets = [...step.join("\n").matchAll(/secrets\.(\w+)/g)].map(
224
+ (m) => m[1],
225
+ );
226
+ const gates = [condition, guarded.guard && `${guarded.guard} == 'true'`]
227
+ .filter(Boolean)
228
+ .map((part) => `(${part})`)
229
+ .join(" && ");
230
+ const publishId = `prm_nuget_publish_${index}`;
231
+ const lines = nugetWorkflowSteps(
232
+ {
233
+ ...packages[0],
234
+ token_secrets: [...secrets, ...(packages[0].token_secrets ?? [])],
235
+ },
236
+ { build: false, artifactGlob: guarded.glob },
237
+ )
238
+ .map((line) => prefix + line.slice(6))
239
+ .map((line) =>
240
+ line.replaceAll("prm_nuget_login", `prm_nuget_login_${index}`),
241
+ );
242
+ const loginIf = lines.findIndex((line) => /if: vars.NUGET_USER/.test(line));
243
+ if (gates) {
244
+ lines[loginIf] += ` && ${gates}`;
245
+ }
246
+ const publish = lines.findIndex((line) =>
247
+ /- name: Publish validated NuGet/.test(line),
248
+ );
249
+ lines.splice(
250
+ publish + 1,
251
+ 0,
252
+ `${prefix} id: ${publishId}`,
253
+ ...(gates ? [`${prefix} if: ${gates}`] : []),
254
+ );
255
+ const record = [
256
+ `${prefix}- name: ${name}`,
257
+ ...(id ? [`${prefix} id: ${id}`] : []),
258
+ `${prefix} if: \${{ !cancelled()${condition ? ` && (${condition})` : ""} }}`,
259
+ `${prefix} env:`,
260
+ `${prefix} PRM_SHOULD_PUBLISH: ${guarded.guard ? `\${{ ${guarded.guard} }}` : "'true'"}`,
261
+ `${prefix} PRM_PUBLISH_OUTCOME: \${{ steps.${publishId}.outcome }}`,
262
+ `${prefix} run: |`,
263
+ `${prefix} if [ "$PRM_SHOULD_PUBLISH" != "true" ]; then`,
264
+ `${prefix} echo "published=skipped" >> "$GITHUB_OUTPUT"`,
265
+ `${prefix} exit 0`,
266
+ `${prefix} fi`,
267
+ `${prefix} if [ "$PRM_PUBLISH_OUTCOME" = "success" ]; then`,
268
+ `${prefix} echo "published=true" >> "$GITHUB_OUTPUT"`,
269
+ `${prefix} else`,
270
+ `${prefix} echo "published=failed" >> "$GITHUB_OUTPUT"`,
271
+ `${prefix} exit 1`,
272
+ `${prefix} fi`,
273
+ ];
274
+ return [...lines, ...record];
275
+ }
@@ -16,6 +16,23 @@ export function packageWorkflows(workflows, packageInfo) {
16
16
  )
17
17
  .filter(Boolean)
18
18
  .at(-1);
19
+ // A step running in a parent directory publishes this package only when
20
+ // it names it, as in `cargo publish -p name` or a shell function invoked
21
+ // with the name, or reads its manifest by path.
22
+ const ancestor = (cwd, child) =>
23
+ child !== cwd && (cwd === "." || child.startsWith(`${cwd}/`));
24
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ const namesPackage = (text, cwd) => {
26
+ const relative =
27
+ cwd === "."
28
+ ? packageInfo.manifest
29
+ : packageInfo.manifest.slice(cwd.length + 1);
30
+ return [packageInfo.name, relative]
31
+ .filter(Boolean)
32
+ .some((token) =>
33
+ new RegExp(`(?<![\\w./-])${escape(token)}(?![\\w-])`).test(text),
34
+ );
35
+ };
19
36
  return workflows.map((workflow) => {
20
37
  const parsed = parseWorkflow(workflow.contents);
21
38
  const jobs = parsed.jobs.flatMap((job) => {
@@ -24,11 +41,10 @@ export function packageWorkflows(workflows, packageInfo) {
24
41
  const called = job.lines.some((line) =>
25
42
  /^\s*uses\s*:\s*["']?\.\/\.github\/workflows\//.test(line),
26
43
  );
27
- const cwd = working(job.lines) ?? working(parsed.header) ?? ".";
28
- return called &&
29
- cwd.replace(/^\.\//, "").replace(/\/$/, "") === directory
30
- ? [` ${job.name}:\n${job.lines.join("\n")}`]
31
- : [];
44
+ // A reusable-workflow caller has no steps or working directory of its
45
+ // own; it carries the OIDC identity of whatever its callee publishes,
46
+ // and the callee is scoped separately.
47
+ return called ? [` ${job.name}:\n${job.lines.join("\n")}`] : [];
32
48
  }
33
49
  const prefix = job.lines.slice(0, start + 1);
34
50
  const blocks = [];
@@ -67,12 +83,20 @@ export function packageWorkflows(workflows, packageInfo) {
67
83
  (working(block) ?? cd ?? inferred ?? fallback)
68
84
  .replace(/^\.\//, "")
69
85
  .replace(/\/$/, "") || ".";
70
- return cwd === directory;
86
+ return (
87
+ cwd === directory ||
88
+ (ancestor(cwd, directory) && namesPackage(text, cwd))
89
+ );
71
90
  });
91
+ // Action-only steps (authentication exchanges such as
92
+ // crates-io-auth-action) belong to the job that publishes this package.
93
+ const kept = blocks.filter(
94
+ (block) =>
95
+ matching.includes(block) ||
96
+ !block.some((line) => /^\s*-?\s*run\s*:/.test(line)),
97
+ );
72
98
  return matching.length
73
- ? [
74
- ` ${job.name}:\n${prefix.join("\n")}\n${matching.flat().join("\n")}`,
75
- ]
99
+ ? [` ${job.name}:\n${prefix.join("\n")}\n${kept.flat().join("\n")}`]
76
100
  : [];
77
101
  });
78
102
  return {
package/src/plan.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  pypiFlow,
9
9
  } from "./flows.mjs";
10
10
  import { credentialPolicy } from "./credential-cycle.mjs";
11
- import { trustedFlow, tokenFlow } from "./extra-flows.mjs";
11
+ import { packagistSteps, trustedFlow, tokenFlow } from "./extra-flows.mjs";
12
12
  import { bootstrapRef } from "./bootstrap-reference.mjs";
13
13
  import { pagesSteps } from "./pages.mjs";
14
14
  import { planPrerequisites } from "./prerequisites.mjs";
@@ -73,6 +73,22 @@ function buildPlan(inspection, packageInfo, options) {
73
73
  };
74
74
  }
75
75
  const command = (program, args) => ({ program, args });
76
+ if (
77
+ (options.migrate || options.addPublishJob) &&
78
+ packageInfo.publishing_policy === "variable-gated-oidc-with-token-fallback"
79
+ ) {
80
+ // The workflow already tries OIDC first and keeps the token fallback; only
81
+ // the opt-in repository variable is off. Never rewrite the workflow.
82
+ return basePlan(inspection, packageInfo, [
83
+ {
84
+ id: "enable-oidc-variable",
85
+ title: `Register trust, then set ${packageInfo.authentication_gate?.variable ?? "the OIDC opt-in variable"} to true`,
86
+ kind: "manual",
87
+ confirm: true,
88
+ description: `The workflow exchanges OIDC only when ${packageInfo.authentication_gate?.variable ?? "its repository variable"} is 'true' and otherwise keeps the existing token. Register the trusted publisher for ${packageInfo.workflow ?? "the caller workflow"}, set the variable, and verify the next release selected OIDC; the token fallback is retained.`,
89
+ },
90
+ ]);
91
+ }
76
92
  if (
77
93
  (options.migrate || options.addPublishJob) &&
78
94
  ["crates-io", "npm", "pypi", "nuget"].includes(packageInfo.registry) &&
@@ -159,12 +175,17 @@ function buildPlan(inspection, packageInfo, options) {
159
175
  "Strictly validate composer.json before submitting it.",
160
176
  command("composer", ["validate", "--strict"]),
161
177
  ),
162
- browser(
163
- "submit-repository",
164
- "Submit the repository to Packagist",
165
- "Sign in and submit the public VCS repository URL. Packagist reads package versions from tags.",
166
- "https://packagist.org/packages/submit",
167
- ),
178
+ ...(packageInfo.exists_on_registry !== true
179
+ ? [
180
+ browser(
181
+ "submit-repository",
182
+ "Submit the repository to Packagist",
183
+ "Sign in and submit the public VCS repository URL. Packagist reads package versions from tags.",
184
+ "https://packagist.org/packages/submit",
185
+ ),
186
+ ]
187
+ : []),
188
+ ...packagistSteps(packageInfo),
168
189
  ];
169
190
  break;
170
191
  default:
@@ -67,9 +67,15 @@ export async function versionEvidence(item, document, url, options, getJson) {
67
67
  state.latest_version = versions?.at(-1);
68
68
  break;
69
69
  case "packagist":
70
+ // p2/<name>.json carries tagged stable releases only (dev branches live
71
+ // in ~dev.json), so a match is an exact stable tag read back from Packagist.
70
72
  versions = document.packages?.[item.name]?.map((value) =>
71
73
  value.version.replace(/^v/, ""),
72
74
  );
75
+ state.stable_tag = document.packages?.[item.name]?.find(
76
+ (value) =>
77
+ value.version.replace(/^v/, "") === expected?.replace(/^v/, ""),
78
+ )?.version;
73
79
  state.latest_version = document.packages?.[item.name]?.[0]?.version;
74
80
  break;
75
81
  case "jsr":
@@ -238,6 +244,33 @@ export async function distributionState(item, options, base) {
238
244
  } catch {
239
245
  /* Non-JSON is unknown. */
240
246
  }
247
+ // Exact module version: the proxy serves @v/<version>.info only once the
248
+ // tag has been fetched, independent of @latest or a stale list cache.
249
+ let exact = {};
250
+ if (item.version && canonicalGoVersion(item.version)) {
251
+ const evidence = `${base}/${module}/@v/${encodeURIComponent(item.version)}.info`;
252
+ const info = await read(evidence);
253
+ let found;
254
+ try {
255
+ found = info === null ? null : JSON.parse(info)?.Version;
256
+ } catch {
257
+ found = undefined;
258
+ }
259
+ exact =
260
+ found === null
261
+ ? { version_evidence_url: evidence, manifest_version_exists: false }
262
+ : found === item.version
263
+ ? { version_evidence_url: evidence, manifest_version_exists: true }
264
+ : {
265
+ version_evidence_url: evidence,
266
+ version_state: "manifest-version-unknown",
267
+ };
268
+ if (exact.manifest_version_exists === false) {
269
+ exact.version_state = olderVersion(item.version, version)
270
+ ? "stale-manifest-version"
271
+ : "manifest-version-absent";
272
+ }
273
+ }
241
274
  return {
242
275
  registry_evidence_url: url,
243
276
  ...((!version && !(list === null && latest === null)) ||
@@ -256,6 +289,7 @@ export async function distributionState(item, options, base) {
256
289
  ...(validList && tags.length === 0 && version
257
290
  ? { version_state: "no-tagged-release" }
258
291
  : {}),
292
+ ...exact,
259
293
  };
260
294
  }
261
295
  return {
@@ -1,4 +1,4 @@
1
- import { publishingPolicy } from "./publishing-state.mjs";
1
+ import { authenticationFacts, publishingPolicy } from "./publishing-state.mjs";
2
2
  import { readFile, realpath, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { stripComments, commandPosition } from "./source-code.mjs";
@@ -21,7 +21,10 @@ export const JOB_PATTERNS = new Map([
21
21
  ],
22
22
  ["jsr", /\bdeno\s+publish\b|\bjsr\s+publish\b/],
23
23
  ["docker-hub", /docker\/login-action|\bdocker\s+(?:login|push)\b/],
24
- ["maven-central", /\bmvn\s+(?:-\S+\s+)*deploy\b|\bgradle(?:w)?\s+publish\b/],
24
+ [
25
+ "maven-central",
26
+ /(?:\.\/|\b)mvnw?\s+(?:-\S+\s+)*deploy\b|(?:\.\/|\b)gradlew?\s+(?:-\S+\s+)*publish\b/,
27
+ ],
25
28
  ["vscode-marketplace", /\bvsce\s+publish\b/],
26
29
  ["open-vsx", /\bovsx\s+publish\b/],
27
30
  [
@@ -42,6 +45,20 @@ export const JOB_PATTERNS = new Map([
42
45
  ],
43
46
  ]);
44
47
 
48
+ /**
49
+ * Registries without an upload command: a release is a tag, a GitHub release
50
+ * asset or a registry update hook. The evidence is matched anywhere in the
51
+ * job's executable lines, as the URL is usually an argument.
52
+ */
53
+ export const RELEASE_EVIDENCE = new Map([
54
+ ["packagist", /packagist\.org\/api\/(?:update|create)-package/],
55
+ [
56
+ "go-modules",
57
+ /\bgh\s+release\s+create\b|\bgit\s+push\b[^\n]*\btags?\b|proxy\.golang\.org/,
58
+ ],
59
+ ["cmake", /\bgh\s+release\s+(?:create|upload)\b/],
60
+ ]);
61
+
45
62
  // Scripts usually pass the subcommand as a separate argument, as in
46
63
  // `spawnSync("npm", ["publish"])` or `Command::new("cargo")` followed by
47
64
  // `.arg("publish")` within the next few lines.
@@ -155,8 +172,25 @@ export async function detectPublisher(root, workflows, registry) {
155
172
  ["crates-io", "npm", "pypi", "nuget"].includes(registry) &&
156
173
  (result.workflow || result.candidates.length)
157
174
  ) {
158
- result.policy = await publishingPolicies(root, workflows, registry);
159
- if (result.policy !== "oidc-with-token-fallback") {
175
+ const { policy, gates } = await publishingPolicies(
176
+ root,
177
+ workflows,
178
+ registry,
179
+ );
180
+ result.policy = policy;
181
+ if (gates.length) {
182
+ result.gate = gates[0].gate;
183
+ if (gates.some((facts) => facts.passwordAlwaysToken)) {
184
+ result.warnings.push(
185
+ `the publish password expression \`vars.${result.gate.variable} == 'true' && '' || secrets.*\` always selects the token ('' is falsy in GitHub expressions); the variable cannot switch to OIDC`,
186
+ );
187
+ }
188
+ }
189
+ if (result.policy === "variable-gated-oidc-with-token-fallback") {
190
+ result.warnings.push(
191
+ `publishing selects OIDC only when the repository variable ${result.gate?.variable ?? "gate"} is 'true' and otherwise uses the token; register the trusted publisher, verify it, then set the variable`,
192
+ );
193
+ } else if (result.policy !== "oidc-with-token-fallback") {
160
194
  result.warnings.push(
161
195
  `publishing authentication is ${result.policy}; migrate to OIDC with token fallback`,
162
196
  );
@@ -173,6 +207,7 @@ async function publishingPolicies(root, workflows, registry) {
173
207
  ]),
174
208
  );
175
209
  const policies = [];
210
+ const gates = [];
176
211
  const visit = async (name, callerGrant, seen = new Set()) => {
177
212
  if (seen.has(name) || !parsed.has(name)) {
178
213
  return;
@@ -196,6 +231,13 @@ async function publishingPolicies(root, workflows, registry) {
196
231
  if (called) {
197
232
  await visit(called, grant, new Set([...seen, name]));
198
233
  } else if (await jobPublishes(root, workflow, job, registry)) {
234
+ const facts = authenticationFacts(
235
+ [...workflow.header, ...job.lines].join("\n"),
236
+ registry,
237
+ );
238
+ if (facts.gate) {
239
+ gates.push(facts);
240
+ }
199
241
  policies.push(
200
242
  publishingPolicy(
201
243
  [[...workflow.header, ...job.lines].join("\n")],
@@ -212,11 +254,13 @@ async function publishingPolicies(root, workflows, registry) {
212
254
  }
213
255
  }
214
256
  const unique = [...new Set(policies)];
215
- return unique.length === 1
216
- ? unique[0]
217
- : unique.length
218
- ? "mixed-authentication"
219
- : "authentication-unverified";
257
+ const policy =
258
+ unique.length === 1
259
+ ? unique[0]
260
+ : unique.length
261
+ ? "mixed-authentication"
262
+ : "authentication-unverified";
263
+ return { policy, gates };
220
264
  }
221
265
 
222
266
  async function detectPublisherDetails(root, workflows, registry) {
@@ -242,6 +286,7 @@ async function detectPublisherDetails(root, workflows, registry) {
242
286
  job.lines.find((line) => LOCAL_WORKFLOW.test(line)) ?? "",
243
287
  )?.[1];
244
288
  const callee = called ? parsedWorkflows.get(called) : null;
289
+ const chain = [];
245
290
  const environments = callee
246
291
  ? await calledEnvironments(
247
292
  root,
@@ -249,6 +294,7 @@ async function detectPublisherDetails(root, workflows, registry) {
249
294
  called,
250
295
  registry,
251
296
  new Set([workflow.name]),
297
+ chain,
252
298
  )
253
299
  : (await jobPublishes(root, parsed, job, registry))
254
300
  ? [
@@ -265,6 +311,7 @@ async function detectPublisherDetails(root, workflows, registry) {
265
311
  found.push({
266
312
  workflow: workflow.name,
267
313
  job: job.name,
314
+ callees: chain,
268
315
  idToken:
269
316
  grantsIdToken(childBlock(job.lines, indent, "permissions")) ??
270
317
  workflowGrant ??
@@ -323,8 +370,10 @@ function described(workflow, found) {
323
370
  `the publishing jobs in ${workflow} use different environments (${environments.map((item) => item ?? "none").join(", ")}); pass --environment <name> to choose one`,
324
371
  );
325
372
  }
373
+ const callees = [...new Set(jobs.flatMap((item) => item.callees ?? []))];
326
374
  return {
327
375
  workflow,
376
+ ...(callees.length ? { callees } : {}),
328
377
  jobs: [...new Set(jobs.map((item) => item.job))],
329
378
  environment: environments.length === 1 ? environments[0] : null,
330
379
  candidates: [],
@@ -372,7 +421,14 @@ export function registryTokenSecrets(registry) {
372
421
  return TOKEN_SECRETS.get(registry) ?? [];
373
422
  }
374
423
 
375
- async function calledEnvironments(root, workflows, name, registry, visited) {
424
+ async function calledEnvironments(
425
+ root,
426
+ workflows,
427
+ name,
428
+ registry,
429
+ visited,
430
+ chain = [],
431
+ ) {
376
432
  if (visited.has(name)) {
377
433
  return [];
378
434
  }
@@ -392,9 +448,12 @@ async function calledEnvironments(root, workflows, name, registry, visited) {
392
448
  LOCAL_WORKFLOW.exec(called)[1],
393
449
  registry,
394
450
  seen,
451
+ chain,
395
452
  )),
396
453
  );
397
454
  } else if (await jobPublishes(root, parsed, job, registry)) {
455
+ // Exact reusable-workflow identity: each callee job that publishes.
456
+ chain.push(`${[...seen].slice(1).join(" -> ")} -> ${job.name}`);
398
457
  environments.push(
399
458
  environmentName(
400
459
  childBlock(job.lines, childIndent(job.lines), "environment"),
@@ -409,6 +468,10 @@ async function calledEnvironments(root, workflows, name, registry, visited) {
409
468
  export async function jobPublishes(root, parsed, job, registry) {
410
469
  const pattern = JOB_PATTERNS.get(registry);
411
470
  const executable = executableLines(job.lines);
471
+ const evidence = RELEASE_EVIDENCE.get(registry);
472
+ if (evidence) {
473
+ return evidence.test(executable.join("\n"));
474
+ }
412
475
  if (publishingLine(executable, pattern)) {
413
476
  return true;
414
477
  }
@@ -40,7 +40,16 @@ export function publishingPolicy(contents, registry, grantOverride) {
40
40
  /continue-on-error:\s*true/.test(text)
41
41
  : /outcome == 'failure'/.test(text) &&
42
42
  /continue-on-error:\s*true/.test(text);
43
- if (oidc && token && fallback) {
43
+ const facts = authenticationFacts(text, registry);
44
+ if (facts.passwordAlwaysToken) {
45
+ // `vars.FLAG == 'true' && '' || secrets.TOKEN` always yields the token:
46
+ // '' is falsy in GitHub expressions, so the flag never selects OIDC.
47
+ return token ? "token-only" : "authentication-unverified";
48
+ }
49
+ if (grant && token && facts.gate && facts.shellFallback) {
50
+ return "variable-gated-oidc-with-token-fallback";
51
+ }
52
+ if ((oidc && token && fallback) || (oidc && token && facts.npmNative)) {
44
53
  return "oidc-with-token-fallback";
45
54
  }
46
55
  return oidc
@@ -49,3 +58,53 @@ export function publishingPolicy(contents, registry, grantOverride) {
49
58
  ? "token-only"
50
59
  : "authentication-unverified";
51
60
  }
61
+
62
+ /**
63
+ * Actual credential-selection semantics of a publishing job, kept apart from
64
+ * publisher registration and historical trusted uploads.
65
+ */
66
+ export function authenticationFacts(contents, registry) {
67
+ const text = Array.isArray(contents) ? contents.join("\n") : contents;
68
+ const facts = {
69
+ gate: null,
70
+ shellFallback: false,
71
+ passwordAlwaysToken: false,
72
+ npmNative: false,
73
+ };
74
+ const steps = text.split(/(?=^[ \t]*- )/m);
75
+ const auth = steps.find((step) =>
76
+ registry === "crates-io"
77
+ ? /crates-io-auth-action@/.test(step)
78
+ : registry === "nuget"
79
+ ? /NuGet\/login@/.test(step)
80
+ : false,
81
+ );
82
+ const gate = auth
83
+ ? /\bif:.*\bvars\.(\w+)\s*==\s*['"]true['"]/.exec(auth)?.[1]
84
+ : null;
85
+ if (gate) {
86
+ facts.gate = { variable: gate, value: "true" };
87
+ }
88
+ facts.shellFallback = /\$\{\w*OIDC\w*:-\$\w+\}/.test(text);
89
+ if (registry === "pypi") {
90
+ const password = /^\s*password\s*:\s*(.+)$/m.exec(text)?.[1] ?? "";
91
+ const flag =
92
+ /vars\.(\w+)\s*==\s*['"]true['"]\s*&&\s*''\s*\|\|\s*secrets\.\w+/.exec(
93
+ password,
94
+ );
95
+ if (flag) {
96
+ facts.passwordAlwaysToken = true;
97
+ facts.gate = { variable: flag[1], value: "true", ineffective: true };
98
+ }
99
+ }
100
+ if (registry === "npm") {
101
+ // npm 11.5.1+ tries the OIDC exchange itself and falls back to the
102
+ // configured token, so a job with both needs no separate fallback step.
103
+ facts.npmNative = steps.some(
104
+ (step) =>
105
+ /\bnpm\s+publish\b/.test(step) &&
106
+ /NODE_AUTH_TOKEN\s*:\s*\$\{\{\s*secrets\./.test(step),
107
+ );
108
+ }
109
+ return facts;
110
+ }
@@ -50,7 +50,7 @@ export function stripComments(contents, language = "shell") {
50
50
  export function commandPosition(prefix) {
51
51
  const segment = prefix.split(/[;&|]/).at(-1).trimStart();
52
52
  return (
53
- /^(?:(?:npx|bunx|exec|sudo|env|time|command|pnpm\s+exec|yarn|\w+=\S*)\s+)*$/.test(
53
+ /^(?:(?:if|elif|then|else|do|while|until|!|npx|bunx|exec|sudo|env|time|command|pnpm\s+exec|yarn|\w+=\S*)\s+)*$/.test(
54
54
  segment,
55
55
  ) ||
56
56
  /(?:\$|shell)\s*`\s*$/.test(segment) ||