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 +2 -2
- package/src/cpp-distribution.mjs +42 -5
- package/src/credential-requirements.mjs +105 -0
- package/src/discovery.mjs +138 -12
- package/src/extra-flows.mjs +83 -2
- package/src/nuget-ci.mjs +32 -1
- package/src/nuget-migration.mjs +178 -1
- package/src/package-coverage.mjs +33 -9
- package/src/plan.mjs +28 -7
- package/src/publication-state.mjs +34 -0
- package/src/publishers.mjs +73 -10
- package/src/publishing-state.mjs +60 -1
- package/src/source-code.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "package-registry-manager",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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"
|
package/src/cpp-distribution.mjs
CHANGED
|
@@ -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(
|
|
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:
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/, "");
|
package/src/extra-flows.mjs
CHANGED
|
@@ -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:
|
|
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.
|
|
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}`,
|
package/src/nuget-migration.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/package-coverage.mjs
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 {
|
package/src/publishers.mjs
CHANGED
|
@@ -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
|
-
[
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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
|
}
|
package/src/publishing-state.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/source-code.mjs
CHANGED
|
@@ -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) ||
|