From c4dc1aa50e6a59d6ebe2ce689e99a5387160f538 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sun, 27 Sep 2026 21:49:21 -0700 Subject: [PATCH 1/2] fix(ci): pass --ignore-scripts on the default publish command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish jobs run the generated command inside the downloaded package-contents/ artifact: npm-pack output with no node_modules and no lockfile. npm/pnpm/yarn publish still fire prepack/prepublishOnly/postpack there, so a repo whose prepack runs a real build (e.g. tsup behind "prepack": "npm run build") re-runs it in a directory that can't supply its devDependencies and the publish dies with `tsup: not found` (exit 127) — CLDMV/vitest-runner v1.4.0 failed both registries this way. build_command already ran in the build job before packing, so no lifecycle script has work left at publish time. Add --ignore-scripts to the default command for both npm and GitHub Packages, for every package manager. A caller-supplied publish_command / github_packages_publish_command is still used verbatim. The command was built in three places — repo-detection (the one the reusable workflow actually uses, core and satellites) plus the publish-npm / publish-github-packages fallbacks — kept in sync by comment. Move it into one shared buildPublishCommand() in npm/utilities/publish-command with unit tests, so the flag can't drift between them. Document the artifact publish contract and the custom command caveat in docs/conventions/package-manager.md. Fixes #320 --- .../utilities/repo-detection/action.mjs | 22 +++---- .../generate-command.mjs | 6 +- .../npm/jobs/publish-npm/generate-command.mjs | 15 ++--- .../npm/utilities/publish-command/build.mjs | 38 ++++++++++++ .../npm/utilities/publish-command/test.mjs | 62 +++++++++++++++++++ docs/conventions/package-manager.md | 17 ++++- 6 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 .github/actions/npm/utilities/publish-command/build.mjs create mode 100644 .github/actions/npm/utilities/publish-command/test.mjs diff --git a/.github/actions/github/utilities/repo-detection/action.mjs b/.github/actions/github/utilities/repo-detection/action.mjs index adf696e9..2be79be6 100644 --- a/.github/actions/github/utilities/repo-detection/action.mjs +++ b/.github/actions/github/utilities/repo-detection/action.mjs @@ -7,6 +7,7 @@ import { api, parseRepo } from "../../api/_api/core.mjs"; import { getInput, setOutputs } from "../../../common/common/core.mjs"; import { resolvePackageManager } from "../../../npm/utilities/detect-package-manager/resolve.mjs"; +import { buildPublishCommand } from "../../../npm/utilities/publish-command/build.mjs"; try { const token = getInput("github-token", { required: true }); @@ -19,27 +20,20 @@ try { const isPrivate = repoInfo.private === true; console.log(`Repository is private: ${isPrivate}`); - const accessLevel = isPrivate ? "restricted" : "public"; console.log( isPrivate ? "šŸ”’ Private repository detected - using restricted access for auto-detection" : "šŸŒ Public repository detected - using public access for auto-detection" ); - const tool = packageManager === "yarn" ? "yarn publish" : packageManager === "pnpm" ? "pnpm publish" : "npm publish"; - let npmCommand = customNpmCommand; if (!npmCommand) { - npmCommand = `${tool} --access ${accessLevel}`; - // Public npm packages published via the npm CLI carry SLSA build - // provenance + a publish attestation (sigstore). Private packages can't - // (provenance is public-only) and `yarn publish` has no equivalent flag, - // so gate on both. Requires id-token: write on the publish job (granted) - // and a supported CI (GitHub Actions) — both true in this pipeline. A - // caller-supplied custom command is left untouched (opts out). - if (!isPrivate && tool === "npm publish") { - npmCommand += " --provenance"; - } + // Adds --ignore-scripts always and --provenance for public npm-CLI + // publishes (see buildPublishCommand). Provenance requires id-token: + // write on the publish job (granted) and a supported CI (GitHub + // Actions) — both true in this pipeline. A caller-supplied custom + // command is left untouched (opts out of both). + npmCommand = buildPublishCommand({ packageManager, isPrivate, registry: "npm" }); console.log(`šŸ“¦ Auto-detected NPM command: ${npmCommand}`); } else { console.log(`šŸ“¦ Using custom NPM command: ${npmCommand}`); @@ -47,7 +41,7 @@ try { let githubPackagesCommand = customGithubPackagesCommand; if (!githubPackagesCommand) { - githubPackagesCommand = `${tool} --access ${accessLevel}`; + githubPackagesCommand = buildPublishCommand({ packageManager, isPrivate, registry: "github-packages" }); console.log(`šŸ“¦ Auto-detected GitHub Packages command: ${githubPackagesCommand}`); } else { console.log(`šŸ“¦ Using custom GitHub Packages command: ${githubPackagesCommand}`); diff --git a/.github/actions/npm/jobs/publish-github-packages/generate-command.mjs b/.github/actions/npm/jobs/publish-github-packages/generate-command.mjs index d4adcb39..f132df9c 100644 --- a/.github/actions/npm/jobs/publish-github-packages/generate-command.mjs +++ b/.github/actions/npm/jobs/publish-github-packages/generate-command.mjs @@ -7,6 +7,7 @@ import { getEventPayload, setOutput } from "../../../common/common/core.mjs"; import { resolvePackageManager } from "../../utilities/detect-package-manager/resolve.mjs"; +import { buildPublishCommand } from "../../utilities/publish-command/build.mjs"; const customCommand = process.env.CUSTOM_CMD || ""; const packageManager = resolvePackageManager(process.env.PACKAGE_MANAGER || "auto", "."); @@ -24,8 +25,9 @@ if (customCommand) { console.log(`šŸ“Š Repository visibility: ${visibility}`); const accessLevel = visibility === "public" ? "public" : "restricted"; console.log(`šŸ”’ Package access level: ${accessLevel}`); - const tool = packageManager === "yarn" ? "yarn publish" : packageManager === "pnpm" ? "pnpm publish" : "npm publish"; - finalCommand = `${tool} --access ${accessLevel}`; + // Shared with the authoritative builder in utilities/repo-detection + // (--ignore-scripts always; no provenance on GitHub Packages). + finalCommand = buildPublishCommand({ packageManager, isPrivate: visibility !== "public", registry: "github-packages" }); } console.log(`šŸ“ Final publish command: ${finalCommand}`); diff --git a/.github/actions/npm/jobs/publish-npm/generate-command.mjs b/.github/actions/npm/jobs/publish-npm/generate-command.mjs index f0cc1827..5d079fcd 100644 --- a/.github/actions/npm/jobs/publish-npm/generate-command.mjs +++ b/.github/actions/npm/jobs/publish-npm/generate-command.mjs @@ -7,6 +7,7 @@ import { getEventPayload, setOutput } from "../../../common/common/core.mjs"; import { resolvePackageManager } from "../../utilities/detect-package-manager/resolve.mjs"; +import { buildPublishCommand } from "../../utilities/publish-command/build.mjs"; const customCommand = process.env.CUSTOM_CMD || ""; const packageManager = resolvePackageManager(process.env.PACKAGE_MANAGER || "auto", "."); @@ -23,16 +24,10 @@ if (customCommand) { console.log(`šŸ“Š Repository visibility: ${visibility}`); const accessLevel = visibility === "public" ? "public" : "restricted"; console.log(`šŸ”’ Package access level: ${accessLevel}`); - const tool = packageManager === "yarn" ? "yarn publish" : packageManager === "pnpm" ? "pnpm publish" : "npm publish"; - finalCommand = `${tool} --access ${accessLevel}`; - // Public npm packages published via the npm CLI carry SLSA build provenance - // + a publish attestation (sigstore). Private packages can't, and `yarn - // publish` has no equivalent flag — so gate on both. Kept in sync with the - // authoritative builder in utilities/repo-detection (this fallback runs only - // when no command was pre-derived). - if (visibility === "public" && tool === "npm publish") { - finalCommand += " --provenance"; - } + // Shared with the authoritative builder in utilities/repo-detection (this + // fallback runs only when no command was pre-derived): --ignore-scripts + // always, --provenance for public npm-CLI publishes. + finalCommand = buildPublishCommand({ packageManager, isPrivate: visibility !== "public", registry: "npm" }); } console.log(`šŸ“ Final publish command: ${finalCommand}`); diff --git a/.github/actions/npm/utilities/publish-command/build.mjs b/.github/actions/npm/utilities/publish-command/build.mjs new file mode 100644 index 00000000..b75feea7 --- /dev/null +++ b/.github/actions/npm/utilities/publish-command/build.mjs @@ -0,0 +1,38 @@ +/** + * @fileoverview Build the default publish command for NPM or GitHub Packages. + * Single source of truth shared by repo-detection (the authoritative builder) + * and the publish-npm / publish-github-packages fallbacks. + * @module @cldmv/.github.npm.utilities.publish-command.build + */ + +/** + * Build the default (non-custom) publish command. + * + * Always passes `--ignore-scripts`: the command runs inside the downloaded + * `package-contents/` artifact — `npm pack` output with no `node_modules` and + * no lockfile. The build job already ran `build_command` before packing, so no + * lifecycle script (`prepack`, `prepublishOnly`, `postpack`, …) has legitimate + * work left, and one that needs devDependencies (e.g. a bundler in `prepack`) + * would crash the publish (#320). + * + * Public packages published via the npm CLI to the npm registry also carry + * SLSA build provenance + a publish attestation (sigstore). Private packages + * can't (provenance is public-only), `yarn publish` / `pnpm publish` have no + * equivalent flag, and GitHub Packages doesn't take it — so it's gated on all + * three. + * + * @param {object} options + * @param {"npm"|"yarn"|"pnpm"} options.packageManager - Resolved package manager. + * @param {boolean} options.isPrivate - Whether the repository is private. + * @param {"npm"|"github-packages"} options.registry - Target registry. + * @returns {string} The publish command. + */ +export function buildPublishCommand({ packageManager, isPrivate, registry }) { + const tool = packageManager === "yarn" ? "yarn publish" : packageManager === "pnpm" ? "pnpm publish" : "npm publish"; + const accessLevel = isPrivate ? "restricted" : "public"; + let command = `${tool} --access ${accessLevel} --ignore-scripts`; + if (registry === "npm" && !isPrivate && tool === "npm publish") { + command += " --provenance"; + } + return command; +} diff --git a/.github/actions/npm/utilities/publish-command/test.mjs b/.github/actions/npm/utilities/publish-command/test.mjs new file mode 100644 index 00000000..7100f6af --- /dev/null +++ b/.github/actions/npm/utilities/publish-command/test.mjs @@ -0,0 +1,62 @@ +/** + * @fileoverview Unit tests for the default publish-command builder. + * Run: `node test.mjs`. Exits non-zero on failure. + * @module @cldmv/.github.npm.utilities.publish-command.test + */ + +import { buildPublishCommand } from "./build.mjs"; + +let failures = 0; +function eq(name, got, want) { + if (got === want) { + console.log(` āœ… ${name}`); + } else { + failures++; + console.log(` āŒ ${name}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`); + } +} + +console.log("buildPublishCommand — npm registry:"); +eq( + "npm, public → public access, ignore-scripts, provenance", + buildPublishCommand({ packageManager: "npm", isPrivate: false, registry: "npm" }), + "npm publish --access public --ignore-scripts --provenance" +); +eq( + "npm, private → restricted, ignore-scripts, no provenance", + buildPublishCommand({ packageManager: "npm", isPrivate: true, registry: "npm" }), + "npm publish --access restricted --ignore-scripts" +); +eq( + "pnpm, public → no provenance", + buildPublishCommand({ packageManager: "pnpm", isPrivate: false, registry: "npm" }), + "pnpm publish --access public --ignore-scripts" +); +eq( + "yarn, public → no provenance", + buildPublishCommand({ packageManager: "yarn", isPrivate: false, registry: "npm" }), + "yarn publish --access public --ignore-scripts" +); + +console.log("buildPublishCommand — GitHub Packages:"); +eq( + "npm, public → no provenance on GitHub Packages", + buildPublishCommand({ packageManager: "npm", isPrivate: false, registry: "github-packages" }), + "npm publish --access public --ignore-scripts" +); +eq( + "npm, private → restricted", + buildPublishCommand({ packageManager: "npm", isPrivate: true, registry: "github-packages" }), + "npm publish --access restricted --ignore-scripts" +); +eq( + "pnpm, private → restricted", + buildPublishCommand({ packageManager: "pnpm", isPrivate: true, registry: "github-packages" }), + "pnpm publish --access restricted --ignore-scripts" +); + +if (failures) { + console.log(`\nāŒ ${failures} failure(s)`); + process.exit(1); +} +console.log("\nāœ… all passed"); diff --git a/docs/conventions/package-manager.md b/docs/conventions/package-manager.md index 3fce2c1f..357b9866 100644 --- a/docs/conventions/package-manager.md +++ b/docs/conventions/package-manager.md @@ -17,7 +17,7 @@ The single source of truth is `.github/actions/npm/utilities/detect-package-mana - **install** — `pnpm install` (`--frozen-lockfile` per the frozen rule below); pnpm/yarn are provisioned via **corepack** (bundled with Node — no third-party action, keeping the Scorecard Pinned-Dependencies check green). - **run / exec** — script and tool invocations route through the resolved manager (`pnpm run …`, `pnpm exec` / `pnpm dlx`), a no-op for npm. -- **publish** — pnpm uses `pnpm publish`, so `workspace:*` dependencies are rewritten to real versions in the published tarball. npm keeps `npm publish` (with `--provenance` on public packages). +- **publish** — pnpm uses `pnpm publish`, so `workspace:*` dependencies are rewritten to real versions in the published tarball. npm keeps `npm publish` (with `--provenance` on public packages). Every generated publish command also passes `--ignore-scripts` — see [Publishing from the build artifact](#publishing-from-the-build-artifact). - **setup-node cache** — the cache key is manager-aware for npm/yarn (with a lockfile); pnpm store caching is intentionally left off for now to avoid `actions/setup-node`'s pre-install pnpm ordering failure. This is a caching optimization, not a correctness gap. ## Frozen installs are the CI default @@ -44,3 +44,18 @@ CLDMV_SKIP_FROZEN_LOCKFILE = 1 Set it to a truthy value (`1` / `true` / `yes`) to opt out; unset (the default) means frozen. It is read at every install site — including the release/publish path — via the `vars` context, so no consumer workflow files need editing to use it. This is a deliberate escape hatch for the rare repository that genuinely cannot commit a lockfile. It is **strongly discouraged**: a non-frozen install is not reproducible and undermines the point of a CI/release pipeline. Prefer committing a lockfile. Do not set this variable unless there is a specific, documented reason to. + +## Publishing from the build artifact + +The publish jobs never run in the repo checkout. `build-and-test` runs `build_command`, then `npm pack`, expands the tarball into `package-contents/`, and uploads it; the publish jobs download that artifact and publish from it. That directory has no `node_modules` and no lockfile, and nothing left to build. + +So the default publish command — for both npm and GitHub Packages, with any package manager — passes `--ignore-scripts`: + +| Target | Public repo | Private repo | +| --------------- | ----------------------------------------------------------- | -------------------------------------------------- | +| npm | `npm publish --access public --ignore-scripts --provenance` | `npm publish --access restricted --ignore-scripts` | +| GitHub Packages | `npm publish --access public --ignore-scripts` | `npm publish --access restricted --ignore-scripts` | + +(`pnpm publish` / `yarn publish` replace `npm publish` when that manager is resolved; `--provenance` is npm-CLI-only.) Without it, lifecycle scripts such as `prepack` or `prepublishOnly` would fire a second time inside the artifact, and any that need devDependencies — a bundler build behind `"prepack": "npm run build"`, for example — fail with `command not found` and abort the publish. A repo does not need to guard its own `prepack` against this. + +The builder lives in `.github/actions/npm/utilities/publish-command/build.mjs`. A caller-supplied `publish_command` / `github_packages_publish_command` is used verbatim, with no flags added — include `--ignore-scripts` in a custom command too unless a lifecycle script genuinely has to run against the packed tree. From e5289c54d7043b15a4529b29c359066d61d2fe65 Mon Sep 17 00:00:00 2001 From: "cldmv-bot[bot]" <230771808+cldmv-bot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:59:16 +0000 Subject: [PATCH 2/2] chore: bump version to 4.29.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 363966ae..050ba552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/.github", - "version": "4.29.0", + "version": "4.29.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/.github", - "version": "4.29.0", + "version": "4.29.1", "license": "Apache-2.0", "devDependencies": { "@cldmv/eslint-plugin-jsonv": "^1.0.3", diff --git a/package.json b/package.json index d50f1536..7880d440 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/.github", - "version": "4.29.0", + "version": "4.29.1", "description": "Shared GitHub Actions, reusable workflows, and org-wide tooling for the CLDMV organization.", "author": { "name": "Shinrai",