package-registry-manager 0.22.5 → 0.22.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +3 -3
- package/src/bootstrap-reference.mjs +76 -0
- package/src/cli.mjs +12 -3
- package/src/containers.mjs +8 -1
- package/src/credential-browser.mjs +82 -0
- package/src/credential-cycle.mjs +91 -0
- package/src/credential-setup.mjs +210 -0
- package/src/credential-verification.mjs +96 -0
- package/src/discovery.mjs +97 -7
- package/src/extra-flows.mjs +88 -0
- package/src/flows.mjs +29 -19
- package/src/model.mjs +15 -0
- package/src/npm-policy.mjs +69 -0
- package/src/package-coverage.mjs +106 -0
- package/src/plan.mjs +28 -30
- package/src/publishers.mjs +113 -27
- package/src/setup.mjs +45 -4
- package/src/sign-in-import.mjs +8 -0
- package/src/skips.mjs +3 -0
- package/src/source-code.mjs +61 -0
- package/src/tokens.mjs +8 -0
- package/src/version-guard.mjs +34 -0
- package/src/workflow-proposal.mjs +32 -3
- package/src/workflows.mjs +49 -10
package/src/discovery.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, readdir, realpath } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { CONTAINER_FILES, containerPackages } from "./containers.mjs";
|
|
5
|
+
import { packageWorkflows, auditWrappers } from "./package-coverage.mjs";
|
|
5
6
|
import { REGISTRIES } from "./model.mjs";
|
|
6
7
|
import {
|
|
7
8
|
TRUSTED_REGISTRIES,
|
|
@@ -39,6 +40,8 @@ const MANIFEST_NAMES = new Set([
|
|
|
39
40
|
"build.gradle",
|
|
40
41
|
"build.gradle.kts",
|
|
41
42
|
"composer.json",
|
|
43
|
+
"jsr.json",
|
|
44
|
+
"deno.json",
|
|
42
45
|
]);
|
|
43
46
|
|
|
44
47
|
/**
|
|
@@ -73,7 +76,9 @@ export async function inspectRepository(repository, options = {}) {
|
|
|
73
76
|
const directory = testDirectory(manifest);
|
|
74
77
|
return (
|
|
75
78
|
directory === null ||
|
|
76
|
-
(
|
|
79
|
+
(!["docs", "experiments", "case-studies"].includes(directory) &&
|
|
80
|
+
publishable &&
|
|
81
|
+
referencedByWorkflow(workflows, manifest)) ||
|
|
77
82
|
skip(manifest, testDirectoryReason(directory))
|
|
78
83
|
);
|
|
79
84
|
};
|
|
@@ -103,14 +108,82 @@ export async function inspectRepository(repository, options = {}) {
|
|
|
103
108
|
for (const registry of TRUSTED_REGISTRIES) {
|
|
104
109
|
publishers.set(registry, await detectPublisher(root, workflows, registry));
|
|
105
110
|
}
|
|
111
|
+
// Extension packages can publish to both marketplaces independently.
|
|
112
|
+
for (const item of [...parsed].filter(Boolean)) {
|
|
113
|
+
if (item.registry !== "npm") {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const data = JSON.parse(await readManifest(path.join(root, item.manifest)));
|
|
117
|
+
if (data.publisher && data.engines?.vscode) {
|
|
118
|
+
for (const registry of ["vscode-marketplace", "open-vsx"]) {
|
|
119
|
+
const publisher = await detectPublisher(root, workflows, registry);
|
|
120
|
+
if (publisher.workflow) {
|
|
121
|
+
publishers.set(registry, publisher);
|
|
122
|
+
parsed.push({
|
|
123
|
+
...item,
|
|
124
|
+
registry,
|
|
125
|
+
name: `${data.publisher}.${item.name}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
publishers.set(
|
|
132
|
+
"maven-central",
|
|
133
|
+
await detectPublisher(root, workflows, "maven-central"),
|
|
134
|
+
);
|
|
106
135
|
const containerFiles = dockerfiles
|
|
107
136
|
.map((item) => relativePath(root, item))
|
|
108
137
|
.filter((item) => !ignored(item) && kept(item, true));
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
const counts = new Map();
|
|
139
|
+
for (const item of parsed.filter(Boolean)) {
|
|
140
|
+
if (!testDirectory(item.manifest)) {
|
|
141
|
+
counts.set(item.registry, (counts.get(item.registry) ?? 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const chrome = await detectPublisher(root, workflows, "chrome-web-store");
|
|
145
|
+
const chromePackages = chrome.workflow
|
|
146
|
+
? [
|
|
147
|
+
withPublisher(
|
|
148
|
+
packageInfo(
|
|
149
|
+
"chrome-web-store",
|
|
150
|
+
coordinates.github_repository ?? "chrome-extension",
|
|
151
|
+
null,
|
|
152
|
+
`.github/workflows/${chrome.workflow}`,
|
|
153
|
+
),
|
|
154
|
+
workflows,
|
|
155
|
+
new Map([["chrome-web-store", chrome]]),
|
|
156
|
+
),
|
|
157
|
+
]
|
|
158
|
+
: [];
|
|
159
|
+
const packages = (
|
|
160
|
+
await Promise.all(
|
|
161
|
+
parsed
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.filter((item) => kept(item.manifest, item.publishable))
|
|
164
|
+
.map(async (item) => {
|
|
165
|
+
const scoped =
|
|
166
|
+
counts.get(item.registry) > 1 &&
|
|
167
|
+
TRUSTED_REGISTRIES.includes(item.registry)
|
|
168
|
+
? new Map([
|
|
169
|
+
[
|
|
170
|
+
item.registry,
|
|
171
|
+
await detectPublisher(
|
|
172
|
+
root,
|
|
173
|
+
packageWorkflows(workflows, item),
|
|
174
|
+
item.registry,
|
|
175
|
+
),
|
|
176
|
+
],
|
|
177
|
+
])
|
|
178
|
+
: publishers;
|
|
179
|
+
return withPublisher(item, workflows, scoped);
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
.concat(
|
|
184
|
+
containerPackages(containerFiles, workflows, coordinates),
|
|
185
|
+
chromePackages,
|
|
186
|
+
)
|
|
114
187
|
.sort((left, right) => {
|
|
115
188
|
const registryOrder =
|
|
116
189
|
REGISTRIES.indexOf(left.registry) - REGISTRIES.indexOf(right.registry);
|
|
@@ -122,6 +195,7 @@ export async function inspectRepository(repository, options = {}) {
|
|
|
122
195
|
);
|
|
123
196
|
});
|
|
124
197
|
|
|
198
|
+
await auditWrappers(root, packages);
|
|
125
199
|
const inspection = {
|
|
126
200
|
schema_version: 1,
|
|
127
201
|
repository: {
|
|
@@ -206,7 +280,7 @@ async function collectManifests(directory, manifests, dockerfiles) {
|
|
|
206
280
|
} else if (
|
|
207
281
|
entry.isFile() &&
|
|
208
282
|
(MANIFEST_NAMES.has(entry.name) ||
|
|
209
|
-
/\.(?:csproj|fsproj|vbproj)$/.test(entry.name))
|
|
283
|
+
/\.(?:csproj|fsproj|vbproj|gemspec)$/.test(entry.name))
|
|
210
284
|
) {
|
|
211
285
|
manifests.push(item);
|
|
212
286
|
}
|
|
@@ -217,6 +291,13 @@ async function parseManifest(manifestPath, manifest) {
|
|
|
217
291
|
const contents = await readManifest(manifestPath);
|
|
218
292
|
const filename = path.basename(manifestPath);
|
|
219
293
|
switch (filename) {
|
|
294
|
+
case "jsr.json":
|
|
295
|
+
case "deno.json": {
|
|
296
|
+
const value = JSON.parse(contents);
|
|
297
|
+
return value.name && value.version
|
|
298
|
+
? packageInfo("jsr", value.name, value.version, manifest)
|
|
299
|
+
: null;
|
|
300
|
+
}
|
|
220
301
|
case "package.json":
|
|
221
302
|
return parseNpm(contents, manifest);
|
|
222
303
|
case "Cargo.toml":
|
|
@@ -246,6 +327,15 @@ async function parseManifest(manifestPath, manifest) {
|
|
|
246
327
|
case "composer.json":
|
|
247
328
|
return parseComposer(contents, manifest);
|
|
248
329
|
default:
|
|
330
|
+
if (filename.endsWith(".gemspec")) {
|
|
331
|
+
return packageInfo(
|
|
332
|
+
"rubygems",
|
|
333
|
+
capture(contents, /\.name\s*=\s*['"]([^'"]+)/) ??
|
|
334
|
+
"unknown-ruby-package",
|
|
335
|
+
capture(contents, /\.version\s*=\s*['"]([^'"]+)/),
|
|
336
|
+
manifest,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
249
339
|
if (/\.(?:csproj|fsproj|vbproj)$/.test(filename)) {
|
|
250
340
|
return parseDotnet(contents, manifestPath, manifest);
|
|
251
341
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { credentialSteps } from "./credential-cycle.mjs";
|
|
2
|
+
import { tokenSecretSteps } from "./tokens.mjs";
|
|
3
|
+
|
|
4
|
+
/** Additional OIDC registries never fall back to a stored publishing token. */
|
|
5
|
+
export function trustedFlow(item, context) {
|
|
6
|
+
const specs = {
|
|
7
|
+
rubygems: [
|
|
8
|
+
"gem",
|
|
9
|
+
["build", item.manifest.split("/").at(-1)],
|
|
10
|
+
"https://rubygems.org/profile/oidc/pending_trusted_publishers",
|
|
11
|
+
"Register the GitHub Actions identity as a pending publisher, or add it in the existing gem settings.",
|
|
12
|
+
],
|
|
13
|
+
nuget: [
|
|
14
|
+
"dotnet",
|
|
15
|
+
["pack", "--configuration", "Release"],
|
|
16
|
+
"https://www.nuget.org/account/TrustedPublishing",
|
|
17
|
+
"Register a trusted publishing policy for this GitHub Actions identity. Use NuGet/login to exchange OIDC for a short-lived API key; never store that key.",
|
|
18
|
+
],
|
|
19
|
+
jsr: [
|
|
20
|
+
"deno",
|
|
21
|
+
["publish", "--dry-run"],
|
|
22
|
+
`https://jsr.io/${item.name}/settings`,
|
|
23
|
+
"Link this package to the GitHub repository; publish from the configured workflow through OIDC.",
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
const [program, args, url, description] = specs[item.registry];
|
|
27
|
+
const steps = [
|
|
28
|
+
{
|
|
29
|
+
id: "validate-package",
|
|
30
|
+
title: `Validate ${item.registry} package`,
|
|
31
|
+
kind: "check",
|
|
32
|
+
description: "Build or validate locally without uploading.",
|
|
33
|
+
command: { program, args },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "configure-trusted-publisher",
|
|
37
|
+
title: `Configure ${item.registry} trusted publishing`,
|
|
38
|
+
kind: "browser",
|
|
39
|
+
description,
|
|
40
|
+
url,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "verify-oidc-release",
|
|
44
|
+
title: "Verify an OIDC release before removing tokens",
|
|
45
|
+
kind: "manual",
|
|
46
|
+
description: `Run ${context.workflow} and verify registry acceptance through OIDC; remove secret references from every workflow before deleting unused credentials.`,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
if (context.slug) {
|
|
50
|
+
steps.push(...tokenSecretSteps(item, context.slug));
|
|
51
|
+
}
|
|
52
|
+
return steps;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function tokenFlow(item) {
|
|
56
|
+
const checks = {
|
|
57
|
+
"maven-central": { program: "mvn", args: ["--batch-mode", "verify"] },
|
|
58
|
+
"vscode-marketplace": {
|
|
59
|
+
program: "npx",
|
|
60
|
+
args: ["--no-install", "vsce", "package"],
|
|
61
|
+
},
|
|
62
|
+
"open-vsx": { program: "npx", args: ["--no-install", "vsce", "package"] },
|
|
63
|
+
};
|
|
64
|
+
const steps = checks[item.registry]
|
|
65
|
+
? [
|
|
66
|
+
{
|
|
67
|
+
id: "validate-package",
|
|
68
|
+
title: "Validate package before credential changes",
|
|
69
|
+
kind: "check",
|
|
70
|
+
description: "Build without publishing.",
|
|
71
|
+
command: checks[item.registry],
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
: [];
|
|
75
|
+
if (item.registry === "maven-central") {
|
|
76
|
+
steps.push({
|
|
77
|
+
id: "verify-namespace",
|
|
78
|
+
title: "Verify a Central namespace",
|
|
79
|
+
kind: "browser",
|
|
80
|
+
description:
|
|
81
|
+
"Sign in to the Central Portal and verify the namespace used by the package coordinates.",
|
|
82
|
+
url: "https://central.sonatype.com/publishing/namespaces",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return steps.concat(
|
|
86
|
+
credentialSteps(item.registry, item.token_secrets?.[0] ?? item.registry),
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/flows.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { credentialSteps } from "./credential-cycle.mjs";
|
|
1
2
|
import { DEFAULT_TRUST_NPM, NPM_TFA_URL } from "./prerequisites.mjs";
|
|
2
3
|
import {
|
|
3
4
|
npmName,
|
|
@@ -255,6 +256,13 @@ export function npmFlow(packageInfo, context) {
|
|
|
255
256
|
},
|
|
256
257
|
),
|
|
257
258
|
checkRegistryStep("npm", registryStateUrl(packageInfo)),
|
|
259
|
+
step(
|
|
260
|
+
"check-name-policy",
|
|
261
|
+
"Check npm name availability and policy",
|
|
262
|
+
"check",
|
|
263
|
+
"Check exact and punctuation variants before approval. Server-side blocks can still refuse publication.",
|
|
264
|
+
{ when: "package-missing" },
|
|
265
|
+
),
|
|
258
266
|
step(
|
|
259
267
|
"check-sign-in",
|
|
260
268
|
"Check the npm session",
|
|
@@ -308,6 +316,25 @@ export function npmFlow(packageInfo, context) {
|
|
|
308
316
|
"Compare the bin entries of the packed package.json with package.json and check that each installed bin prints its version for --version.",
|
|
309
317
|
{ when: "package-missing" },
|
|
310
318
|
),
|
|
319
|
+
step(
|
|
320
|
+
"publish-dry-run",
|
|
321
|
+
"Validate first publication without upload",
|
|
322
|
+
"check",
|
|
323
|
+
"Validate the reviewed tarball before asking for publication approval.",
|
|
324
|
+
{
|
|
325
|
+
command: command("npm", [
|
|
326
|
+
"publish",
|
|
327
|
+
"{tarball}",
|
|
328
|
+
"--dry-run",
|
|
329
|
+
"--ignore-scripts",
|
|
330
|
+
"--access",
|
|
331
|
+
"public",
|
|
332
|
+
"--provenance=false",
|
|
333
|
+
]),
|
|
334
|
+
when: "package-missing",
|
|
335
|
+
cwd: packageCwd(context.directory),
|
|
336
|
+
},
|
|
337
|
+
),
|
|
311
338
|
// Sign in right before publishing, so the sign-in, publish, and trust
|
|
312
339
|
// approvals happen together and npm can skip repeated 2FA prompts.
|
|
313
340
|
step(
|
|
@@ -416,7 +443,6 @@ export function npmFlow(packageInfo, context) {
|
|
|
416
443
|
"Confirm that npm lists the GitHub Actions trusted publisher.",
|
|
417
444
|
{ command: trustList, when: "trust-missing" },
|
|
418
445
|
),
|
|
419
|
-
...tokenSecretSteps(packageInfo, context.slug),
|
|
420
446
|
step(
|
|
421
447
|
"rerun-release",
|
|
422
448
|
"Re-run the failed release jobs",
|
|
@@ -439,6 +465,7 @@ export function npmFlow(packageInfo, context) {
|
|
|
439
465
|
if (context.verifyRelease) {
|
|
440
466
|
steps.push(...verifyReleaseSteps(context, registryStateUrl(packageInfo)));
|
|
441
467
|
}
|
|
468
|
+
steps.push(...tokenSecretSteps(packageInfo, context.slug));
|
|
442
469
|
}
|
|
443
470
|
steps.push(
|
|
444
471
|
step(
|
|
@@ -707,13 +734,6 @@ export function dockerHubFlow(packageInfo, context) {
|
|
|
707
734
|
when: "package-missing",
|
|
708
735
|
},
|
|
709
736
|
),
|
|
710
|
-
step(
|
|
711
|
-
"create-access-token",
|
|
712
|
-
"Create a Read & Write access token",
|
|
713
|
-
"browser",
|
|
714
|
-
`Create a token with Read & Write access and a short expiry. Personal access tokens cannot be limited to one repository; an organization access token can be restricted to ${packageInfo.name}. Keep the token only for the next step.`,
|
|
715
|
-
{ url: "https://app.docker.com/settings/personal-access-tokens/create" },
|
|
716
|
-
),
|
|
717
737
|
step(
|
|
718
738
|
"check-github-cli",
|
|
719
739
|
"Check the GitHub CLI session",
|
|
@@ -755,17 +775,7 @@ export function dockerHubFlow(packageInfo, context) {
|
|
|
755
775
|
cwd: ".",
|
|
756
776
|
},
|
|
757
777
|
),
|
|
758
|
-
|
|
759
|
-
"set-token-secret",
|
|
760
|
-
"Set DOCKERHUB_TOKEN",
|
|
761
|
-
"command",
|
|
762
|
-
"gh reads the token from the terminal without echoing it; the tool never sees it.",
|
|
763
|
-
{
|
|
764
|
-
command: command("gh", ["secret", "set", "DOCKERHUB_TOKEN", ...repo]),
|
|
765
|
-
cwd: ".",
|
|
766
|
-
confirm: true,
|
|
767
|
-
},
|
|
768
|
-
),
|
|
778
|
+
...credentialSteps("docker-hub", "DOCKERHUB_TOKEN"),
|
|
769
779
|
];
|
|
770
780
|
}
|
|
771
781
|
|
package/src/model.mjs
CHANGED
|
@@ -8,9 +8,24 @@ export const REGISTRIES = Object.freeze([
|
|
|
8
8
|
"packagist",
|
|
9
9
|
"docker-hub",
|
|
10
10
|
"ghcr",
|
|
11
|
+
"rubygems",
|
|
12
|
+
"jsr",
|
|
13
|
+
"vscode-marketplace",
|
|
14
|
+
"open-vsx",
|
|
15
|
+
"chrome-web-store",
|
|
11
16
|
]);
|
|
12
17
|
|
|
13
18
|
const REGISTRY_ALIASES = new Map([
|
|
19
|
+
["ruby", "rubygems"],
|
|
20
|
+
["rubygems", "rubygems"],
|
|
21
|
+
["gem", "rubygems"],
|
|
22
|
+
["jsr", "jsr"],
|
|
23
|
+
["vscode", "vscode-marketplace"],
|
|
24
|
+
["vsce", "vscode-marketplace"],
|
|
25
|
+
["vscode-marketplace", "vscode-marketplace"],
|
|
26
|
+
["ovsx", "open-vsx"],
|
|
27
|
+
["open-vsx", "open-vsx"],
|
|
28
|
+
["chrome-web-store", "chrome-web-store"],
|
|
14
29
|
["npm", "npm"],
|
|
15
30
|
["cargo", "crates-io"],
|
|
16
31
|
["crate", "crates-io"],
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { npmName, registryEndpoint, USER_AGENT } from "./registry-state.mjs";
|
|
2
|
+
|
|
3
|
+
/** Bounded punctuation variants, including npm's punctuation-free moniker. */
|
|
4
|
+
export function nameVariants(name) {
|
|
5
|
+
const slash = name.lastIndexOf("/");
|
|
6
|
+
const scope = slash < 0 ? "" : name.slice(0, slash + 1);
|
|
7
|
+
const leaf = name.slice(slash + 1);
|
|
8
|
+
const pieces = leaf.split(/[._-]+/).filter(Boolean);
|
|
9
|
+
let variants = [pieces[0]];
|
|
10
|
+
for (const piece of pieces.slice(1)) {
|
|
11
|
+
variants = variants
|
|
12
|
+
.flatMap((prefix) =>
|
|
13
|
+
["", "-", ".", "_"].map((separator) => prefix + separator + piece),
|
|
14
|
+
)
|
|
15
|
+
.slice(0, 64);
|
|
16
|
+
}
|
|
17
|
+
return [...new Set(variants.map((variant) => scope + variant))].filter(
|
|
18
|
+
(variant) => variant !== name,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** npm dry-run cannot validate unpublished server-side block/similarity rules. */
|
|
23
|
+
export async function checkNamePolicy(name, options = {}) {
|
|
24
|
+
if (
|
|
25
|
+
name.length > 214 ||
|
|
26
|
+
!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name) ||
|
|
27
|
+
["node_modules", "favicon.ico"].includes(name)
|
|
28
|
+
) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`npm name '${name}' is invalid; choose a lowercase descriptive name or @owner/name`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const base = registryEndpoint("npm", options.env ?? process.env);
|
|
34
|
+
for (const variant of [name, ...nameVariants(name)]) {
|
|
35
|
+
const response = await (options.fetch ?? globalThis.fetch)(
|
|
36
|
+
`${base}/${npmName(variant)}?_=${Date.now()}`,
|
|
37
|
+
{
|
|
38
|
+
headers: {
|
|
39
|
+
accept: "application/json",
|
|
40
|
+
"cache-control": "no-cache, no-store",
|
|
41
|
+
"user-agent": USER_AGENT,
|
|
42
|
+
},
|
|
43
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
if (response.status === 404) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`npm name policy lookup failed (${response.status}); no approval requested`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (variant !== name) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`npm name '${name}' is similar to existing '${variant}'; use @owner/${name} or a longer descriptive name`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Report npm policy refusal once, without entering authentication retry logic. */
|
|
64
|
+
export function policyRefusal(output) {
|
|
65
|
+
return (
|
|
66
|
+
/\bE403\b|403 Forbidden/i.test(output) &&
|
|
67
|
+
/too similar|forbidden|blocked|not allowed/i.test(output)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
executableLines,
|
|
5
|
+
parseWorkflow,
|
|
6
|
+
scriptReferences,
|
|
7
|
+
} from "./publishers.mjs";
|
|
8
|
+
|
|
9
|
+
/** Keep executable steps for this manifest, preserving the job's OIDC identity. */
|
|
10
|
+
export function packageWorkflows(workflows, packageInfo) {
|
|
11
|
+
const directory = path.posix.dirname(packageInfo.manifest);
|
|
12
|
+
const working = (lines) =>
|
|
13
|
+
lines
|
|
14
|
+
.map(
|
|
15
|
+
(line) => /^\s*working-directory\s*:\s*['"]?([\w./-]+)/.exec(line)?.[1],
|
|
16
|
+
)
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.at(-1);
|
|
19
|
+
return workflows.map((workflow) => {
|
|
20
|
+
const parsed = parseWorkflow(workflow.contents);
|
|
21
|
+
const jobs = parsed.jobs.flatMap((job) => {
|
|
22
|
+
const start = job.lines.findIndex((line) => /^\s*steps\s*:/.test(line));
|
|
23
|
+
if (start < 0) {
|
|
24
|
+
const called = job.lines.some((line) =>
|
|
25
|
+
/^\s*uses\s*:\s*["']?\.\/\.github\/workflows\//.test(line),
|
|
26
|
+
);
|
|
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
|
+
: [];
|
|
32
|
+
}
|
|
33
|
+
const prefix = job.lines.slice(0, start + 1);
|
|
34
|
+
const blocks = [];
|
|
35
|
+
for (const line of job.lines.slice(start + 1)) {
|
|
36
|
+
if (/^\s*-\s+[\w-]+\s*:/.test(line)) {
|
|
37
|
+
blocks.push([]);
|
|
38
|
+
}
|
|
39
|
+
if (blocks.length) {
|
|
40
|
+
blocks.at(-1).push(line);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const fallback = working(prefix) ?? working(parsed.header) ?? ".";
|
|
44
|
+
const matching = blocks.filter((block) => {
|
|
45
|
+
const commands = executableLines(block);
|
|
46
|
+
if (!commands.length) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const text = commands.join("\n");
|
|
50
|
+
if (/--workspaces|--recursive|\bpublish\s+--all\b/.test(text)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
text.includes(`--workspace=${packageInfo.name}`) ||
|
|
55
|
+
text.includes(`--workspace ${packageInfo.name}`)
|
|
56
|
+
) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
const cd = /\bcd\s+['"]?([\w./-]+)/.exec(text)?.[1];
|
|
60
|
+
const scripts = scriptReferences(commands);
|
|
61
|
+
const inferred = scripts.find(
|
|
62
|
+
(script) => directory !== "." && script.startsWith(`${directory}/`),
|
|
63
|
+
)
|
|
64
|
+
? directory
|
|
65
|
+
: undefined;
|
|
66
|
+
const cwd =
|
|
67
|
+
(working(block) ?? cd ?? inferred ?? fallback)
|
|
68
|
+
.replace(/^\.\//, "")
|
|
69
|
+
.replace(/\/$/, "") || ".";
|
|
70
|
+
return cwd === directory;
|
|
71
|
+
});
|
|
72
|
+
return matching.length
|
|
73
|
+
? [
|
|
74
|
+
` ${job.name}:\n${prefix.join("\n")}\n${matching.flat().join("\n")}`,
|
|
75
|
+
]
|
|
76
|
+
: [];
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
...workflow,
|
|
80
|
+
contents: `${parsed.header.join("\n")}\njobs:\n${jobs.join("\n")}`,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Warn when a second npm manifest re-exports a sibling but stops tracking it. */
|
|
86
|
+
export async function auditWrappers(root, packages) {
|
|
87
|
+
const npm = packages.filter((item) => item.registry === "npm");
|
|
88
|
+
for (const wrapper of npm) {
|
|
89
|
+
const data = JSON.parse(
|
|
90
|
+
(await readFile(path.join(root, wrapper.manifest), "utf8")).replace(
|
|
91
|
+
/^\uFEFF/,
|
|
92
|
+
"",
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
for (const target of npm.filter(
|
|
96
|
+
(target) => target !== wrapper && data.dependencies?.[target.name],
|
|
97
|
+
)) {
|
|
98
|
+
if (wrapper.version !== target.version) {
|
|
99
|
+
wrapper.warnings = [
|
|
100
|
+
...(wrapper.warnings ?? []),
|
|
101
|
+
`wrapper ${wrapper.name}@${wrapper.version} differs from ${target.name}@${target.version}; update both manifests and the wrapper dependency for every release`,
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/plan.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
npmFlow,
|
|
7
7
|
pypiFlow,
|
|
8
8
|
} from "./flows.mjs";
|
|
9
|
+
import { credentialPolicy } from "./credential-cycle.mjs";
|
|
10
|
+
import { trustedFlow, tokenFlow } from "./extra-flows.mjs";
|
|
11
|
+
import { bootstrapRef } from "./bootstrap-reference.mjs";
|
|
9
12
|
import { pagesSteps } from "./pages.mjs";
|
|
10
13
|
import { planPrerequisites } from "./prerequisites.mjs";
|
|
11
14
|
import { TRUSTED_REGISTRIES } from "./publishers.mjs";
|
|
@@ -17,6 +20,9 @@ const FLOWS = new Map([
|
|
|
17
20
|
["pypi", pypiFlow],
|
|
18
21
|
["docker-hub", dockerHubFlow],
|
|
19
22
|
["ghcr", ghcrFlow],
|
|
23
|
+
["rubygems", trustedFlow],
|
|
24
|
+
["nuget", trustedFlow],
|
|
25
|
+
["jsr", trustedFlow],
|
|
20
26
|
]);
|
|
21
27
|
|
|
22
28
|
/**
|
|
@@ -65,6 +71,9 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
65
71
|
case "pypi":
|
|
66
72
|
case "docker-hub":
|
|
67
73
|
case "ghcr":
|
|
74
|
+
case "rubygems":
|
|
75
|
+
case "nuget":
|
|
76
|
+
case "jsr":
|
|
68
77
|
return flowPlan(inspection, packageInfo, options);
|
|
69
78
|
case "go-modules":
|
|
70
79
|
steps = [
|
|
@@ -84,37 +93,17 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
84
93
|
},
|
|
85
94
|
];
|
|
86
95
|
break;
|
|
87
|
-
case "nuget":
|
|
88
|
-
steps = [
|
|
89
|
-
check(
|
|
90
|
-
"pack-package",
|
|
91
|
-
"Build the NuGet package",
|
|
92
|
-
"Create the package locally without pushing it.",
|
|
93
|
-
command("dotnet", ["pack", "--configuration", "Release"]),
|
|
94
|
-
),
|
|
95
|
-
browser(
|
|
96
|
-
"configure-trusted-publishing",
|
|
97
|
-
"Configure NuGet trusted publishing",
|
|
98
|
-
"Sign in and add a GitHub Actions federated credential for this package.",
|
|
99
|
-
"https://www.nuget.org/account/TrustedPublishing",
|
|
100
|
-
),
|
|
101
|
-
];
|
|
102
|
-
break;
|
|
103
96
|
case "maven-central":
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"Sign in to the Central Portal and verify the namespace used by the package coordinates.",
|
|
115
|
-
"https://central.sonatype.com/publishing/namespaces",
|
|
116
|
-
),
|
|
117
|
-
];
|
|
97
|
+
case "vscode-marketplace":
|
|
98
|
+
case "open-vsx":
|
|
99
|
+
case "chrome-web-store":
|
|
100
|
+
if (!packageInfo.workflow && !options.workflow) {
|
|
101
|
+
return {
|
|
102
|
+
...basePlan(inspection, packageInfo, []),
|
|
103
|
+
skipped_reason: `no workflow publishes ${packageInfo.name} to ${packageInfo.registry}; pass --workflow <file> for an explicit setup target`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
steps = tokenFlow(packageInfo);
|
|
118
107
|
break;
|
|
119
108
|
case "packagist":
|
|
120
109
|
steps = [
|
|
@@ -143,6 +132,7 @@ function basePlan(inspection, packageInfo, steps) {
|
|
|
143
132
|
return {
|
|
144
133
|
schema_version: 1,
|
|
145
134
|
registry: packageInfo.registry,
|
|
135
|
+
credential_policy: credentialPolicy(packageInfo.registry),
|
|
146
136
|
package: structuredClone(packageInfo),
|
|
147
137
|
repository: structuredClone(inspection.repository),
|
|
148
138
|
steps,
|
|
@@ -204,6 +194,14 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
204
194
|
} else if (mode === "attach") {
|
|
205
195
|
steps = steps.filter((item) => !BOOTSTRAP_CONDITIONS.has(item.when));
|
|
206
196
|
}
|
|
197
|
+
if (options.ref) {
|
|
198
|
+
const ref = bootstrapRef(options.ref);
|
|
199
|
+
const fetch = steps.find((step) => step.id === "fetch-default-branch");
|
|
200
|
+
if (fetch) {
|
|
201
|
+
fetch.command.args = ["fetch", "origin", ref];
|
|
202
|
+
fetch.title = `Fetch bootstrap ref ${ref}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
207
205
|
// Pages readiness belongs to the repository, so it is checked in every mode.
|
|
208
206
|
const pages = inspection.repository.pages_workflow;
|
|
209
207
|
if (slug && pages) {
|