Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 8 additions & 14 deletions .github/actions/github/utilities/repo-detection/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -19,35 +20,28 @@ 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}`);
}

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}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", ".");
Expand All @@ -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}`);
Expand Down
15 changes: 5 additions & 10 deletions .github/actions/npm/jobs/publish-npm/generate-command.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", ".");
Expand All @@ -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}`);
Expand Down
38 changes: 38 additions & 0 deletions .github/actions/npm/utilities/publish-command/build.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
62 changes: 62 additions & 0 deletions .github/actions/npm/utilities/publish-command/test.mjs
Original file line number Diff line number Diff line change
@@ -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");
17 changes: 16 additions & 1 deletion docs/conventions/package-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading