CI/CD, Publishing & npm Provenance
Automate npm publishing with GitHub Actions, OIDC trusted publishing, Sigstore provenance attestation, and pre-publish validation for dual ESM/CJS packages.
This section covers the release engineering side of dual-format npm packages: automating builds and publishes with GitHub Actions, generating cryptographic Sigstore provenance for every tarball, replacing long-lived tokens with OIDC trusted publishing, and validating a package’s shape before it ever reaches the registry. It is written for maintainers and platform engineers who need a release pipeline that is reproducible, auditable, and resistant to supply-chain tampering.
Quick-Reference: Key Terms
| Term | Definition | Reference |
|---|---|---|
| Provenance | A signed statement linking a published tarball to the exact source commit and CI workflow that produced it | npm Provenance & Sigstore Attestation |
| Sigstore | The open, keyless signing infrastructure (Fulcio + Rekor) npm uses to generate and store provenance attestations | npm Provenance & Sigstore Attestation |
| OIDC trusted publishing | A publish flow where npm exchanges a CI-issued OIDC token for a short-lived credential, removing the need for a stored NPM_TOKEN |
Configuring OIDC Trusted Publishing for npm |
| dist-tag | A named pointer (latest, next, beta) that maps to a specific published version, controlling what plain npm install resolves to |
Managing prerelease and dist-tags on npm |
| Attestation | The signed, verifiable record (build provenance or publish attestation) stored alongside a package version in the registry | Publishing with npm Provenance in GitHub Actions |
publint |
A static linter that checks a package’s exports, main, and types fields resolve to files that actually exist in the published tarball |
Using publint to catch exports errors |
attw (are the types wrong) |
A CLI that simulates how TypeScript resolves a package’s types under every moduleResolution mode and reports mismatches |
Checking types with are-the-types-wrong |
Core Concepts
The automated release pipeline
A dual-format package cannot be published safely by running npm publish from a developer’s laptop. The build step must run in a clean, reproducible environment so the ESM and CJS artifacts in the tarball match exactly what CI validated — a laptop with stale dist/ output or an uncommitted local patch produces a tarball nobody can audit. The baseline pipeline triggers on a version tag, installs with a locked lockfile, builds both formats, validates the result, and only then publishes:
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
id-token: write # required for --provenance and OIDC trusted publishing
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run build
- run: npx publint --strict
- run: npx attw --pack .
- run: npm publish --provenance --access public
Every step is ordered deliberately: npm ci (not npm install) enforces the committed lockfile, the build runs before validation so publint inspects real output, and npm publish is the last step so a validation failure never reaches the registry.
How provenance attestation works
npm publish --provenance asks npm to generate a Sigstore attestation during the publish step instead of, or in addition to, signing with a maintainer’s personal key. The CI job proves its identity to Sigstore’s Fulcio certificate authority using the workflow’s short-lived OIDC token — no long-lived signing key is stored anywhere. Fulcio issues a certificate valid for minutes, npm’s CLI signs the package manifest and tarball digest with it, and the resulting attestation is written to Sigstore’s Rekor transparency log, a public, append-only ledger.
# Inside the CI job, after the build:
npm publish --provenance --access public
# npm CLI (>= 9.5) performs, transparently:
# 1. Requests a short-lived OIDC token from the CI provider (GitHub Actions)
# 2. Exchanges it for a Fulcio-issued signing certificate
# 3. Signs the tarball digest + build metadata
# 4. Uploads the attestation to Rekor and links it to the published version
The result is a verifiable answer to “which commit and which workflow run produced this exact tarball” — without requiring consumers to trust the maintainer’s personal machine or a shared secret.
Token auth vs OIDC trusted publishing
Classic npm automation relies on a long-lived NPM_TOKEN stored as a repository secret. It works, but the token grants publish rights indefinitely until manually rotated or revoked, and a leaked token compromises every package it can publish. OIDC trusted publishing removes the stored secret entirely: the maintainer configures npmjs.com to trust a specific GitHub repository and workflow file, and at publish time npm exchanges the workflow’s ephemeral OIDC token for a publish credential that expires within minutes.
# No NPM_TOKEN secret required at all — trust is configured on npmjs.com
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci && npm run build
- run: npm publish --provenance
If the workflow file, branch, or repository does not exactly match what was registered as a trusted publisher, the exchange fails closed — there is no fallback to a stored token unless one is explicitly configured as a backup.
Hazard and Failure-Mode Inventory
HAZARD PREVENTION
Symptom:
npm ERR! code ENEEDAUTHduringnpm publishin CI, even though a token secret is configured.Root cause: The
NPM_TOKENenvironment variable was not exported to thenpm publishstep, the token expired, orregistry-urlwas omitted fromactions/setup-node, leaving.npmrcunconfigured.Fix: Pass
registry-url: 'https://registry.npmjs.org'tosetup-node, exportNODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}on the publish step, or switch to OIDC trusted publishing to remove the token dependency entirely.
HAZARD PREVENTION
Symptom:
npm publish --provenancefails with an error mentioning the OIDC token could not be obtained, or provenance silently does not appear on the published version.Root cause: The workflow’s
permissionsblock does not grantid-token: write. Without it, the runner never issues the short-lived OIDC token that Sigstore’s Fulcio authority needs to sign the attestation.Fix: Add
permissions: { id-token: write, contents: read }at the job or workflow level in your release workflow.
HAZARD PREVENTION
Symptom:
npm publish --provenancesucceeds for a public package but fails, or is rejected on the registry, for a scoped package that has never been published before.Root cause: Scoped packages (
@scope/name) default to private on first publish. Provenance attestations are only supported for packages published with public visibility; omitting--access publicon a scoped package’s first publish causes a registry-side rejection unrelated to the attestation itself.Fix: Always pass
--access publicalongside--provenancethe first time a scoped package is published, as shown in publishing with npm provenance in GitHub Actions.
HAZARD PREVENTION
Symptom: A published version is missing files a consumer expects, or ships a stale
dist/build from a previous run — even though the workflow appears green.Root cause: The
buildstep was skipped or cached incorrectly, andnpm publishran against a leftoverdist/directory from an earlier job, a local checkout, or a partially restored cache.Fix: Run the build step unconditionally before publish in the same job (never a separate, independently-cacheable job), and add a pre-publish validation step that fails the pipeline if
publintfinds paths that do not resolve.
Decision Guide: Token, OIDC, or Both?
Which authentication method you use to publish depends on two questions: can your CI provider present an OIDC identity token, and can you pin a single trusted workflow and branch on npmjs.com? The tree below maps those answers to the three outcomes.
- OIDC trusted publishing is the default when your CI can present an OIDC token and you can register a fixed workflow file and branch as a trusted publisher on npmjs.com — no long-lived secret is stored anywhere.
- A scoped automation token is the fallback when workflows are dynamic (matrix publishes or generated workflow files) so a single trusted-publisher record cannot cover them. Use a Granular Access Token scoped to the one package.
- A Granular Access Token is the only option for self-hosted runners or CI providers that cannot issue OIDC tokens; rotate it on a schedule.
The Release Pipeline as a Supply Chain
Every step between a merged pull request and an installed node_modules folder is a link in a chain that somebody could tamper with, and each link has a different owner. Seeing the chain laid out is what makes the individual controls — OIDC, provenance, lockfile integrity, dist-tags — stop feeling like unrelated chores.
The most under-applied control on that diagram is pinning third-party actions to a commit SHA. A workflow that writes uses: some-org/setup-thing@v2 re-resolves that tag on every run, and whoever controls the tag controls a step that runs inside a job holding your publish credentials. Pinning (uses: some-org/setup-thing@a1b2c3d4…) freezes the code and turns any change into a reviewable diff, with Dependabot proposing the bump. The cost is one extra pull request per action per quarter; the benefit is that a compromised upstream tag cannot silently reach your release job.
The second is scoping permissions per job rather than per workflow. A default permissions: read-all at the top of the file, with id-token: write and contents: write granted only inside the publish job, means the test matrix — which runs untrusted code from pull requests — never holds a token capable of publishing anything.
Reproducible Builds and Why They Matter Here
Provenance records which workflow produced a tarball. Reproducibility answers the follow-up question a security reviewer asks next: could an independent party rebuild that commit and obtain the same bytes? For most JavaScript packages the honest answer is “not quite”, and the reasons are worth knowing because two of them are easy to fix.
The first is timestamps. Build tools that embed a build date in a banner comment, or archive tools that record file modification times, produce a different tarball on every run. npm’s tarball creation normalises modification times, but a banner containing new Date().toISOString() survives into the published JavaScript. Replacing it with the version number or the commit SHA removes the nondeterminism without losing the diagnostic value.
The second is dependency drift. A build that resolves ^ ranges at build time can pick up a new transitive version between two runs of the same commit. Committing a lockfile and installing with npm ci — never npm install — pins the tree, which is the same discipline that makes CI failures debuggable in the first place.
The third is genuinely hard: minifier and compiler versions change output. This is why reproducibility claims are always scoped to a toolchain, and why pinning the Node.js version in the workflow (node-version: '22.11.0', not '22') is part of the story rather than a fussy detail.
- uses: actions/setup-node@v4
with:
node-version: '22.11.0' # exact, not a floating major
registry-url: 'https://registry.npmjs.org'
cache: npm
- run: npm ci # lockfile-exact install, never npm install
Even partial reproducibility pays off. If a maintainer can rebuild last week’s release locally and diff the tarball against the published one, a compromised runner becomes detectable rather than theoretical, and npm pack plus a checksum comparison is the whole verification procedure:
npm pack --silent | xargs shasum -a 512
npm view @scope/[email protected] dist.integrity
What “Publishing Health” Looks Like Over Time
The controls above are point-in-time. Packages, however, degrade slowly: a latest tag left on a broken version, an abandoned beta, a repository field pointing at a moved URL, a deprecated dependency that never got replaced. A short quarterly review keeps the published surface honest, and every item on it is one command:
- Where do the dist-tags point?
npm dist-tag ls @scope/my-library— stalebetaornexttags mislead consumers following old instructions. - What does a fresh install actually pull?
npm view @scope/my-library dependencies— a range that once meant a small dependency may now resolve to something much larger. - Do the published types still resolve?
npx attw @scope/my-library@latestruns against the registry copy, not your working tree, which catches drift between what you validate locally and what shipped. - Is the provenance chain unbroken?
npm view @scope/my-library@latest --json | jq '.dist.attestations'— a missing attestation on a recent version means some release bypassed the pipeline. - Are there versions that should be deprecated?
npm deprecateon known-broken releases removes them from the recommended path without breaking anyone who has already pinned them.
None of this requires new tooling, and the whole review takes a few minutes per package — less time than a single support thread about a package that quietly stopped resolving. Its real value is catching the class of problem that no gate can catch at publish time — because the package was fine when it shipped, and the world moved. Put it on a calendar rather than a wiki page; a review that depends on somebody remembering is a review that stops happening after the second quarter.
Who Is Allowed to Publish
Access control is the part of publishing that teams get wrong quietly, because nothing fails until the day it matters. npm’s model has three levers, and they compose differently for a personal package than for an organisation.
Ownership is the coarsest: npm owner ls @scope/my-library lists the accounts that can publish, add owners, and deprecate. A package whose owner list contains one person is a bus-factor problem; a package whose owner list contains eight is a much larger attack surface. For scoped packages published under an organisation, prefer granting access through a team rather than adding individual owners, so removing someone from the team removes their publish rights everywhere at once.
Two-factor authentication can be required for publishing at the account level or the package level. The package-level setting (npm access set mfa=publish @scope/my-library) is the one that matters for supply-chain safety, because it applies regardless of which maintainer publishes. Note the interaction with automation: a classic automation token bypasses 2FA by design, which is precisely why trusted publishing via OIDC is preferable — it removes the long-lived credential that the bypass exists to accommodate.
Granular access tokens replace the old all-or-nothing tokens. A token can be scoped to specific packages, given read-only or read-write rights, restricted to an IP range, and set to expire. If you still need a token — for a registry that does not support OIDC, or a self-hosted runner — a granular token scoped to exactly one package with a 90-day expiry is a far smaller liability than a classic automation token that never expires and can publish anything the account owns.
npm owner ls @scope/my-library # who can publish today
npm access get status @scope/my-library # public or restricted
npm token list # what credentials exist, and when they expire
The reason to run those three commands on a schedule is that access drifts in one direction. People join and are granted rights; people leave and the revocation is forgotten. A departing maintainer’s still-valid token is the single most common way a package changes hands without anyone noticing, and the only defence is periodically comparing the answer to those commands against the list of people who should currently be able to publish.
One more habit closes the loop between access and provenance: whenever the set of people who can publish changes, re-read the release workflow with the same eyes. Publishing rights and pipeline configuration are two halves of the same permission — an organisation can tighten npm access to a single team and still leave a workflow that any contributor can modify in a pull request, which puts the publish job back within reach of anyone who can get a change merged. Requiring review on .github/workflows/** through a CODEOWNERS entry is the cheapest way to keep those two halves aligned.
For organisations, add one structural rule: the npm account that owns the package should not be a personal account. A package owned by @alice becomes unmaintainable the day Alice’s account is deleted, whereas a package owned by an organisation survives any individual’s departure, and the audit trail of who published what stays intact because each publish is still attributed to a human or a workflow identity.
Topic Index
npm Provenance & Sigstore Attestation
How npm publish --provenance produces a Sigstore-backed attestation, how GitHub Actions permissions feed the signing flow, and how consumers verify the resulting supply-chain link before installing.
Read guide →
Includes: Publishing with npm Provenance in GitHub Actions, Verifying Package Provenance with npm audit signatures
Publishing to Private and Alternative Registries
Scoped registry routing, publishConfig pinning, CI credentials that never touch the repository, and a local registry for rehearsing a full publish — plus what provenance you give up by publishing somewhere other than the public registry. Read the guide
Automating npm Releases with GitHub Actions
A complete release workflow for dual-format packages: matrix builds, registry authentication, build-before-publish ordering, and tag-triggered releases that stay reproducible across runs. Read guide →
Includes: Configuring OIDC Trusted Publishing for npm, semantic-release for Dual-Format Packages
Validating Packages Before Publish
The pre-publish checklist that catches broken exports maps, incorrect TypeScript resolution, and missing files before they ship — using publint, are-the-types-wrong, and npm pack --dry-run.
Read guide →
Includes: Using publint to catch exports errors, Checking types with are-the-types-wrong
Versioning & Changelog Automation
Automating semantic version bumps, changelog generation, and dist-tag management so prereleases, betas, and stable releases never collide on the registry.
Read guide →
Includes: Automating Changelogs with Changesets, Managing Prerelease and dist-tags on npm
Frequently Asked Questions
Does npm publish --provenance work outside GitHub Actions?
Provenance generation requires a CI environment that issues an OIDC token npm’s CLI can exchange with Sigstore’s Fulcio authority. GitHub Actions and GitLab CI both support this natively as of npm 9.5+. Publishing from a local machine or an unsupported CI provider cannot generate provenance — the flag is silently unavailable outside a recognized OIDC-issuing environment.
Is OIDC trusted publishing the same thing as provenance?
No — they solve adjacent but distinct problems. Provenance proves what built the tarball (the workflow, commit, and build environment). OIDC trusted publishing removes the credential used to authenticate the publish call itself, replacing a stored NPM_TOKEN with a short-lived exchange. Most release pipelines use both together.
Can I build once and publish the same artifact from a promotion pipeline instead of rebuilding per stage?
Yes, and it is the safer pattern: build once, upload the tarball as a workflow artifact, run validation against that exact tarball, then publish it unchanged. Rebuilding at the publish step risks a non-reproducible build producing a different tarball than what was validated.
Do I need both publint and are-the-types-wrong in CI?
They check different failure modes. publint validates that exports/main/files fields point at real files with correct condition ordering. attw simulates how TypeScript actually resolves your types under node10, node16, and bundler resolution — a package can pass publint and still resolve to the wrong .d.ts file under a specific moduleResolution. Run both, detailed in validating packages before publish.
What happens if I forget to bump the version before publishing?
npm publish rejects a version that already exists on the registry with npm ERR! 403 Forbidden - cannot publish over previously published version. Automating version bumps with tools like semantic-release or Changesets removes this class of human error entirely by deriving the version from commit history.
Related
- Module System Fundamentals & Dual-Package Resolution — the
exportsfield and dual-format output this pipeline builds and validates before every publish. - Tree-Shaking & Bundle Optimization — bundle-size regressions are worth catching in the same CI run that validates and publishes a release.
- TypeScript Configuration & Build Tooling — the
tsconfig.jsonand build-tool settings that produce the declaration filesattwchecks in this pipeline. - Mastering the package.json Exports Field — the condition map that
publintandattwboth validate against during pre-publish checks.