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