package-registry-manager 0.22.4 → 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 +7 -3
- package/package.json +4 -4
- package/src/bootstrap-reference.mjs +76 -0
- package/src/browser-catalogue.mjs +3 -3
- package/src/cli.mjs +67 -26
- 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 +113 -8
- package/src/extra-flows.mjs +88 -0
- package/src/flows.mjs +29 -19
- package/src/index.mjs +1 -1
- package/src/model.mjs +15 -0
- package/src/npm-policy.mjs +69 -0
- package/src/package-coverage.mjs +106 -0
- package/src/plan.mjs +51 -30
- package/src/prerequisites.mjs +2 -0
- package/src/publishers.mjs +114 -28
- package/src/python.mjs +149 -0
- package/src/setup-batch.mjs +174 -0
- package/src/setup.mjs +75 -61
- 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 +362 -0
- package/src/workflows.mjs +49 -10
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,9 +6,13 @@ 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";
|
|
15
|
+
import { applyPython } from "./python.mjs";
|
|
12
16
|
|
|
13
17
|
const FLOWS = new Map([
|
|
14
18
|
["npm", npmFlow],
|
|
@@ -16,6 +20,9 @@ const FLOWS = new Map([
|
|
|
16
20
|
["pypi", pypiFlow],
|
|
17
21
|
["docker-hub", dockerHubFlow],
|
|
18
22
|
["ghcr", ghcrFlow],
|
|
23
|
+
["rubygems", trustedFlow],
|
|
24
|
+
["nuget", trustedFlow],
|
|
25
|
+
["jsr", trustedFlow],
|
|
19
26
|
]);
|
|
20
27
|
|
|
21
28
|
/**
|
|
@@ -64,6 +71,9 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
64
71
|
case "pypi":
|
|
65
72
|
case "docker-hub":
|
|
66
73
|
case "ghcr":
|
|
74
|
+
case "rubygems":
|
|
75
|
+
case "nuget":
|
|
76
|
+
case "jsr":
|
|
67
77
|
return flowPlan(inspection, packageInfo, options);
|
|
68
78
|
case "go-modules":
|
|
69
79
|
steps = [
|
|
@@ -83,37 +93,17 @@ function buildPlan(inspection, packageInfo, options) {
|
|
|
83
93
|
},
|
|
84
94
|
];
|
|
85
95
|
break;
|
|
86
|
-
case "nuget":
|
|
87
|
-
steps = [
|
|
88
|
-
check(
|
|
89
|
-
"pack-package",
|
|
90
|
-
"Build the NuGet package",
|
|
91
|
-
"Create the package locally without pushing it.",
|
|
92
|
-
command("dotnet", ["pack", "--configuration", "Release"]),
|
|
93
|
-
),
|
|
94
|
-
browser(
|
|
95
|
-
"configure-trusted-publishing",
|
|
96
|
-
"Configure NuGet trusted publishing",
|
|
97
|
-
"Sign in and add a GitHub Actions federated credential for this package.",
|
|
98
|
-
"https://www.nuget.org/account/TrustedPublishing",
|
|
99
|
-
),
|
|
100
|
-
];
|
|
101
|
-
break;
|
|
102
96
|
case "maven-central":
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
"Sign in to the Central Portal and verify the namespace used by the package coordinates.",
|
|
114
|
-
"https://central.sonatype.com/publishing/namespaces",
|
|
115
|
-
),
|
|
116
|
-
];
|
|
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);
|
|
117
107
|
break;
|
|
118
108
|
case "packagist":
|
|
119
109
|
steps = [
|
|
@@ -142,6 +132,7 @@ function basePlan(inspection, packageInfo, steps) {
|
|
|
142
132
|
return {
|
|
143
133
|
schema_version: 1,
|
|
144
134
|
registry: packageInfo.registry,
|
|
135
|
+
credential_policy: credentialPolicy(packageInfo.registry),
|
|
145
136
|
package: structuredClone(packageInfo),
|
|
146
137
|
repository: structuredClone(inspection.repository),
|
|
147
138
|
steps,
|
|
@@ -166,6 +157,27 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
166
157
|
skipped_reason: `several workflows publish to ${packageInfo.registry} (${packageInfo.workflow_candidates.join(", ")}); pass --workflow <file> to choose the trusted publisher`,
|
|
167
158
|
};
|
|
168
159
|
}
|
|
160
|
+
if (
|
|
161
|
+
trusted &&
|
|
162
|
+
(!workflow ||
|
|
163
|
+
(options.addPublishJob &&
|
|
164
|
+
!packageInfo.workflow &&
|
|
165
|
+
!packageInfo.workflow_candidates?.length))
|
|
166
|
+
) {
|
|
167
|
+
return {
|
|
168
|
+
...basePlan(inspection, packageInfo, [
|
|
169
|
+
{
|
|
170
|
+
id: "add-publishing-workflow",
|
|
171
|
+
title: "Offer a publishing job in a reviewed pull request",
|
|
172
|
+
kind: "manual",
|
|
173
|
+
description:
|
|
174
|
+
"Add the missing publishing job to the workflow used by the other registries, on a new branch and pull request. Review and merge it, then re-run setup to attach the trusted publisher.",
|
|
175
|
+
confirm: true,
|
|
176
|
+
},
|
|
177
|
+
]),
|
|
178
|
+
skipped_reason: `no workflow publishes ${packageInfo.name} to ${packageInfo.registry}; CI releases will not reach it; add a publishing job or pass --workflow <file> before attaching a trusted publisher`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
169
181
|
const context = {
|
|
170
182
|
directory: packageDirectory(packageInfo.manifest),
|
|
171
183
|
slug,
|
|
@@ -182,6 +194,14 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
182
194
|
} else if (mode === "attach") {
|
|
183
195
|
steps = steps.filter((item) => !BOOTSTRAP_CONDITIONS.has(item.when));
|
|
184
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
|
+
}
|
|
185
205
|
// Pages readiness belongs to the repository, so it is checked in every mode.
|
|
186
206
|
const pages = inspection.repository.pages_workflow;
|
|
187
207
|
if (slug && pages) {
|
|
@@ -213,6 +233,7 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
213
233
|
plan.trusted_publisher.project = packageInfo.name;
|
|
214
234
|
}
|
|
215
235
|
}
|
|
236
|
+
applyPython(plan, options.environment?.python);
|
|
216
237
|
return plan;
|
|
217
238
|
}
|
|
218
239
|
|
package/src/prerequisites.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { exec } from "command-stream";
|
|
2
2
|
|
|
3
3
|
import { automatedDescription } from "./browser-options.mjs";
|
|
4
|
+
import { probePython } from "./python.mjs";
|
|
4
5
|
import { getJson, registryEndpoint } from "./registry-state.mjs";
|
|
5
6
|
|
|
6
7
|
/** The Node.js versions npm 11 runs on. */
|
|
@@ -154,6 +155,7 @@ export async function probeEnvironment(options = {}) {
|
|
|
154
155
|
: null;
|
|
155
156
|
return {
|
|
156
157
|
offline,
|
|
158
|
+
python: options.python ? await probePython({ verbose }) : undefined,
|
|
157
159
|
node: nodeVersion,
|
|
158
160
|
npm: npmVersion?.code === 0 ? npmVersion.stdout.trim() : null,
|
|
159
161
|
trustNpm: spec,
|
package/src/publishers.mjs
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { stripComments, commandPosition } from "./source-code.mjs";
|
|
3
4
|
|
|
4
5
|
/** Registries whose trusted publisher is bound to a workflow file. */
|
|
5
|
-
export const TRUSTED_REGISTRIES = Object.freeze([
|
|
6
|
+
export const TRUSTED_REGISTRIES = Object.freeze([
|
|
7
|
+
"npm",
|
|
8
|
+
"crates-io",
|
|
9
|
+
"pypi",
|
|
10
|
+
"rubygems",
|
|
11
|
+
"nuget",
|
|
12
|
+
"jsr",
|
|
13
|
+
]);
|
|
6
14
|
|
|
7
|
-
const JOB_PATTERNS = new Map([
|
|
15
|
+
export const JOB_PATTERNS = new Map([
|
|
16
|
+
["rubygems", /\bgem\s+push\b|rubygems\/release-gem/],
|
|
17
|
+
["nuget", /\bdotnet\s+nuget\s+push\b|\bnuget\s+push\b/],
|
|
18
|
+
["jsr", /\bdeno\s+publish\b|\bjsr\s+publish\b/],
|
|
19
|
+
["docker-hub", /docker\/login-action|\bdocker\s+(?:login|push)\b/],
|
|
20
|
+
["maven-central", /\bmvn\s+(?:-\S+\s+)*deploy\b|\bgradle(?:w)?\s+publish\b/],
|
|
21
|
+
["vscode-marketplace", /\bvsce\s+publish\b/],
|
|
22
|
+
["open-vsx", /\bovsx\s+publish\b/],
|
|
23
|
+
[
|
|
24
|
+
"chrome-web-store",
|
|
25
|
+
/chrome-webstore-upload|chrome-web-store|chromewebstore/,
|
|
26
|
+
],
|
|
8
27
|
[
|
|
9
28
|
"npm",
|
|
10
29
|
/\b(?:npm|pnpm)\s+(?:-r\s+|--recursive\s+)?(?:stage\s+)?publish\b|\byarn\s+npm\s+publish\b|\bchangeset\s+publish\b|changesets\/action\/publish@|JS-DevTools\/npm-publish/,
|
|
@@ -15,7 +34,7 @@ const JOB_PATTERNS = new Map([
|
|
|
15
34
|
],
|
|
16
35
|
[
|
|
17
36
|
"pypi",
|
|
18
|
-
/pypa\/gh-action-pypi-publish|\
|
|
37
|
+
/pypa\/gh-action-pypi-publish|\b(?:python(?:3(?:\.\d+)?)?\s+-m\s+)?twine\s+upload\b|\b(?:uv|poetry|hatch|flit|pdm)\s+publish\b/,
|
|
19
38
|
],
|
|
20
39
|
]);
|
|
21
40
|
|
|
@@ -24,6 +43,12 @@ const JOB_PATTERNS = new Map([
|
|
|
24
43
|
// `.arg("publish")` within the next few lines.
|
|
25
44
|
const SCRIPT_WINDOW = 3;
|
|
26
45
|
const SCRIPT_PATTERNS = new Map([
|
|
46
|
+
["rubygems", [/\bgem\b/, /["'`]push["'`]/]],
|
|
47
|
+
["nuget", [/\b(?:dotnet|nuget)\b/, /["'`]push["'`]/]],
|
|
48
|
+
["jsr", [/\b(?:deno|jsr)\b/, /["'`]publish["'`]/]],
|
|
49
|
+
["maven-central", [/\b(?:mvn|gradle)\b/, /["'`]deploy["'`]/]],
|
|
50
|
+
["vscode-marketplace", [/\bvsce\b/, /["'`]publish["'`]/]],
|
|
51
|
+
["open-vsx", [/\bovsx\b/, /["'`]publish["'`]/]],
|
|
27
52
|
["npm", [/\b(?:npm|pnpm|yarn)\b/, /["'`]publish["'`]/]],
|
|
28
53
|
["crates-io", [/\bcargo\b/, /["'`]publish["'`]/]],
|
|
29
54
|
[
|
|
@@ -33,6 +58,27 @@ const SCRIPT_PATTERNS = new Map([
|
|
|
33
58
|
]);
|
|
34
59
|
|
|
35
60
|
const TOKEN_SECRETS = new Map([
|
|
61
|
+
["rubygems", ["GEM_HOST_API_KEY", "RUBYGEMS_API_KEY"]],
|
|
62
|
+
["nuget", ["NUGET_API_KEY", "NUGET_TOKEN"]],
|
|
63
|
+
["jsr", ["JSR_TOKEN"]],
|
|
64
|
+
["docker-hub", ["DOCKERHUB_TOKEN", "DOCKER_HUB_TOKEN", "DOCKER_PASSWORD"]],
|
|
65
|
+
[
|
|
66
|
+
"maven-central",
|
|
67
|
+
[
|
|
68
|
+
"MAVEN_CENTRAL_TOKEN",
|
|
69
|
+
"MAVEN_CENTRAL_PASSWORD",
|
|
70
|
+
"OSSRH_TOKEN",
|
|
71
|
+
"OSSRH_PASSWORD",
|
|
72
|
+
"SONATYPE_TOKEN",
|
|
73
|
+
"CENTRAL_TOKEN",
|
|
74
|
+
],
|
|
75
|
+
],
|
|
76
|
+
["vscode-marketplace", ["VSCE_PAT", "VSCE_TOKEN"]],
|
|
77
|
+
["open-vsx", ["OVSX_PAT", "OVSX_TOKEN"]],
|
|
78
|
+
[
|
|
79
|
+
"chrome-web-store",
|
|
80
|
+
["CHROME_WEB_STORE_REFRESH_TOKEN", "CHROME_REFRESH_TOKEN"],
|
|
81
|
+
],
|
|
36
82
|
["npm", ["NPM_TOKEN", "NPM_AUTH_TOKEN"]],
|
|
37
83
|
[
|
|
38
84
|
"crates-io",
|
|
@@ -61,9 +107,6 @@ const RUN_SCRIPT =
|
|
|
61
107
|
const CHANGESETS_PUBLISH = /^\s*publish(?:-script)?\s*:\s*(.+?)\s*$/;
|
|
62
108
|
const LOCAL_WORKFLOW =
|
|
63
109
|
/^\s*uses\s*:\s*["']?\.\/\.github\/workflows\/([\w.-]+\.ya?ml)/;
|
|
64
|
-
const SCRIPT_COMMENT = /^\s*(?:#|\/\/|\/?\*)/;
|
|
65
|
-
const COMMAND_PREFIX =
|
|
66
|
-
/^\s*(?:(?:npx|bunx|exec|sudo|env|time|command|pnpm\s+exec|yarn|\w+=\S*)\s+)*$/;
|
|
67
110
|
const MAX_SCRIPT_BYTES = 1024 * 1024;
|
|
68
111
|
|
|
69
112
|
/**
|
|
@@ -71,7 +114,7 @@ const MAX_SCRIPT_BYTES = 1024 * 1024;
|
|
|
71
114
|
* parser is bundled. Full-line comments are dropped.
|
|
72
115
|
*/
|
|
73
116
|
export function parseWorkflow(contents) {
|
|
74
|
-
const lines = contents.split(/\r?\n/)
|
|
117
|
+
const lines = stripComments(contents).split(/\r?\n/);
|
|
75
118
|
const start = lines.findIndex((line) => /^jobs\s*:\s*$/.test(line));
|
|
76
119
|
if (start === -1) {
|
|
77
120
|
return { header: lines, jobs: [] };
|
|
@@ -145,7 +188,9 @@ export async function detectPublisher(root, workflows, registry) {
|
|
|
145
188
|
});
|
|
146
189
|
}
|
|
147
190
|
}
|
|
148
|
-
const trusted = found.filter(
|
|
191
|
+
const trusted = found.filter(
|
|
192
|
+
(item) => !TRUSTED_REGISTRIES.includes(registry) || item.idToken,
|
|
193
|
+
);
|
|
149
194
|
const files = [...new Set(trusted.map((item) => item.workflow))];
|
|
150
195
|
if (files.length > 1) {
|
|
151
196
|
return {
|
|
@@ -215,7 +260,7 @@ export function tokenSecrets(workflows, registry) {
|
|
|
215
260
|
const names = TOKEN_SECRETS.get(registry) ?? [];
|
|
216
261
|
const found = [];
|
|
217
262
|
for (const workflow of workflows) {
|
|
218
|
-
const lines = workflow.contents
|
|
263
|
+
const lines = stripComments(workflow.contents)
|
|
219
264
|
.split(/\r?\n/)
|
|
220
265
|
.filter((line) => !/^\s*#/.test(line));
|
|
221
266
|
for (const name of names) {
|
|
@@ -231,7 +276,9 @@ export function tokenSecrets(workflows, registry) {
|
|
|
231
276
|
|
|
232
277
|
/** Explains how to replace a long-lived token secret with trusted publishing. */
|
|
233
278
|
export function tokenSecretWarning(registry, { workflow, secret }) {
|
|
234
|
-
return
|
|
279
|
+
return TRUSTED_REGISTRIES.includes(registry)
|
|
280
|
+
? `${workflow} reads secrets.${secret}; publish with ${TRUSTED_PUBLISHING_HINTS.get(registry) ?? `${registry} trusted publishing (id-token: write)`} and delete the long-lived token after verification`
|
|
281
|
+
: `${workflow} reads secrets.${secret}; ensure a scoped expiring publishing token through gh-manager`;
|
|
235
282
|
}
|
|
236
283
|
|
|
237
284
|
/** Names of the long-lived token secrets that trusted publishing replaces. */
|
|
@@ -250,14 +297,15 @@ async function anyJobPublishes(root, parsed, registry) {
|
|
|
250
297
|
|
|
251
298
|
async function jobPublishes(root, parsed, job, registry) {
|
|
252
299
|
const pattern = JOB_PATTERNS.get(registry);
|
|
253
|
-
|
|
300
|
+
const executable = executableLines(job.lines);
|
|
301
|
+
if (publishingLine(executable, pattern)) {
|
|
254
302
|
return true;
|
|
255
303
|
}
|
|
256
304
|
const directories = workingDirectories([...parsed.header, ...job.lines]);
|
|
257
305
|
const scripts = [];
|
|
258
|
-
for (const script of scriptReferences(
|
|
306
|
+
for (const script of scriptReferences(executable)) {
|
|
259
307
|
const contents = await readInside(root, directories, script);
|
|
260
|
-
const lines = contents === null ? [] : codeLines(contents);
|
|
308
|
+
const lines = contents === null ? [] : codeLines(contents, script);
|
|
261
309
|
if (scriptPublishes(lines, registry)) {
|
|
262
310
|
return true;
|
|
263
311
|
}
|
|
@@ -274,8 +322,11 @@ async function jobPublishes(root, parsed, job, registry) {
|
|
|
274
322
|
}
|
|
275
323
|
// A package script may run from the job, its changesets command, or a
|
|
276
324
|
// script, as in `$\`npm run changeset:publish\``.
|
|
277
|
-
for (const line of [...
|
|
325
|
+
for (const line of [...executable, ...commands, ...scripts]) {
|
|
278
326
|
for (const match of line.matchAll(RUN_SCRIPT)) {
|
|
327
|
+
if (!commandPosition(line.slice(0, match.index))) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
279
330
|
const manifest = await readInside(root, directories, "package.json");
|
|
280
331
|
const command =
|
|
281
332
|
manifest === null
|
|
@@ -312,17 +363,52 @@ function onlyCalled(header) {
|
|
|
312
363
|
}
|
|
313
364
|
|
|
314
365
|
function publishingLine(lines, pattern) {
|
|
366
|
+
if (!pattern) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
const matcher = new RegExp(pattern.source, "g");
|
|
315
370
|
return lines.some(
|
|
316
|
-
(line) =>
|
|
371
|
+
(line) =>
|
|
372
|
+
!line.includes("--dry-run") &&
|
|
373
|
+
[...line.matchAll(matcher)].some(
|
|
374
|
+
(match) =>
|
|
375
|
+
commandPosition(line.slice(0, match.index)) ||
|
|
376
|
+
/^\s*-?\s*uses\s*:\s*['"]?[\w./-]*$/.test(line.slice(0, match.index)),
|
|
377
|
+
),
|
|
317
378
|
);
|
|
318
379
|
}
|
|
319
380
|
|
|
320
|
-
|
|
321
|
-
|
|
381
|
+
/** Extract executable run blocks and action references; names and env values are inert. */
|
|
382
|
+
export function executableLines(lines) {
|
|
383
|
+
const result = [];
|
|
384
|
+
let blockIndent = null;
|
|
385
|
+
for (const line of lines) {
|
|
386
|
+
const indent = indentOf(line);
|
|
387
|
+
if (blockIndent !== null && (line.trim() === "" || indent > blockIndent)) {
|
|
388
|
+
result.push(line);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
blockIndent = null;
|
|
392
|
+
if (/^\s*-?\s*uses\s*:/.test(line)) {
|
|
393
|
+
result.push(line);
|
|
394
|
+
}
|
|
395
|
+
const match = /^\s*-?\s*run\s*:\s*(.*)$/.exec(line);
|
|
396
|
+
if (!match) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (/^[|>][-+\d]*$/.test(match[1])) {
|
|
400
|
+
blockIndent = indent + (line.trimStart().startsWith("-") ? 2 : 0);
|
|
401
|
+
} else {
|
|
402
|
+
result.push(unquote(match[1]));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function codeLines(contents, script) {
|
|
409
|
+
return stripComments(contents, script)
|
|
322
410
|
.split(/\r?\n/)
|
|
323
|
-
.filter(
|
|
324
|
-
(line) => !line.includes("--dry-run") && !SCRIPT_COMMENT.test(line),
|
|
325
|
-
);
|
|
411
|
+
.filter((line) => !line.includes("--dry-run"));
|
|
326
412
|
}
|
|
327
413
|
|
|
328
414
|
function scriptPublishes(lines, registry) {
|
|
@@ -336,23 +422,23 @@ function scriptPublishes(lines, registry) {
|
|
|
336
422
|
) {
|
|
337
423
|
return true;
|
|
338
424
|
}
|
|
339
|
-
const
|
|
425
|
+
const patterns = SCRIPT_PATTERNS.get(registry);
|
|
426
|
+
if (!patterns) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
const [program, subcommand] = patterns;
|
|
340
430
|
return lines.some(
|
|
341
431
|
(line, index) =>
|
|
342
432
|
program.test(line) &&
|
|
433
|
+
/(?:exec|spawn|run|Command::new|subprocess|system|(?:npm|pnpm|yarn)\s*\(\s*\[)/.test(
|
|
434
|
+
line,
|
|
435
|
+
) &&
|
|
343
436
|
lines
|
|
344
437
|
.slice(index, index + SCRIPT_WINDOW)
|
|
345
438
|
.some((nearby) => subcommand.test(nearby)),
|
|
346
439
|
);
|
|
347
440
|
}
|
|
348
441
|
|
|
349
|
-
// A script mentions `cargo publish` in messages and comments too; it runs it
|
|
350
|
-
// only where the command starts a line, a shell command or a string literal.
|
|
351
|
-
function commandPosition(prefix) {
|
|
352
|
-
const segment = prefix.split(/["'`;&|(]/).at(-1);
|
|
353
|
-
return COMMAND_PREFIX.test(segment);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
442
|
function packageScript(contents, name) {
|
|
357
443
|
try {
|
|
358
444
|
const command = JSON.parse(contents).scripts?.[name];
|