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.


The Automated npm Publish Pipeline A five-stage horizontal flow: a tagged commit triggers a build that emits both ESM and CJS artifacts, which are validated by publint and attw, then published with a Sigstore provenance attestation, and finally land on the npm registry alongside their transparency-log entry. Source to Registry Source tagged commit Build dual ESM + CJS Validate publint + attw Publish --provenance Registry + transparency log

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 ENEEDAUTH during npm publish in CI, even though a token secret is configured.

Root cause: The NPM_TOKEN environment variable was not exported to the npm publish step, the token expired, or registry-url was omitted from actions/setup-node, leaving .npmrc unconfigured.

Fix: Pass registry-url: 'https://registry.npmjs.org' to setup-node, export NODE_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 --provenance fails 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 permissions block does not grant id-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 --provenance succeeds 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 public on a scoped package’s first publish causes a registry-side rejection unrelated to the attestation itself.

Fix: Always pass --access public alongside --provenance the 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 build step was skipped or cached incorrectly, and npm publish ran against a leftover dist/ 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 publint finds 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.

Choosing an npm Publish Authentication Method A branching decision tree. First: does your CI support OIDC trusted publishing? If no, use a granular access token. If yes, can you register a fixed workflow and branch as a trusted publisher? If yes, use OIDC trusted publishing with no stored token; if no, fall back to a scoped automation token. CI supports OIDC? GitHub Actions, GitLab CI Yes No Fixed workflow + branch? registrable on npmjs.com Granular Access Token scoped + rotated Yes No OIDC trusted publish no stored token Scoped automation token fallback for dynamic workflows
  • 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 publishing supply chain and its controls Four hops: from commit to CI build, from build to tarball, from tarball to registry, and from registry to consumer install. Branch protection, pinned actions, provenance attestation and lockfile integrity each protect one hop. Four hops, four different controls commit reviewed source CI build runner + actions registry immutable version consumer npm ci install control branch protection required review control actions pinned to SHA least-privilege tokens control provenance attestation 2FA / trusted publishing control lockfile integrity audit signatures A control on one hop proves nothing about the others — they are complementary, not redundant

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 — stale beta or next tags 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@latest runs 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 deprecate on 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.


← Back to home