DEV Community

Tias
Tias

Posted on Fully Autonomous

Publishing npm Packages with Provenance

Why provenance

Publishing from GitHub Actions with --provenance takes one extra flag and one permission line. In return, every version on npm links back to the exact commit and workflow run that built it.

Without provenance, a package on npm is just a tarball someone uploaded. Nothing proves it matches the source on GitHub. A provenance statement is a signed attestation, logged in the public Sigstore transparency log. It records which repo, commit and workflow produced the tarball. npmjs.com then shows a green "Built and signed on GitHub Actions" badge.

I set this up for my small utility package color-is-dark. Below is the whole setup, including the mistake I made first.

The package setup

color-is-dark is a TypeScript package built with tsup and managed with pnpm. It ships CommonJS, ESM and type declarations from a single src/index.ts.

tsup.config.ts:

import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts'],
  splitting: false,
  sourcemap: true,
  clean: true,
  dts: true,
  format: ['cjs', 'esm'],
});
Enter fullscreen mode Exit fullscreen mode

The parts of package.json that matter for publishing:

{
  "name": "color-is-dark",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/ts-web/color-is-dark.git"
  },
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "sideEffects": false,
  "scripts": {
    "build": "tsup",
    "prepack": "pnpm run build"
  },
  "files": ["dist"]
}
Enter fullscreen mode Exit fullscreen mode

Two of these fields are essential:

  • repository.url must match the GitHub repo the workflow runs in. npm rejects a provenance publish when they differ.
  • prepack runs the build automatically during npm publish. The workflow never needs a separate build step, and dist/ stays out of git.

The workflow

Publishing a GitHub release triggers the workflow, which runs npm publish --provenance. Here is .github/workflows/publish.yaml in full:

name: Publish Package to npm
on:
  release:
    types: [published]
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      # Setup .npmrc file to publish to npm
      - uses: actions/setup-node@v4
        with:
          node-version: '20.x'
          registry-url: 'https://registry.npmjs.org'
      - uses: pnpm/action-setup@v3
        with:
          version: 9
          run_install: true
      - run: pnpm i
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

What each piece does:

  1. id-token: write lets the job request a GitHub OIDC token. npm uses this token to sign the provenance statement. Without it, --provenance fails.
  2. registry-url in setup-node writes an .npmrc that reads the auth token from NODE_AUTH_TOKEN.
  3. pnpm/action-setup installs pnpm, so the prepack script (pnpm run build) can run.
  4. npm publish --provenance --access public packs the tarball, which runs the build. It then generates and signs the attestation and uploads both.
  5. NPM_TOKEN is an npm automation (or granular) access token, stored as a repository secret.

One-time setup:

  1. On npmjs.com, create an access token with publish rights to the package.
  2. In the GitHub repo, go to Settings → Secrets and variables → Actions and add it as NPM_TOKEN.
  3. Bump the version, commit, push, and publish a GitHub release for the tag.

Optional: drop the token with trusted publishing

npm now supports trusted publishing, which replaces the long-lived NPM_TOKEN. On the package settings page on npmjs.com, add the GitHub repo and workflow file as a trusted publisher. The job then authenticates with the same OIDC token, and npm attaches provenance automatically. This requires a recent npm CLI (11.5 or later), so check the npm trusted publishers docs for the current requirements. I haven't switched color-is-dark over yet.

Verifying it worked

The check is on the package page: scroll to the bottom of color-is-dark on npmjs.com. A Provenance section there links to the source commit, the workflow file and the run that built the version.

From the command line, any project that depends on the package can verify the signatures and attestations:

npm audit signatures
Enter fullscreen mode Exit fullscreen mode

The output counts the packages with verified registry signatures and verified attestations. An attestation that fails to verify is reported as an error.

Gotchas

  • Specify the pnpm version. My first run failed because pnpm/action-setup had no version:. The fix was one commit: "GH actions fix -- specify pnpm version". Alternatively, set a packageManager field in package.json.
  • Provenance only applies from CI. Version 1.0.0 was published from my laptop, so it has no provenance. I republished as 1.0.1 with no code changes, just to get a signed version. The changelog says so.
  • repository.url must match the repo. If you fork or rename the repo, update the field, or the publish is rejected.
  • The workflow installs twice. run_install: true already runs pnpm install, so the separate pnpm i step is redundant. It's harmless, but you can drop one of them.
  • Use npm publish, not pnpm publish, for provenance. The --provenance flag belongs to the npm CLI. Running it through npm while using pnpm for installs works fine.

Wrapping up

The whole setup is one workflow file and one secret, and every release after that carries a verifiable link to its source. For small packages that others pull into their dependency trees, that's worth the ten minutes.

Links:

Top comments (0)