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 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, and it never publishes an
20
- artifact. See the repository README for all registry walkthroughs and safety
21
- details.
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.4",
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.25.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 in browser-commander's catalogue${known.family === "safari" ? " (Safari setup: https://github.com/link-foundation/browser-commander/issues/126)" : ""}`
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 { BROWSER_MODES, defaultBrowserProfile, executePlan } from "./setup.mjs";
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(repository, values.workflow),
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 (registries.length !== 1) {
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 plan = selectPlan(plans, values.package);
219
- outputPlans([plan], values.format);
220
- await executePlan(plan, {
221
- repository,
222
- execute: values.execute,
223
- yes: values.yes,
224
- noBrowser: values["no-browser"],
225
- openWith: nonEmpty(values["open-with"], "--open-with"),
226
- keepSession: values["keep-session"],
227
- browser: values.browser,
228
- browserOptions,
229
- browserProfile: path.resolve(
230
- values["browser-profile"] ??
231
- defaultBrowserProfile({ channel: browserOptions.channel }),
232
- ),
233
- verbose: values.verbose,
234
- verifyRelease: values["verify-release"],
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
- return packageInfo("pypi", name, tomlString(section, "version"), manifest);
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
 
@@ -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,
@@ -15,7 +15,7 @@ const JOB_PATTERNS = new Map([
15
15
  ],
16
16
  [
17
17
  "pypi",
18
- /pypa\/gh-action-pypi-publish|\btwine\s+upload\b|\b(?:uv|poetry|hatch|flit|pdm)\s+publish\b/,
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 (step.id === "sign-out" && this.keepsSession(step)) {
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
- const result = await this.runProcess(step, true);
152
- if (result.code !== 0) {
153
- console.error(`warning: ${step.id} exited with ${result.code}`);
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
+ }