package-registry-manager 0.22.4 → 0.22.5
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 +6 -3
- package/package.json +3 -3
- package/src/browser-catalogue.mjs +3 -3
- package/src/cli.mjs +55 -23
- package/src/discovery.mjs +16 -1
- package/src/index.mjs +1 -1
- package/src/plan.mjs +23 -0
- package/src/prerequisites.mjs +2 -0
- package/src/publishers.mjs +1 -1
- package/src/python.mjs +149 -0
- package/src/setup-batch.mjs +174 -0
- package/src/setup.mjs +30 -57
- package/src/workflow-proposal.mjs +333 -0
package/README.md
CHANGED
|
@@ -10,12 +10,15 @@ browser, filling forms in a dedicated visible browser profile.
|
|
|
10
10
|
npx package-registry-manager inspect --repository /path/to/repository
|
|
11
11
|
npx package-registry-manager plan --repository /path/to/repository --format json
|
|
12
12
|
npx package-registry-manager setup --repository /path/to/repository --registry npm
|
|
13
|
+
npx package-registry-manager setup --repository /path/to/repository --all --execute
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
The package installs the command as both `package-registry-manager` and
|
|
16
17
|
`package-registry-manager-js`; use the second name when the Rust command of the
|
|
17
18
|
same name is also on your `PATH`.
|
|
18
19
|
|
|
19
|
-
Setup is a dry run unless `--execute` is present
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
Setup is a dry run unless `--execute` is present. It can publish the first
|
|
21
|
+
version after confirmation; later versions publish through CI. Missing CI
|
|
22
|
+
publishing jobs are offered in a draft pull request, and PyPI builds select an
|
|
23
|
+
interpreter satisfying `requires-python`. See the repository README for the
|
|
24
|
+
registry walkthroughs and safety details.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "package-registry-manager",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.5",
|
|
4
4
|
"description": "Inspect repositories and guide secure package-registry setup",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"provenance": true
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs",
|
|
31
|
+
"syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs && node --check src/python.mjs && node --check src/setup-batch.mjs && node --check src/workflow-proposal.mjs",
|
|
32
32
|
"lint": "eslint src test scripts --max-warnings 0",
|
|
33
33
|
"lint:fix": "eslint src test scripts --fix --max-warnings 0",
|
|
34
34
|
"format": "prettier --write .",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"audit": "node scripts/npm-audit.mjs"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"browser-commander": "^0.
|
|
43
|
+
"browser-commander": "^0.26.0",
|
|
44
44
|
"command-stream": "^1.6.2",
|
|
45
45
|
"playwright": "^1.63.0",
|
|
46
46
|
"selenium-webdriver": "^4.50.0"
|
|
@@ -11,7 +11,7 @@ export const browserSources = () => BROWSER_IDS.map(findBrowserSource);
|
|
|
11
11
|
/** Catalogue channels with a launch control protocol. */
|
|
12
12
|
export function launchChannels() {
|
|
13
13
|
return browserSources()
|
|
14
|
-
.filter((source) => source.controlProtocol)
|
|
14
|
+
.filter((source) => ["cdp", "bidi"].includes(source.controlProtocol))
|
|
15
15
|
.flatMap((source) => [source.id, ...(source.aliases ?? [])]);
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -122,11 +122,11 @@ export function installedDescription(options = {}) {
|
|
|
122
122
|
/** Reject unavailable engines before creating an automated profile. */
|
|
123
123
|
export function validateChannel(channel) {
|
|
124
124
|
const known = findBrowserSource(channel);
|
|
125
|
-
if (known?.controlProtocol) {
|
|
125
|
+
if (["cdp", "bidi"].includes(known?.controlProtocol)) {
|
|
126
126
|
return channel;
|
|
127
127
|
}
|
|
128
128
|
const reason = known
|
|
129
|
-
? `${channel} has no launch control protocol
|
|
129
|
+
? `${channel} has no launch control protocol supported by this manager${known.family === "safari" ? " (Safari setup: https://github.com/link-foundation/browser-commander/issues/126)" : ""}`
|
|
130
130
|
: `unknown --browser-channel '${channel}'`;
|
|
131
131
|
throw new Error(
|
|
132
132
|
`${reason}; choose from ${launchChannels().join(", ")}. ${installedDescription()}`,
|
package/src/cli.mjs
CHANGED
|
@@ -14,7 +14,11 @@ import { parseRegistry } from "./model.mjs";
|
|
|
14
14
|
import { buildPlans } from "./plan.mjs";
|
|
15
15
|
import { probeEnvironment, renderPrerequisites } from "./prerequisites.mjs";
|
|
16
16
|
import { probeRegistryState } from "./registry-state.mjs";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
BROWSER_MODES,
|
|
19
|
+
defaultBrowserProfile,
|
|
20
|
+
executePlans,
|
|
21
|
+
} from "./setup.mjs";
|
|
18
22
|
import { importSources } from "./sign-in-import.mjs";
|
|
19
23
|
|
|
20
24
|
export { isDirectExecution };
|
|
@@ -25,6 +29,7 @@ Commands:
|
|
|
25
29
|
inspect Discover supported package manifests
|
|
26
30
|
plan [--registry <registry>] Print ordered setup plans
|
|
27
31
|
setup --registry <registry> Bootstrap or attach trusted publishing
|
|
32
|
+
setup --all Set up every publishable package in one session
|
|
28
33
|
|
|
29
34
|
Global options:
|
|
30
35
|
-h, --help Print this help
|
|
@@ -41,6 +46,8 @@ Plan and setup options:
|
|
|
41
46
|
--workflow <file> Trusted-publisher workflow file in
|
|
42
47
|
.github/workflows, when detection finds
|
|
43
48
|
several or the wrong one
|
|
49
|
+
--add-publish-job Propose missing publishing jobs in a draft PR;
|
|
50
|
+
--workflow chooses its target file
|
|
44
51
|
--environment <name> GitHub environment of the trusted publisher
|
|
45
52
|
--package <name> Plan only this package, or select it for
|
|
46
53
|
setup when a registry has several
|
|
@@ -104,11 +111,13 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
104
111
|
offline: { type: "boolean", default: false },
|
|
105
112
|
"verify-release": { type: "boolean", default: false },
|
|
106
113
|
workflow: { type: "string" },
|
|
114
|
+
"add-publish-job": { type: "boolean", default: false },
|
|
107
115
|
environment: { type: "string" },
|
|
108
116
|
"dry-run": { type: "boolean", default: false },
|
|
109
117
|
registry: { type: "string", multiple: true },
|
|
110
118
|
package: { type: "string" },
|
|
111
119
|
execute: { type: "boolean", default: false },
|
|
120
|
+
all: { type: "boolean", default: false },
|
|
112
121
|
yes: { type: "boolean", default: false },
|
|
113
122
|
"no-browser": { type: "boolean", default: false },
|
|
114
123
|
"open-with": { type: "string" },
|
|
@@ -175,6 +184,12 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
175
184
|
const environment = await probeEnvironment({
|
|
176
185
|
offline: values.offline,
|
|
177
186
|
verbose: values.verbose,
|
|
187
|
+
python: inspection.packages.some(
|
|
188
|
+
(item) =>
|
|
189
|
+
item.registry === "pypi" &&
|
|
190
|
+
item.publishable &&
|
|
191
|
+
(registries.length === 0 || registries.includes("pypi")),
|
|
192
|
+
),
|
|
178
193
|
npm: inspection.packages.some(
|
|
179
194
|
(item) =>
|
|
180
195
|
item.registry === "npm" &&
|
|
@@ -185,7 +200,12 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
185
200
|
const planOptions = {
|
|
186
201
|
verifyRelease: values["verify-release"],
|
|
187
202
|
manual: values.manual,
|
|
188
|
-
workflow: await workflowOverride(
|
|
203
|
+
workflow: await workflowOverride(
|
|
204
|
+
repository,
|
|
205
|
+
values.workflow,
|
|
206
|
+
values["add-publish-job"],
|
|
207
|
+
),
|
|
208
|
+
addPublishJob: values["add-publish-job"],
|
|
189
209
|
publisherEnvironment: nonEmpty(values.environment, "--environment"),
|
|
190
210
|
environment,
|
|
191
211
|
browser: browserSummary(command, values, browserOptions),
|
|
@@ -198,7 +218,10 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
198
218
|
outputPlans(plans, values.format);
|
|
199
219
|
return;
|
|
200
220
|
}
|
|
201
|
-
if (
|
|
221
|
+
if (values.all && values.package) {
|
|
222
|
+
throw new Error("--all and --package are mutually exclusive");
|
|
223
|
+
}
|
|
224
|
+
if (!values.all && registries.length !== 1) {
|
|
202
225
|
throw new Error("setup requires exactly one --registry <registry>");
|
|
203
226
|
}
|
|
204
227
|
if (values["dry-run"] && values.execute) {
|
|
@@ -215,27 +238,36 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
215
238
|
}
|
|
216
239
|
|
|
217
240
|
const plans = buildPlans(inspection, registries, planOptions);
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
values
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
const selected = values.all
|
|
242
|
+
? plans.filter((plan) => plan.package.publishable)
|
|
243
|
+
: [selectPlan(plans, values.package)];
|
|
244
|
+
outputPlans(selected, values.format);
|
|
245
|
+
await executePlans(
|
|
246
|
+
selected,
|
|
247
|
+
{
|
|
248
|
+
inspection,
|
|
249
|
+
workflow: planOptions.workflow,
|
|
250
|
+
publisherEnvironment: planOptions.publisherEnvironment,
|
|
251
|
+
repository,
|
|
252
|
+
execute: values.execute,
|
|
253
|
+
yes: values.yes,
|
|
254
|
+
noBrowser: values["no-browser"],
|
|
255
|
+
openWith: nonEmpty(values["open-with"], "--open-with"),
|
|
256
|
+
keepSession: values["keep-session"],
|
|
257
|
+
browser: values.browser,
|
|
258
|
+
browserOptions,
|
|
259
|
+
browserProfile: path.resolve(
|
|
260
|
+
values["browser-profile"] ??
|
|
261
|
+
defaultBrowserProfile({ channel: browserOptions.channel }),
|
|
262
|
+
),
|
|
263
|
+
verbose: values.verbose,
|
|
264
|
+
verifyRelease: values["verify-release"],
|
|
265
|
+
},
|
|
266
|
+
values.all,
|
|
267
|
+
);
|
|
236
268
|
}
|
|
237
269
|
|
|
238
|
-
async function workflowOverride(repository, workflow) {
|
|
270
|
+
async function workflowOverride(repository, workflow, adding = false) {
|
|
239
271
|
if (workflow === undefined) {
|
|
240
272
|
return undefined;
|
|
241
273
|
}
|
|
@@ -249,7 +281,7 @@ async function workflowOverride(repository, workflow) {
|
|
|
249
281
|
(metadata) => metadata.isFile(),
|
|
250
282
|
() => false,
|
|
251
283
|
);
|
|
252
|
-
if (!found) {
|
|
284
|
+
if (!found && !adding) {
|
|
253
285
|
throw new Error(`--workflow: .github/workflows/${workflow} does not exist`);
|
|
254
286
|
}
|
|
255
287
|
return workflow;
|
package/src/discovery.mjs
CHANGED
|
@@ -178,6 +178,11 @@ function withPublisher(item, workflows, publishers) {
|
|
|
178
178
|
const secrets = tokenSecrets(workflows, item.registry);
|
|
179
179
|
const warnings = [
|
|
180
180
|
...publisher.warnings,
|
|
181
|
+
...(!publisher.workflow && publisher.candidates.length === 0
|
|
182
|
+
? [
|
|
183
|
+
`no workflow publishes ${item.name} to ${item.registry}; CI releases will not reach it`,
|
|
184
|
+
]
|
|
185
|
+
: []),
|
|
181
186
|
...secrets.map((secret) => tokenSecretWarning(item.registry, secret)),
|
|
182
187
|
];
|
|
183
188
|
if (warnings.length > 0) {
|
|
@@ -291,7 +296,17 @@ function parsePyproject(contents, manifest) {
|
|
|
291
296
|
if (!name) {
|
|
292
297
|
return null;
|
|
293
298
|
}
|
|
294
|
-
|
|
299
|
+
const item = packageInfo(
|
|
300
|
+
"pypi",
|
|
301
|
+
name,
|
|
302
|
+
tomlString(section, "version"),
|
|
303
|
+
manifest,
|
|
304
|
+
);
|
|
305
|
+
const required = tomlString(section, "requires-python");
|
|
306
|
+
if (required) {
|
|
307
|
+
item.requires_python = required;
|
|
308
|
+
}
|
|
309
|
+
return item;
|
|
295
310
|
}
|
|
296
311
|
|
|
297
312
|
function parseDotnet(contents, manifestPath, manifest) {
|
package/src/index.mjs
CHANGED
|
@@ -3,4 +3,4 @@ export { inspectRepository } from "./discovery.mjs";
|
|
|
3
3
|
export { REGISTRIES, parseRegistry } from "./model.mjs";
|
|
4
4
|
export { buildPlans, packageDirectory } from "./plan.mjs";
|
|
5
5
|
export { ensureProfileIgnored } from "./profile.mjs";
|
|
6
|
-
export { defaultBrowserProfile, executePlan } from "./setup.mjs";
|
|
6
|
+
export { defaultBrowserProfile, executePlan, executePlans } from "./setup.mjs";
|
package/src/plan.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { pagesSteps } from "./pages.mjs";
|
|
10
10
|
import { planPrerequisites } from "./prerequisites.mjs";
|
|
11
11
|
import { TRUSTED_REGISTRIES } from "./publishers.mjs";
|
|
12
|
+
import { applyPython } from "./python.mjs";
|
|
12
13
|
|
|
13
14
|
const FLOWS = new Map([
|
|
14
15
|
["npm", npmFlow],
|
|
@@ -166,6 +167,27 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
166
167
|
skipped_reason: `several workflows publish to ${packageInfo.registry} (${packageInfo.workflow_candidates.join(", ")}); pass --workflow <file> to choose the trusted publisher`,
|
|
167
168
|
};
|
|
168
169
|
}
|
|
170
|
+
if (
|
|
171
|
+
trusted &&
|
|
172
|
+
(!workflow ||
|
|
173
|
+
(options.addPublishJob &&
|
|
174
|
+
!packageInfo.workflow &&
|
|
175
|
+
!packageInfo.workflow_candidates?.length))
|
|
176
|
+
) {
|
|
177
|
+
return {
|
|
178
|
+
...basePlan(inspection, packageInfo, [
|
|
179
|
+
{
|
|
180
|
+
id: "add-publishing-workflow",
|
|
181
|
+
title: "Offer a publishing job in a reviewed pull request",
|
|
182
|
+
kind: "manual",
|
|
183
|
+
description:
|
|
184
|
+
"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.",
|
|
185
|
+
confirm: true,
|
|
186
|
+
},
|
|
187
|
+
]),
|
|
188
|
+
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`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
169
191
|
const context = {
|
|
170
192
|
directory: packageDirectory(packageInfo.manifest),
|
|
171
193
|
slug,
|
|
@@ -213,6 +235,7 @@ function flowPlan(inspection, packageInfo, options) {
|
|
|
213
235
|
plan.trusted_publisher.project = packageInfo.name;
|
|
214
236
|
}
|
|
215
237
|
}
|
|
238
|
+
applyPython(plan, options.environment?.python);
|
|
216
239
|
return plan;
|
|
217
240
|
}
|
|
218
241
|
|
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
|
@@ -15,7 +15,7 @@ const JOB_PATTERNS = new Map([
|
|
|
15
15
|
],
|
|
16
16
|
[
|
|
17
17
|
"pypi",
|
|
18
|
-
/pypa\/gh-action-pypi-publish|\
|
|
18
|
+
/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
19
|
],
|
|
20
20
|
]);
|
|
21
21
|
|
package/src/python.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exec } from "command-stream";
|
|
4
|
+
|
|
5
|
+
// requires-python describes interpreter releases. Reject unsupported or
|
|
6
|
+
// prerelease syntax rather than silently choosing an incompatible executable.
|
|
7
|
+
function release(value) {
|
|
8
|
+
return /^\d+(?:\.\d+){0,2}$/.test(value)
|
|
9
|
+
? value.split(".").map(Number)
|
|
10
|
+
: null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function compare(left, right) {
|
|
14
|
+
for (let i = 0; i < 3; i += 1) {
|
|
15
|
+
const difference = (left[i] ?? 0) - (right[i] ?? 0);
|
|
16
|
+
if (difference) {
|
|
17
|
+
return Math.sign(difference);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Tests stable interpreter versions against requires-python specifiers. */
|
|
24
|
+
export function pythonMatches(version, requirement = "") {
|
|
25
|
+
const actual = release(version);
|
|
26
|
+
if (!actual) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!requirement.trim()) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return requirement.split(",").every((clause) => {
|
|
33
|
+
const match =
|
|
34
|
+
/^\s*(~=|==|!=|<=|>=|<|>)\s*(\d+(?:\.\d+){0,2})(\.\*)?\s*$/.exec(clause);
|
|
35
|
+
if (!match) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const [, operator, text, wildcard] = match;
|
|
39
|
+
const expected = release(text);
|
|
40
|
+
if (wildcard) {
|
|
41
|
+
const equal = expected.every((part, i) => actual[i] === part);
|
|
42
|
+
return operator === "==" ? equal : operator === "!=" && !equal;
|
|
43
|
+
}
|
|
44
|
+
const order = compare(actual, expected);
|
|
45
|
+
switch (operator) {
|
|
46
|
+
case "==":
|
|
47
|
+
return order === 0;
|
|
48
|
+
case "!=":
|
|
49
|
+
return order !== 0;
|
|
50
|
+
case ">=":
|
|
51
|
+
return order >= 0;
|
|
52
|
+
case "<=":
|
|
53
|
+
return order <= 0;
|
|
54
|
+
case ">":
|
|
55
|
+
return order > 0;
|
|
56
|
+
case "<":
|
|
57
|
+
return order < 0;
|
|
58
|
+
case "~=": {
|
|
59
|
+
if (expected.length < 2) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const upper = expected.slice(0, -1);
|
|
63
|
+
upper[upper.length - 1] += 1;
|
|
64
|
+
return order >= 0 && compare(actual, upper) < 0;
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Selects the first installed compatible interpreter, preferring PATH defaults. */
|
|
73
|
+
export function choosePython(tools, requirement) {
|
|
74
|
+
return tools.find((tool) => pythonMatches(tool.version, requirement)) ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function candidatePrograms() {
|
|
78
|
+
const candidates = new Set(["python", "python3"]);
|
|
79
|
+
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
80
|
+
if (!directory) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const entries = await readdir(directory).catch(() => []);
|
|
84
|
+
for (const entry of entries.sort()) {
|
|
85
|
+
if (/^python3\.\d+(?:\.exe)?$/.test(entry)) {
|
|
86
|
+
candidates.add(entry);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...candidates].slice(0, 32);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Probes only interpreter versions, without loading manifests or package code. */
|
|
94
|
+
export async function probePython(options = {}) {
|
|
95
|
+
const candidates = options.candidates ?? (await candidatePrograms());
|
|
96
|
+
const probe =
|
|
97
|
+
options.probe ??
|
|
98
|
+
(async (program, args) => {
|
|
99
|
+
if (options.verbose) {
|
|
100
|
+
console.error(`+ ${program} ${args.join(" ")}`);
|
|
101
|
+
}
|
|
102
|
+
return exec(program, args, {
|
|
103
|
+
capture: true,
|
|
104
|
+
mirror: false,
|
|
105
|
+
stdin: "ignore",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
const tools = [];
|
|
109
|
+
for (const program of candidates) {
|
|
110
|
+
try {
|
|
111
|
+
const result = await probe(program, ["--version"]);
|
|
112
|
+
const version = /^Python (\d+\.\d+\.\d+)\s*$/.exec(
|
|
113
|
+
String(result.stdout ?? "").trim(),
|
|
114
|
+
)?.[1];
|
|
115
|
+
if (result.code === 0 && version) {
|
|
116
|
+
tools.push({ program, version });
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// An unavailable candidate is not an installed interpreter.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return tools;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Adds a Python prerequisite and selects the executable used by the build step. */
|
|
126
|
+
export function applyPython(plan, tools) {
|
|
127
|
+
const build = plan.steps.find((step) => step.id === "build-package");
|
|
128
|
+
if (plan.registry !== "pypi" || !build || !tools) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const requirement = plan.package.requires_python ?? "";
|
|
132
|
+
const selected = choosePython(tools, requirement);
|
|
133
|
+
const item = {
|
|
134
|
+
id: "python",
|
|
135
|
+
title: "Python",
|
|
136
|
+
detected: selected
|
|
137
|
+
? `${selected.program} (${selected.version})`
|
|
138
|
+
: "no compatible interpreter found",
|
|
139
|
+
required: `Install Python satisfying ${requirement || "the project requirements"} and its build module (python -m pip install build)`,
|
|
140
|
+
ok: Boolean(selected),
|
|
141
|
+
};
|
|
142
|
+
plan.prerequisites = [
|
|
143
|
+
...(plan.prerequisites ?? []).filter((entry) => entry.id !== "python"),
|
|
144
|
+
item,
|
|
145
|
+
];
|
|
146
|
+
if (selected) {
|
|
147
|
+
build.command = { program: selected.program, args: ["-m", "build"] };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { protectLegacyProfile } from "./profile.mjs";
|
|
3
|
+
import { SetupSession, prompt } from "./setup.mjs";
|
|
4
|
+
import { offerWorkflow } from "./workflow-proposal.mjs";
|
|
5
|
+
import { signInDomains } from "./sign-in-import.mjs";
|
|
6
|
+
import { oidcReleaseNote } from "./approvals.mjs";
|
|
7
|
+
|
|
8
|
+
/** Validates a plan before running any registry or repository mutations. */
|
|
9
|
+
export function validatePlan(plan, execute = true) {
|
|
10
|
+
if (!plan.package.publishable) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`${plan.package.name} is not publishable: ${(plan.package.problems ?? []).join("; ")}`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
if (
|
|
16
|
+
execute &&
|
|
17
|
+
plan.skipped_reason &&
|
|
18
|
+
!plan.steps.some((step) => step.id === "add-publishing-workflow")
|
|
19
|
+
) {
|
|
20
|
+
throw new Error(plan.skipped_reason);
|
|
21
|
+
}
|
|
22
|
+
const python = plan.prerequisites?.find(
|
|
23
|
+
(item) => item.id === "python" && item.ok === false,
|
|
24
|
+
);
|
|
25
|
+
if (execute && python) {
|
|
26
|
+
throw new Error(`${python.detected}; ${python.required}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Runs one package, using the same executor as setup --all. */
|
|
31
|
+
export async function executePlan(plan, options) {
|
|
32
|
+
return executePlans([plan], options, false);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runs all plans with one browser session and defers sign-out until the end. */
|
|
36
|
+
export async function executePlans(plans, options, summary = true) {
|
|
37
|
+
if (plans.length === 0) {
|
|
38
|
+
throw new Error("no publishable package manifests were found");
|
|
39
|
+
}
|
|
40
|
+
for (const plan of plans) {
|
|
41
|
+
validatePlan(plan, options.execute);
|
|
42
|
+
}
|
|
43
|
+
for (const plan of plans) {
|
|
44
|
+
if (plan.mode === "complete") {
|
|
45
|
+
console.log(
|
|
46
|
+
`${plan.package.name} already publishes through trusted publishing; ${plan.steps.length ? "only the repository checks remain." : "nothing to do."}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
plans.every((plan) => plan.mode === "complete" && plan.steps.length === 0)
|
|
52
|
+
) {
|
|
53
|
+
const outcomes = plans.map((plan) => ({
|
|
54
|
+
registry: plan.registry,
|
|
55
|
+
package: plan.package.name,
|
|
56
|
+
status: "complete",
|
|
57
|
+
}));
|
|
58
|
+
printSummary(outcomes, summary);
|
|
59
|
+
return outcomes;
|
|
60
|
+
}
|
|
61
|
+
if (!options.execute) {
|
|
62
|
+
console.log(
|
|
63
|
+
"Dry run only. Re-run with --execute to run these steps and open the registry.",
|
|
64
|
+
);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const missing = plans.filter((plan) =>
|
|
68
|
+
plan.steps.some((step) => step.id === "add-publishing-workflow"),
|
|
69
|
+
);
|
|
70
|
+
if (missing.length) {
|
|
71
|
+
const result = await offerWorkflow(missing, {
|
|
72
|
+
...options,
|
|
73
|
+
prompt: options.prompt ?? prompt,
|
|
74
|
+
});
|
|
75
|
+
const outcomes = plans.map((plan) => ({
|
|
76
|
+
registry: plan.registry,
|
|
77
|
+
package: plan.package.name,
|
|
78
|
+
status: missing.includes(plan) ? result.status : "blocked",
|
|
79
|
+
...(result.url ? { url: result.url } : {}),
|
|
80
|
+
}));
|
|
81
|
+
printSummary(outcomes, summary);
|
|
82
|
+
return outcomes;
|
|
83
|
+
}
|
|
84
|
+
if (options.browserProfile) {
|
|
85
|
+
await protectLegacyProfile(
|
|
86
|
+
options.repository,
|
|
87
|
+
options.browserProfile,
|
|
88
|
+
options,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const domains = [
|
|
92
|
+
...new Set(plans.flatMap((plan) => signInDomains(plan.registry))),
|
|
93
|
+
];
|
|
94
|
+
let automation = options.automation ?? null;
|
|
95
|
+
const deferred = new Map();
|
|
96
|
+
const outcomes = [];
|
|
97
|
+
let failure;
|
|
98
|
+
try {
|
|
99
|
+
for (const plan of plans) {
|
|
100
|
+
const session = new SetupSession(plan, {
|
|
101
|
+
...options,
|
|
102
|
+
automation,
|
|
103
|
+
domains,
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
await session.run();
|
|
107
|
+
outcomes.push({
|
|
108
|
+
registry: plan.registry,
|
|
109
|
+
package: plan.package.name,
|
|
110
|
+
status: plan.steps.length === 0 ? "complete" : "configured",
|
|
111
|
+
});
|
|
112
|
+
if (plan.trusted_publisher?.workflow) {
|
|
113
|
+
console.log(`\n${oidcReleaseNote(plan.trusted_publisher.workflow)}`);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
outcomes.push({
|
|
117
|
+
registry: plan.registry,
|
|
118
|
+
package: plan.package.name,
|
|
119
|
+
status: "failed",
|
|
120
|
+
});
|
|
121
|
+
failure = error;
|
|
122
|
+
} finally {
|
|
123
|
+
const steps = await session.cleanup({
|
|
124
|
+
deferAuth: plans.length > 1,
|
|
125
|
+
closeBrowser: false,
|
|
126
|
+
});
|
|
127
|
+
automation = session.automation;
|
|
128
|
+
for (const step of steps) {
|
|
129
|
+
deferred.set(`${plan.registry}:${step.id}`, { session, step });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (failure) {
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
for (const { session, step } of deferred.values()) {
|
|
138
|
+
session.automation = automation;
|
|
139
|
+
try {
|
|
140
|
+
await session.runStep(step);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(`warning: ${step.id} failed: ${error.message}`);
|
|
143
|
+
} finally {
|
|
144
|
+
if (session.shim?.directory) {
|
|
145
|
+
await rm(session.shim.directory, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await automation?.close();
|
|
150
|
+
for (const plan of plans.slice(outcomes.length)) {
|
|
151
|
+
outcomes.push({
|
|
152
|
+
registry: plan.registry,
|
|
153
|
+
package: plan.package.name,
|
|
154
|
+
status: "not-run",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
printSummary(outcomes, summary);
|
|
158
|
+
}
|
|
159
|
+
if (failure) {
|
|
160
|
+
throw failure;
|
|
161
|
+
}
|
|
162
|
+
return outcomes;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function printSummary(outcomes, enabled) {
|
|
166
|
+
if (enabled) {
|
|
167
|
+
console.log("\nSetup summary:");
|
|
168
|
+
for (const item of outcomes) {
|
|
169
|
+
console.log(
|
|
170
|
+
`- ${item.registry}: ${item.package}: ${item.status}${item.url ? ` (${item.url})` : ""}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
import {
|
|
16
16
|
approvalDeadline,
|
|
17
17
|
TWO_FACTOR_HINT,
|
|
18
|
-
oidcReleaseNote,
|
|
19
18
|
withFreshLinks,
|
|
20
19
|
} from "./approvals.mjs";
|
|
21
20
|
import { connectAutomation } from "./automation.mjs";
|
|
@@ -30,7 +29,6 @@ import { CLEANUP_CONDITIONS } from "./flows.mjs";
|
|
|
30
29
|
import { pagesSettingsUrl, pagesState } from "./pages.mjs";
|
|
31
30
|
import { packageDirectory } from "./plan.mjs";
|
|
32
31
|
import { twoFactorMode } from "./prerequisites.mjs";
|
|
33
|
-
import { protectLegacyProfile } from "./profile.mjs";
|
|
34
32
|
import { getJson, probePackage } from "./registry-state.mjs";
|
|
35
33
|
import { launchBrowser, signInDomains } from "./sign-in-import.mjs";
|
|
36
34
|
import { reportTokenSecrets, verifyTokenRevoked } from "./tokens.mjs";
|
|
@@ -54,54 +52,9 @@ const PUBLISH_REJECTED = /\bE404\b|404 Not Found|invalid-publisher/i;
|
|
|
54
52
|
/** Where `--browser` opens URLs that need no automation. */
|
|
55
53
|
export const BROWSER_MODES = ["default", "automated"];
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
* Runs a setup plan. Without `options.execute` it only reports a dry run.
|
|
59
|
-
* Steps whose `when` condition does not hold are skipped, so a re-run after a
|
|
60
|
-
* partial success resumes where the previous run stopped.
|
|
61
|
-
*/
|
|
62
|
-
export async function executePlan(plan, options) {
|
|
63
|
-
if (!plan.package.publishable) {
|
|
64
|
-
throw new Error(
|
|
65
|
-
`${plan.package.name} is not publishable: ${(plan.package.problems ?? []).join("; ")}`,
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
if (plan.mode === "complete") {
|
|
69
|
-
const remaining =
|
|
70
|
-
plan.steps.length === 0
|
|
71
|
-
? "nothing to do."
|
|
72
|
-
: "only the repository checks remain.";
|
|
73
|
-
console.log(
|
|
74
|
-
`${plan.package.name} already publishes through trusted publishing; ${remaining}`,
|
|
75
|
-
);
|
|
76
|
-
if (plan.steps.length === 0) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (!options.execute) {
|
|
81
|
-
console.log(
|
|
82
|
-
"Dry run only. Re-run with --execute to run these steps and open the registry.",
|
|
83
|
-
);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (options.browserProfile) {
|
|
87
|
-
await protectLegacyProfile(
|
|
88
|
-
options.repository,
|
|
89
|
-
options.browserProfile,
|
|
90
|
-
options,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
const session = new SetupSession(plan, options);
|
|
94
|
-
try {
|
|
95
|
-
await session.run();
|
|
96
|
-
} finally {
|
|
97
|
-
await session.cleanup();
|
|
98
|
-
}
|
|
99
|
-
if (plan.trusted_publisher?.workflow) {
|
|
100
|
-
console.log(`\n${oidcReleaseNote(plan.trusted_publisher.workflow)}`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
55
|
+
export { executePlan, executePlans } from "./setup-batch.mjs";
|
|
103
56
|
|
|
104
|
-
class SetupSession {
|
|
57
|
+
export class SetupSession {
|
|
105
58
|
constructor(plan, options) {
|
|
106
59
|
this.plan = plan;
|
|
107
60
|
this.options = options;
|
|
@@ -135,9 +88,22 @@ class SetupSession {
|
|
|
135
88
|
* Runs every cleanup step of the plan whose condition holds, including
|
|
136
89
|
* those after a step that failed (#24).
|
|
137
90
|
*/
|
|
138
|
-
async cleanup() {
|
|
91
|
+
async cleanup({ deferAuth = false, closeBrowser = true } = {}) {
|
|
92
|
+
const deferred = [];
|
|
139
93
|
for (const step of cleanupSteps(this.plan)) {
|
|
140
|
-
if (
|
|
94
|
+
if (
|
|
95
|
+
deferAuth &&
|
|
96
|
+
["sign-out", "crates-sign-out"].includes(step.id) &&
|
|
97
|
+
this.conditions.has(step.when)
|
|
98
|
+
) {
|
|
99
|
+
if (step.id === "sign-out" && this.options.keepSession) {
|
|
100
|
+
console.log(
|
|
101
|
+
"Keeping the npm session (--keep-session); run npm logout to revoke it.",
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
deferred.push(step);
|
|
105
|
+
}
|
|
106
|
+
} else if (step.id === "sign-out" && this.keepsSession(step)) {
|
|
141
107
|
console.log(
|
|
142
108
|
"Keeping the npm session (--keep-session): its token stays in npm's user configuration until you run npm logout, and the next run reuses it while npm whoami succeeds.",
|
|
143
109
|
);
|
|
@@ -148,13 +114,17 @@ class SetupSession {
|
|
|
148
114
|
);
|
|
149
115
|
} else if (this.conditions.has(step.when)) {
|
|
150
116
|
this.log(`==> ${step.title}`);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
117
|
+
try {
|
|
118
|
+
const result = await this.runProcess(step, true);
|
|
119
|
+
if (result.code !== 0) {
|
|
120
|
+
console.error(`warning: ${step.id} exited with ${result.code}`);
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(`warning: ${step.id} failed: ${error.message}`);
|
|
154
124
|
}
|
|
155
125
|
}
|
|
156
126
|
}
|
|
157
|
-
if (this.automation) {
|
|
127
|
+
if (this.automation && closeBrowser) {
|
|
158
128
|
await this.automation.close();
|
|
159
129
|
}
|
|
160
130
|
for (const directory of [this.temporary, this.shim?.directory]) {
|
|
@@ -162,6 +132,9 @@ class SetupSession {
|
|
|
162
132
|
await rm(directory, { recursive: true, force: true });
|
|
163
133
|
}
|
|
164
134
|
}
|
|
135
|
+
this.shim = null;
|
|
136
|
+
this.temporary = null;
|
|
137
|
+
return deferred;
|
|
165
138
|
}
|
|
166
139
|
|
|
167
140
|
keepsSession(step) {
|
|
@@ -643,7 +616,7 @@ class SetupSession {
|
|
|
643
616
|
*/
|
|
644
617
|
async automatedPage(browser = this.options.browserOptions) {
|
|
645
618
|
if (!this.automation) {
|
|
646
|
-
const domains = signInDomains(this.plan.registry);
|
|
619
|
+
const domains = this.options.domains ?? signInDomains(this.plan.registry);
|
|
647
620
|
this.automation = await connectAutomation({
|
|
648
621
|
browser: await launchBrowser(browser, domains, {
|
|
649
622
|
deferred: this.plan.registry === "crates-io",
|
|
@@ -872,7 +845,7 @@ function render(command) {
|
|
|
872
845
|
}
|
|
873
846
|
|
|
874
847
|
/** Asks a question on the terminal; a closed stdin answers with "". */
|
|
875
|
-
async function prompt(message) {
|
|
848
|
+
export async function prompt(message) {
|
|
876
849
|
const terminal = createInterface({ input: stdin, output: stdout });
|
|
877
850
|
try {
|
|
878
851
|
return await new Promise((resolve) => {
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
mkdtemp,
|
|
4
|
+
writeFile,
|
|
5
|
+
rm,
|
|
6
|
+
realpath,
|
|
7
|
+
lstat,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { exec } from "command-stream";
|
|
12
|
+
import { packageDirectory } from "./plan.mjs";
|
|
13
|
+
import { parseWorkflow } from "./publishers.mjs";
|
|
14
|
+
import { readWorkflows } from "./workflows.mjs";
|
|
15
|
+
|
|
16
|
+
const quote = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
17
|
+
const RELEASE_IF =
|
|
18
|
+
"github.event_name == 'release' || ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))";
|
|
19
|
+
|
|
20
|
+
function job(packageInfo, index, environment) {
|
|
21
|
+
if (
|
|
22
|
+
!/^(?:[\w@.-]+\/)*[\w@.-]+$/.test(packageInfo.manifest) ||
|
|
23
|
+
packageInfo.manifest.split("/").some((part) => ["..", "."].includes(part))
|
|
24
|
+
) {
|
|
25
|
+
throw new Error("unsafe manifest path for publishing job");
|
|
26
|
+
}
|
|
27
|
+
const directory = packageDirectory(packageInfo.manifest);
|
|
28
|
+
const lines = [
|
|
29
|
+
` prm-publish-${packageInfo.registry}-${index + 1}:`,
|
|
30
|
+
` if: ${RELEASE_IF}`,
|
|
31
|
+
" runs-on: ubuntu-latest",
|
|
32
|
+
" timeout-minutes: 15",
|
|
33
|
+
" permissions:",
|
|
34
|
+
" contents: read",
|
|
35
|
+
" id-token: write",
|
|
36
|
+
...(environment ? [` environment: ${quote(environment)}`] : []),
|
|
37
|
+
" defaults:",
|
|
38
|
+
" run:",
|
|
39
|
+
` working-directory: ${quote(directory)}`,
|
|
40
|
+
" steps:",
|
|
41
|
+
" - uses: actions/checkout@v6",
|
|
42
|
+
" with:",
|
|
43
|
+
" persist-credentials: false",
|
|
44
|
+
];
|
|
45
|
+
switch (packageInfo.registry) {
|
|
46
|
+
case "npm":
|
|
47
|
+
lines.push(
|
|
48
|
+
" - uses: actions/setup-node@v6",
|
|
49
|
+
" with:",
|
|
50
|
+
" node-version: '22'",
|
|
51
|
+
" - run: npm install --global npm@^11",
|
|
52
|
+
" - run: npm install",
|
|
53
|
+
" - run: npm run build --if-present",
|
|
54
|
+
" - run: npm publish --provenance --access public",
|
|
55
|
+
);
|
|
56
|
+
break;
|
|
57
|
+
case "crates-io":
|
|
58
|
+
lines.push(
|
|
59
|
+
" - uses: dtolnay/rust-toolchain@stable",
|
|
60
|
+
" - uses: rust-lang/crates-io-auth-action@v1",
|
|
61
|
+
" id: auth",
|
|
62
|
+
" - run: cargo publish",
|
|
63
|
+
" env:",
|
|
64
|
+
" CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}",
|
|
65
|
+
);
|
|
66
|
+
break;
|
|
67
|
+
case "pypi":
|
|
68
|
+
lines.push(" - uses: actions/setup-python@v6", " with:");
|
|
69
|
+
lines.push(
|
|
70
|
+
packageInfo.manifest.endsWith("pyproject.toml") &&
|
|
71
|
+
packageInfo.requires_python
|
|
72
|
+
? ` python-version-file: ${quote(packageInfo.manifest)}`
|
|
73
|
+
: " python-version: '3.x'",
|
|
74
|
+
);
|
|
75
|
+
lines.push(
|
|
76
|
+
" - run: python -m pip install build",
|
|
77
|
+
" - run: python -m build",
|
|
78
|
+
" - uses: pypa/gh-action-pypi-publish@release/v1",
|
|
79
|
+
" with:",
|
|
80
|
+
` packages-dir: ${quote(`${directory === "." ? "" : `${directory}/`}dist/`)}`,
|
|
81
|
+
);
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(`no publishing job template for ${packageInfo.registry}`);
|
|
85
|
+
}
|
|
86
|
+
return `${lines.join("\n")}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Generates reviewable workflow contents without modifying the checkout. */
|
|
90
|
+
export function workflowProposal(
|
|
91
|
+
inspection,
|
|
92
|
+
packages,
|
|
93
|
+
workflows,
|
|
94
|
+
override,
|
|
95
|
+
chosenEnvironment,
|
|
96
|
+
) {
|
|
97
|
+
const known = [
|
|
98
|
+
...new Set(
|
|
99
|
+
inspection.packages
|
|
100
|
+
.filter(
|
|
101
|
+
(item) =>
|
|
102
|
+
item.publishable !== false &&
|
|
103
|
+
["npm", "crates-io", "pypi"].includes(item.registry),
|
|
104
|
+
)
|
|
105
|
+
.map((item) => item.workflow)
|
|
106
|
+
.filter(Boolean),
|
|
107
|
+
),
|
|
108
|
+
];
|
|
109
|
+
if (!override && known.length > 1) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"publishing uses several files; pass --add-publish-job --workflow <file> to choose the shared workflow",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const workflow =
|
|
115
|
+
override ??
|
|
116
|
+
known[0] ??
|
|
117
|
+
inspection.repository.release_workflow ??
|
|
118
|
+
"release.yml";
|
|
119
|
+
if (!/^[\w.-]+\.ya?ml$/.test(workflow)) {
|
|
120
|
+
throw new Error("invalid publishing workflow name");
|
|
121
|
+
}
|
|
122
|
+
let contents =
|
|
123
|
+
workflows.find((item) => item.name === workflow)?.contents ??
|
|
124
|
+
"name: Package release\non:\n workflow_dispatch:\n release:\n types: [published]\npermissions:\n contents: read\njobs:\n";
|
|
125
|
+
const parsed = parseWorkflow(contents);
|
|
126
|
+
const environments = [
|
|
127
|
+
...new Set(
|
|
128
|
+
inspection.packages
|
|
129
|
+
.filter((item) => item.workflow === workflow)
|
|
130
|
+
.map((item) => item.environment ?? null),
|
|
131
|
+
),
|
|
132
|
+
];
|
|
133
|
+
if (chosenEnvironment === undefined && environments.length > 1) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
"publishing jobs use different environments; choose a shared environment with --environment",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
let addition = packages
|
|
139
|
+
.map((item, index) =>
|
|
140
|
+
job(item, index, chosenEnvironment ?? environments[0]),
|
|
141
|
+
)
|
|
142
|
+
.join("\n");
|
|
143
|
+
for (const item of parseWorkflow(`jobs:\n${addition}`).jobs) {
|
|
144
|
+
if (parsed.jobs.some((existing) => existing.name === item.name)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`publishing job ${item.name} already exists; inspect the workflow before adding it again`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const lines = contents.split(/\r?\n/);
|
|
151
|
+
const start = lines.findIndex((line) => /^jobs\s*:\s*$/.test(line));
|
|
152
|
+
if (start === -1) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"workflow has no editable jobs block; add a publishing job manually",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
let end = lines.findIndex((line, index) => index > start && /^\S/.test(line));
|
|
158
|
+
end = end === -1 ? lines.length : end;
|
|
159
|
+
// Match the existing job indentation without reformatting unrelated YAML.
|
|
160
|
+
const indent = parsed.jobs.length
|
|
161
|
+
? (lines
|
|
162
|
+
.slice(start + 1, end)
|
|
163
|
+
.find((line) => /^\s+\S/.test(line) && !/^\s*#/.test(line))
|
|
164
|
+
?.search(/\S/) ?? 2)
|
|
165
|
+
: 2;
|
|
166
|
+
if (indent !== 2) {
|
|
167
|
+
if (indent < 2) {
|
|
168
|
+
throw new Error("unsupported jobs indentation");
|
|
169
|
+
}
|
|
170
|
+
addition = addition
|
|
171
|
+
.split("\n")
|
|
172
|
+
.map((line) => (line ? `${" ".repeat(indent - 2)}${line}` : line))
|
|
173
|
+
.join("\n");
|
|
174
|
+
}
|
|
175
|
+
contents = `${lines.slice(0, end).join("\n").trimEnd()}\n\n${addition}${lines.slice(end).join("\n")}`;
|
|
176
|
+
return { workflow, contents };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Creates a branch and draft PR; never writes or pushes the default branch. */
|
|
180
|
+
export async function offerWorkflow(plans, options) {
|
|
181
|
+
const inspection = options.inspection ?? {
|
|
182
|
+
repository: plans[0].repository,
|
|
183
|
+
packages: plans.map((plan) => plan.package),
|
|
184
|
+
};
|
|
185
|
+
const proposal = workflowProposal(
|
|
186
|
+
inspection,
|
|
187
|
+
plans.map((plan) => plan.package),
|
|
188
|
+
await readWorkflows(options.repository),
|
|
189
|
+
options.workflow,
|
|
190
|
+
options.publisherEnvironment,
|
|
191
|
+
);
|
|
192
|
+
console.log(
|
|
193
|
+
`Proposed .github/workflows/${proposal.workflow}:\n${proposal.contents}`,
|
|
194
|
+
);
|
|
195
|
+
if (
|
|
196
|
+
!options.yes &&
|
|
197
|
+
!/^y(?:es)?$/i.test(
|
|
198
|
+
(
|
|
199
|
+
await options.prompt(
|
|
200
|
+
"Create a branch and draft pull request with these publishing jobs? [y/N] ",
|
|
201
|
+
)
|
|
202
|
+
).trim(),
|
|
203
|
+
)
|
|
204
|
+
) {
|
|
205
|
+
return { status: "blocked" };
|
|
206
|
+
}
|
|
207
|
+
const run =
|
|
208
|
+
options.run ??
|
|
209
|
+
(async (program, args, cwd = options.repository) => {
|
|
210
|
+
if (options.verbose) {
|
|
211
|
+
console.error(`+ ${program} ${args.join(" ")}`);
|
|
212
|
+
}
|
|
213
|
+
const result = await exec(program, args, {
|
|
214
|
+
cwd,
|
|
215
|
+
capture: true,
|
|
216
|
+
mirror: false,
|
|
217
|
+
stdin: "ignore",
|
|
218
|
+
});
|
|
219
|
+
if (result.code !== 0) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`${program} failed: ${String(result.stderr ?? result.stdout ?? "")}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return String(result.stdout ?? "").trim();
|
|
225
|
+
});
|
|
226
|
+
const remote = await run("git", ["remote", "get-url", "origin"]);
|
|
227
|
+
const slug = `${inspection.repository.github_owner}/${inspection.repository.github_repository}`;
|
|
228
|
+
if (
|
|
229
|
+
!inspection.repository.github_owner ||
|
|
230
|
+
!inspection.repository.github_repository ||
|
|
231
|
+
!["https://github.com/", "git@github.com:", "ssh://git@github.com/"].some(
|
|
232
|
+
(prefix) =>
|
|
233
|
+
remote === `${prefix}${slug}` || remote === `${prefix}${slug}.git`,
|
|
234
|
+
)
|
|
235
|
+
) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
"origin must match the inspected GitHub repository before proposing a workflow",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const base = await run("gh", [
|
|
241
|
+
"repo",
|
|
242
|
+
"view",
|
|
243
|
+
slug,
|
|
244
|
+
"--json",
|
|
245
|
+
"defaultBranchRef",
|
|
246
|
+
"--jq",
|
|
247
|
+
".defaultBranchRef.name",
|
|
248
|
+
]);
|
|
249
|
+
await run("git", ["fetch", "origin", base]);
|
|
250
|
+
const branch = `prm/publish-jobs-${Date.now()}`;
|
|
251
|
+
const temporary = await mkdtemp(path.join(os.tmpdir(), "prm-workflow-"));
|
|
252
|
+
const checkout = path.join(temporary, "checkout");
|
|
253
|
+
let created = false;
|
|
254
|
+
try {
|
|
255
|
+
await run("git", [
|
|
256
|
+
"worktree",
|
|
257
|
+
"add",
|
|
258
|
+
"-b",
|
|
259
|
+
branch,
|
|
260
|
+
checkout,
|
|
261
|
+
`origin/${base}`,
|
|
262
|
+
]);
|
|
263
|
+
created = true;
|
|
264
|
+
// Recompute from the default branch to preserve edits made since inspection.
|
|
265
|
+
const current = workflowProposal(
|
|
266
|
+
inspection,
|
|
267
|
+
plans.map((plan) => plan.package),
|
|
268
|
+
await readWorkflows(checkout),
|
|
269
|
+
proposal.workflow,
|
|
270
|
+
options.publisherEnvironment,
|
|
271
|
+
);
|
|
272
|
+
if (current.contents !== proposal.contents) {
|
|
273
|
+
console.log(`Updated proposal from ${base}:\n${current.contents}`);
|
|
274
|
+
if (
|
|
275
|
+
!options.yes &&
|
|
276
|
+
!/^y(?:es)?$/i.test(
|
|
277
|
+
(await options.prompt("Use this updated proposal? [y/N] ")).trim(),
|
|
278
|
+
)
|
|
279
|
+
) {
|
|
280
|
+
return { status: "blocked" };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const file = `.github/workflows/${current.workflow}`;
|
|
284
|
+
await mkdir(path.dirname(path.join(checkout, file)), { recursive: true });
|
|
285
|
+
const target = path.join(checkout, file);
|
|
286
|
+
const physicalParent = await realpath(path.dirname(target));
|
|
287
|
+
if (
|
|
288
|
+
!physicalParent.startsWith(`${await realpath(checkout)}${path.sep}`) ||
|
|
289
|
+
(await lstat(target).catch(() => null))?.isSymbolicLink()
|
|
290
|
+
) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
"workflow must stay inside the proposal worktree without symlinks",
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
await writeFile(target, current.contents);
|
|
296
|
+
await run("git", ["add", "--", file], checkout);
|
|
297
|
+
await run(
|
|
298
|
+
"git",
|
|
299
|
+
["commit", "-m", "Add missing trusted publishing jobs"],
|
|
300
|
+
checkout,
|
|
301
|
+
);
|
|
302
|
+
await run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], checkout);
|
|
303
|
+
const body = `Add missing publishing jobs for ${plans.map((plan) => `${plan.registry}: ${plan.package.name}`).join(", ")} in ${current.workflow}. Review the build, versioning, triggers and job dependencies before merging. Then re-run setup to attach the trusted publishers to this file.`;
|
|
304
|
+
const url = await run(
|
|
305
|
+
"gh",
|
|
306
|
+
[
|
|
307
|
+
"pr",
|
|
308
|
+
"create",
|
|
309
|
+
"--repo",
|
|
310
|
+
slug,
|
|
311
|
+
"--base",
|
|
312
|
+
base,
|
|
313
|
+
"--head",
|
|
314
|
+
branch,
|
|
315
|
+
"--draft",
|
|
316
|
+
"--title",
|
|
317
|
+
"Add missing trusted publishing jobs",
|
|
318
|
+
"--body",
|
|
319
|
+
body,
|
|
320
|
+
],
|
|
321
|
+
checkout,
|
|
322
|
+
);
|
|
323
|
+
console.log(
|
|
324
|
+
`Review ${url}, merge it, then re-run setup. Trusted publishers have not been attached.`,
|
|
325
|
+
);
|
|
326
|
+
return { status: "workflow-pr", url };
|
|
327
|
+
} finally {
|
|
328
|
+
if (created) {
|
|
329
|
+
await run("git", ["worktree", "remove", "--force", checkout]);
|
|
330
|
+
}
|
|
331
|
+
await rm(temporary, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
}
|