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/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";
@@ -27,10 +26,11 @@ import {
27
26
  } from "./browser.mjs";
28
27
  import { detectDefaultBrowser, openWithCommand } from "./default-browser.mjs";
29
28
  import { CLEANUP_CONDITIONS } from "./flows.mjs";
29
+ import { setupCredential } from "./credential-setup.mjs";
30
+ import { checkNamePolicy, policyRefusal } from "./npm-policy.mjs";
30
31
  import { pagesSettingsUrl, pagesState } from "./pages.mjs";
31
32
  import { packageDirectory } from "./plan.mjs";
32
33
  import { twoFactorMode } from "./prerequisites.mjs";
33
- import { protectLegacyProfile } from "./profile.mjs";
34
34
  import { getJson, probePackage } from "./registry-state.mjs";
35
35
  import { launchBrowser, signInDomains } from "./sign-in-import.mjs";
36
36
  import { reportTokenSecrets, verifyTokenRevoked } from "./tokens.mjs";
@@ -54,54 +54,9 @@ const PUBLISH_REJECTED = /\bE404\b|404 Not Found|invalid-publisher/i;
54
54
  /** Where `--browser` opens URLs that need no automation. */
55
55
  export const BROWSER_MODES = ["default", "automated"];
56
56
 
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
- }
57
+ export { executePlan, executePlans } from "./setup-batch.mjs";
103
58
 
104
- class SetupSession {
59
+ export class SetupSession {
105
60
  constructor(plan, options) {
106
61
  this.plan = plan;
107
62
  this.options = options;
@@ -135,9 +90,22 @@ class SetupSession {
135
90
  * Runs every cleanup step of the plan whose condition holds, including
136
91
  * those after a step that failed (#24).
137
92
  */
138
- async cleanup() {
93
+ async cleanup({ deferAuth = false, closeBrowser = true } = {}) {
94
+ const deferred = [];
139
95
  for (const step of cleanupSteps(this.plan)) {
140
- if (step.id === "sign-out" && this.keepsSession(step)) {
96
+ if (
97
+ deferAuth &&
98
+ ["sign-out", "crates-sign-out"].includes(step.id) &&
99
+ this.conditions.has(step.when)
100
+ ) {
101
+ if (step.id === "sign-out" && this.options.keepSession) {
102
+ console.log(
103
+ "Keeping the npm session (--keep-session); run npm logout to revoke it.",
104
+ );
105
+ } else {
106
+ deferred.push(step);
107
+ }
108
+ } else if (step.id === "sign-out" && this.keepsSession(step)) {
141
109
  console.log(
142
110
  "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
111
  );
@@ -148,13 +116,17 @@ class SetupSession {
148
116
  );
149
117
  } else if (this.conditions.has(step.when)) {
150
118
  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}`);
119
+ try {
120
+ const result = await this.runProcess(step, true);
121
+ if (result.code !== 0) {
122
+ console.error(`warning: ${step.id} exited with ${result.code}`);
123
+ }
124
+ } catch (error) {
125
+ console.error(`warning: ${step.id} failed: ${error.message}`);
154
126
  }
155
127
  }
156
128
  }
157
- if (this.automation) {
129
+ if (this.automation && closeBrowser) {
158
130
  await this.automation.close();
159
131
  }
160
132
  for (const directory of [this.temporary, this.shim?.directory]) {
@@ -162,6 +134,9 @@ class SetupSession {
162
134
  await rm(directory, { recursive: true, force: true });
163
135
  }
164
136
  }
137
+ this.shim = null;
138
+ this.temporary = null;
139
+ return deferred;
165
140
  }
166
141
 
167
142
  keepsSession(step) {
@@ -175,6 +150,9 @@ class SetupSession {
175
150
  async runStep(step) {
176
151
  switch (step.kind) {
177
152
  case "check":
153
+ if (step.id === "check-name-policy") {
154
+ return checkNamePolicy(this.plan.package.name, this.options);
155
+ }
178
156
  if (step.id === "check-registry") {
179
157
  return this.checkRegistry();
180
158
  }
@@ -194,9 +172,25 @@ class SetupSession {
194
172
  case "browser":
195
173
  return this.browser(step);
196
174
  case "api":
197
- return runCratesApiStep(this, step);
175
+ return step.id === "manage-registry-token"
176
+ ? setupCredential(this)
177
+ : runCratesApiStep(this, step);
198
178
  default:
199
179
  console.log(step.description);
180
+ if (step.id === "confirm-oidc-cleanup") {
181
+ if (this.conditions.has("oidc-release-verified")) {
182
+ return;
183
+ }
184
+ const answer = await this.prompt(
185
+ "Has a new release succeeded through OIDC without a stored registry token? [y/N] ",
186
+ );
187
+ if (!/^(?:y|yes)$/i.test(answer)) {
188
+ throw new Error(
189
+ "a verified OIDC release is required before deleting token secrets",
190
+ );
191
+ }
192
+ return;
193
+ }
200
194
  if (step.url) {
201
195
  console.log(` ${step.url}`);
202
196
  }
@@ -475,6 +469,14 @@ class SetupSession {
475
469
  ? await this.capture(step)
476
470
  : await this.runProcess(step, true);
477
471
  if (result.code !== 0) {
472
+ if (
473
+ step.id === "first-publish" &&
474
+ policyRefusal(String(result.stderr) + String(result.stdout))
475
+ ) {
476
+ throw new Error(
477
+ "npm refused this name under its policy; no retry. Choose @owner/name or a longer descriptive name.",
478
+ );
479
+ }
478
480
  if (step.id === "attach-trusted-publisher") {
479
481
  console.error(
480
482
  "warning: npm trust failed; falling back to the browser form",
@@ -527,6 +529,15 @@ class SetupSession {
527
529
 
528
530
  recordPack(output) {
529
531
  const packed = packedEntry(output);
532
+ if (
533
+ packed.name !== this.plan.package.name ||
534
+ (this.plan.package.version &&
535
+ packed.version !== this.plan.package.version)
536
+ ) {
537
+ throw new Error(
538
+ "bootstrap ref package identity/version differs from inspection; inspect the selected ref again before publication",
539
+ );
540
+ }
530
541
  for (const file of packed.files ?? []) {
531
542
  console.log(` ${String(file.size).padStart(8)} ${file.path}`);
532
543
  }
@@ -551,6 +562,9 @@ class SetupSession {
551
562
  (step.id !== "confirm-provenance" ||
552
563
  isTrustedRelease(document, this.values))
553
564
  ) {
565
+ if (step.id === "confirm-provenance") {
566
+ this.conditions.add("oidc-release-verified");
567
+ }
554
568
  console.log(` ${url} is ready`);
555
569
  return;
556
570
  }
@@ -641,16 +655,16 @@ class SetupSession {
641
655
  * The automated page, launched once. The crates.io sign-in step offers
642
656
  * `--browser-import default|auto` itself, once it knows a sign-in is missing.
643
657
  */
644
- async automatedPage(browser = this.options.browserOptions) {
658
+ async automatedPage(browser = this.options.browserOptions, quiet = false) {
645
659
  if (!this.automation) {
646
- const domains = signInDomains(this.plan.registry);
660
+ const domains = this.options.domains ?? signInDomains(this.plan.registry);
647
661
  this.automation = await connectAutomation({
648
662
  browser: await launchBrowser(browser, domains, {
649
663
  deferred: this.plan.registry === "crates-io",
650
- verbose: this.options.verbose,
664
+ verbose: quiet ? false : this.options.verbose,
651
665
  }),
652
666
  profile: this.options.browserProfile,
653
- verbose: this.options.verbose,
667
+ verbose: quiet ? false : this.options.verbose,
654
668
  domains,
655
669
  });
656
670
  }
@@ -872,7 +886,7 @@ function render(command) {
872
886
  }
873
887
 
874
888
  /** Asks a question on the terminal; a closed stdin answers with "". */
875
- async function prompt(message) {
889
+ export async function prompt(message) {
876
890
  const terminal = createInterface({ input: stdin, output: stdout });
877
891
  try {
878
892
  return await new Promise((resolve) => {
@@ -15,6 +15,14 @@ export const IMPORT_SCOPES = ["domains", "full"];
15
15
 
16
16
  /** The domains whose cookies hold each registry's sign-in. */
17
17
  export const SIGN_IN_DOMAINS = {
18
+ "docker-hub": ["docker.com", "docker.io"],
19
+ "maven-central": ["sonatype.com"],
20
+ "vscode-marketplace": ["dev.azure.com", "login.microsoftonline.com"],
21
+ "open-vsx": ["open-vsx.org", "github.com"],
22
+ "chrome-web-store": ["google.com"],
23
+ rubygems: ["rubygems.org", "github.com"],
24
+ nuget: ["nuget.org", "login.microsoftonline.com"],
25
+ jsr: ["jsr.io", "github.com"],
18
26
  "crates-io": ["crates.io", "github.com"],
19
27
  npm: ["npmjs.com"],
20
28
  pypi: ["pypi.org", "github.com"],
package/src/skips.mjs CHANGED
@@ -12,6 +12,9 @@ export const TEST_DIRECTORIES = Object.freeze([
12
12
  "__fixtures__",
13
13
  "__tests__",
14
14
  "examples",
15
+ "docs",
16
+ "experiments",
17
+ "case-studies",
15
18
  ]);
16
19
 
17
20
  /**
@@ -0,0 +1,61 @@
1
+ /** Removes comments without interpreting comment markers inside quoted strings. */
2
+ export function stripComments(contents, language = "shell") {
3
+ const cStyle = /\.(?:[cm]?[jt]s|[cm]ts|rs)$/.test(language);
4
+ let quote = null;
5
+ let block = false;
6
+ let line = false;
7
+ let escaped = false;
8
+ let result = "";
9
+ for (let index = 0; index < contents.length; index += 1) {
10
+ const character = contents[index];
11
+ const next = contents[index + 1];
12
+ if (character === "\n" && line) {
13
+ line = false;
14
+ }
15
+ if (line || block) {
16
+ if (block && character === "*" && next === "/") {
17
+ block = false;
18
+ result += " ";
19
+ index += 1;
20
+ } else {
21
+ result += character === "\n" ? "\n" : " ";
22
+ }
23
+ } else if (quote) {
24
+ result += character;
25
+ if (!escaped && character === quote) {
26
+ quote = null;
27
+ }
28
+ escaped = !escaped && character === "\\";
29
+ } else if (["'", '"', "`"].includes(character)) {
30
+ quote = character;
31
+ result += character;
32
+ } else if (
33
+ (cStyle && character === "/" && next === "/") ||
34
+ (!cStyle && character === "#")
35
+ ) {
36
+ line = true;
37
+ result += " ";
38
+ } else if (cStyle && character === "/" && next === "*") {
39
+ block = true;
40
+ result += " ";
41
+ index += 1;
42
+ } else {
43
+ result += character;
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+
49
+ /** Whether a prefix places a publishing command in an executed command. */
50
+ export function commandPosition(prefix) {
51
+ const segment = prefix.split(/[;&|]/).at(-1).trimStart();
52
+ return (
53
+ /^(?:(?:npx|bunx|exec|sudo|env|time|command|pnpm\s+exec|yarn|\w+=\S*)\s+)*$/.test(
54
+ segment,
55
+ ) ||
56
+ /(?:\$|shell)\s*`\s*$/.test(segment) ||
57
+ /(?:exec(?:Sync)?|spawn(?:Sync)?|run(?:_command)?|system|check_call|check_output|Popen)\s*\(\s*["'`]\s*$/.test(
58
+ segment,
59
+ )
60
+ );
61
+ }
package/src/tokens.mjs CHANGED
@@ -29,6 +29,14 @@ export function tokenSecretSteps(packageInfo, slug) {
29
29
  },
30
30
  cwd: ".",
31
31
  },
32
+ {
33
+ id: "confirm-oidc-cleanup",
34
+ title: "Confirm a successful OIDC release before deleting secrets",
35
+ kind: "manual",
36
+ description:
37
+ "Verify that a new release succeeded through OIDC without a stored registry token. Confirm registry acceptance and remove token references from every workflow before deleting unused secrets.",
38
+ when: "token-secret-present",
39
+ },
32
40
  {
33
41
  id: "delete-token-secret",
34
42
  title: "Delete the leftover token secrets",