feat(actions): hardened npm publish composite action#23
Conversation
OIDC-only trusted publishing with SLSA v1 provenance, idempotent over package version, refuses to fall back to NPM_TOKEN. Designed to replace the per-package inline publish step in the stella publishing repos (anonymize, regex-set, aho-corasick, fuzzy-search, stdnum, text-search). What it does: - hard-fails if NPM_TOKEN/NODE_AUTH_TOKEN is set (defence in depth) - verifies npm 11.5.1+ is on PATH (the cutoff for trusted publishing) - skips publish if name@version is already on the registry - runs `npm publish --provenance --access public --tag <tag>`; npm detects the GitHub Actions OIDC env vars automatically - retries with backoff if the publish fails but registry visibility eventually catches up Caller requirements (documented in README): - permissions: id-token: write on the calling job - setup-node + npm 11+ already on PATH - per-package trusted-publisher record configured on npmjs.com first; without it the publish fails with 401 Migration of the existing publishing repos is per-repo and out of scope for this PR.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f894b4fca8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| NPM_VERSION=$(npm --version) | ||
| NPM_MAJOR=${NPM_VERSION%%.*} | ||
| if (( NPM_MAJOR < 11 )); then |
There was a problem hiding this comment.
Enforce the full npm 11.5.1 minimum
When the runner has npm 11.0.0 through 11.5.0, this check passes even though the action contract and npm's trusted-publishing docs say the OIDC publish path requires npm CLI version 11.5.1 or later; those jobs will proceed to npm publish and fail with an auth error instead of getting the intended early, actionable failure. Compare the full semver, not just the major version.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Code Review
This pull request introduces a new composite GitHub Action, 'npm-publish-hardened', designed to publish npm packages securely using OIDC trusted publishing and SLSA v1 provenance. The action includes idempotency checks to skip already-published versions and prevents the use of legacy tokens. The review feedback highlights that the current npm version check is insufficient as it only validates the major version, potentially allowing unsupported minor versions of npm 11. A code suggestion was provided to implement a more precise semver comparison using Node.js to ensure the required 11.5.1+ version is met.
| NPM_MAJOR=${NPM_VERSION%%.*} | ||
| if (( NPM_MAJOR < 11 )); then | ||
| printf '::error::npm %s is too old; trusted publishing requires 11.5.1+.\n' \ | ||
| "${NPM_VERSION}" >&2 | ||
| exit 2 | ||
| fi |
There was a problem hiding this comment.
The current version check only verifies the major version of npm (NPM_MAJOR < 11), which allows versions like 11.0.0. However, the documentation and PR description specify that npm v11.5.1+ is required for trusted publishing support. Using an older version of npm 11 may result in the OIDC exchange being skipped, leading to authentication failures or insecure fallbacks.
Since Node.js is already a requirement for this action, you can use it to perform a more precise semver comparison.
| NPM_MAJOR=${NPM_VERSION%%.*} | |
| if (( NPM_MAJOR < 11 )); then | |
| printf '::error::npm %s is too old; trusted publishing requires 11.5.1+.\n' \ | |
| "${NPM_VERSION}" >&2 | |
| exit 2 | |
| fi | |
| if ! node -e " | |
| const [v, min] = process.argv.slice(2); | |
| const p = s => s.split('.').map(n => parseInt(n, 10) || 0); | |
| const va = p(v), ma = p(min); | |
| for (let i = 0; i < 3; i++) { | |
| if (va[i] > ma[i]) process.exit(0); | |
| if (va[i] < ma[i]) process.exit(1); | |
| } | |
| " "$NPM_VERSION" "11.5.1"; then | |
| printf '::error::npm %s is too old; trusted publishing requires 11.5.1+.\n' "${NPM_VERSION}" >&2 | |
| exit 2 | |
| fi |
The previous check only compared the major version, so npm 11.0.0 through 11.5.0 — which lack OIDC trusted publishing support — would pass and then fail at publish time with a confusing 401. Compare all three semver components. Addresses bot review (codex P2, gemini medium) on PR #23.
|
CC on behalf of @jan-kubica Addressed in the latest commit. Replaced the major-only check with a full semver comparison so npm 11.0.0–11.5.0 (which lack OIDC trusted publishing support) is now correctly rejected. Unit-tested locally against 10.9.0, 11.0.0, 11.4.99, 11.5.0 → reject; 11.5.1, 11.5.1-beta, 11.11.1, 12.0.0 → accept. Thanks codex + gemini. |
Summary
Item #4 of the supply-chain hardening pass — a composite action that codifies OIDC trusted publishing with SLSA v1 provenance for use across the @stll publishing repos.
`.github/actions/npm-publish-hardened/` contains:
What it does, briefly
Composite vs reusable workflow
Composite scopes the abstraction to the part that's actually shared. The surrounding build/test scaffolding diverges across publishing repos (napi-rs cross-compilation vs pure JS), and a reusable workflow would need many inputs to handle every shape. Each repo's release.yml stays readable end-to-end; only the publish step is centralized.
Migration
Out of scope here. After this lands, each publishing repo gets a small follow-up PR that replaces its inline publish step with a `- uses: stella/.github/.github/actions/npm-publish-hardened@` block and drops `NPM_TOKEN` from env. Migration is gated on trusted-publishing being configured for the package on the npm side (confirmed already done across @stll).
Test plan