All posts
·
10 min read

A Release Pipeline With No Long-lived Tokens and No Silent Tampering

  • Supply Chain Security
  • npm
  • GitHub Actions
  • CI/CD
  • Release Engineering

A published package is an attack surface you hand to everyone who installs it. If someone can push to your npm package or edit a GitHub release after the fact, they can ship code to your users under your name. Most release pipelines defend that surface with a single long-lived NPM_TOKEN sitting in CI secrets, which is exactly the thing an attacker wants to steal.

I recently set up releases for the Hookie CLI (@hookie-sh/cli on npm) and used three controls that each close a different gap:

  • OIDC trusted publishing removes the long-lived npm token from CI entirely.
  • Staged npm publishing keeps a human 2FA gate in front of every release, even when CI does the upload.
  • Immutable GitHub releases stop a published tag or asset from being changed after it ships.

None of these are hard to enable. The value is that they are independent: OIDC protects the credential, staging protects the moment of release, and immutability protects the artifact after release. This post is how they fit together and where the sharp edges are.

note

This is not a GoReleaser or full CI tutorial, and it is not a deep dive on provenance or SBOMs. It assumes GitHub Actions and npm. The Hookie CLI is a Go binary distributed through a thin npm wrapper, so the pipeline builds with GoReleaser and then publishes the wrapper to npm; here I focus only on the three security controls.

The classic npm publish flow puts an automation token in CI secrets and runs npm publish. That token is long-lived, broadly scoped, and readable by any workflow that can reach the secret. A leaked token, a malicious dependency in the release job, or a compromised third-party action is enough to publish as you. Rotating tokens helps, but the token still exists, and existence is the problem.

Trusted publishing replaces the token with short-lived OIDC credentials. GitHub Actions requests an identity token, npm validates its claims (repository, workflow file, and optionally environment) against a trusted publisher you configured, and issues a one-time publish credential. Nothing long-lived is stored, and the credential only works from the exact workflow you named.

Setup has two halves. First, on npm, open the package settings under Access and add a trusted publisher for GitHub Actions. You enter the organization or user, the repository, and the workflow filename only (release.yml, not the full path). Since May 2026, you also pick which actions the publisher is allowed to perform: npm publish, npm stage publish, or both. I allow stage publish only, which means a direct npm publish from CI is rejected outright.

npm trusted publisher configuration for GitHub Actions

While you are there, set the package publishing access to require two-factor authentication and disallow tokens. This closes the escape hatch: even if an old automation token exists somewhere, it can no longer publish this package.

npm publishing access set to require 2FA and disallow tokens

Second, on the workflow, grant the OIDC permission. Without id-token: write, GitHub Actions cannot mint the token and npm falls back to nothing.

.github/workflows/release.yml
yaml
permissions: contents: write # create the GitHub release id-token: write # mint the OIDC token for npm trusted publishing

There is no NPM_TOKEN anywhere in the workflow. The npm CLI detects the OIDC environment automatically and also emits provenance attestations by default when publishing this way.

warning

Trusted publishing only works on GitHub-hosted runners, and each package supports one trusted publisher configuration at a time. The organization, repository, and workflow filename are matched exactly and are case-sensitive; a mismatch fails with an authentication error, not a helpful message.

CI should stage, humans should approve

OIDC solves the credential problem but creates a subtle gap. If CI can publish with no human in the loop, then anything that can trigger CI can ship a release. A pushed tag, a compromised action, or a bad merge becomes a live version on the registry with no second check.

Staged publishing puts that check back. Instead of npm publish, CI runs npm stage publish, which uploads the tarball to a staging queue where it is not installable. The package sits there until a maintainer approves it with a 2FA challenge, from either the CLI or npmjs.com. The design decouples publishing from proof-of-presence: CI does the mechanical upload without needing a secret or a 2FA prompt, and the human provides the 2FA only at approval.

The command surface is small. Two commands you run in CI or to inspect, and a set for review and decision:

CommandRequires 2FAPurpose
npm stage publishNoUpload a version to the staging queue (used in CI)
npm stage listNoList staged versions you can act on
npm stage viewNoInspect a staged version's details
npm stage downloadNoDownload the staged tarball for local inspection
npm stage approveYesPublish the staged version to the registry
npm stage rejectYesPermanently discard the staged version

In the Hookie pipeline, CI stages the wrapper and the release summary tells the maintainer how to finish it:

release.yml (staging step, trimmed)
bash
PKG="@hookie-sh/cli@${VERSION}" # Idempotent: skip if already published or already staged. npm view "$PKG" version >/dev/null 2>&1 && exit 0 npm stage view "$PKG" >/dev/null 2>&1 && exit 0 npm stage publish --access public echo "Staged $PKG. A maintainer must approve with 2FA before it is installable."

Approval is one command with a 2FA prompt, or a click in the Staged Packages tab on npmjs.com:

bash
npm stage approve @hookie-sh/cli@0.1.0

The gap between staging and approving is also a real testing window. For Hookie I download or install the staged build against a throwaway project before approving, so the 2FA gate is not just a rubber stamp; it is the last point where I can reject a broken build with npm stage reject and nothing ever reaches consumers.

important

Staged publishing requires npm CLI 11.15.0 or later and Node 22.14.0 or later. It also cannot stage a brand-new package: the name must already exist on the registry, so your very first release still goes through a normal publish. And the version tag chosen at staging is immutable; changing it means rejecting and staging again.

A published release should not change

The npm side is now covered, but the GitHub release is a separate artifact with its own risk. By default, a maintainer (or an attacker with write access) can move a tag to a different commit, overwrite an attached binary, or delete and recreate a release. For a CLI where the GitHub release carries the actual binaries, that is a direct path to shipping a swapped artifact under a version people already trust.

Immutable releases, generally available since October 2025, close that. Once enabled, a published release locks its tag to a specific commit and protects its assets from modification or deletion. The tag cannot be moved or reused, and the binaries attached to it cannot be swapped. It even guards against repository resurrection: if you delete the repo and recreate it with the same name, tags tied to old immutable releases cannot be reused.

Enable it under Settings → General → Releases → Enable release immutability (there is an equivalent organization-level policy). One caveat that matters: immutability applies only to releases created after you enable it. Existing releases stay mutable unless you republish them.

GitHub release immutability setting enabled in repository settings

How the three controls fit together

The pipeline triggers on a pushed v* tag. Tests run first; nothing publishes if they fail. Then a single release job builds the binaries, creates the immutable GitHub release, and stages the npm wrapper. Approval happens out of band, by a human.

The permissions are the whole security story in five lines:

.github/workflows/release.yml
yaml
on: push: tags: - 'v*' permissions: contents: write # create the immutable GitHub release id-token: write # OIDC for npm trusted publishing

I also made the job idempotent, because a security control you cannot safely retry gets disabled the first time it blocks a release at 2am. If the GitHub release already exists, GoReleaser is skipped. If the npm version is already published or already staged, the staging step exits cleanly. Re-running a failed workflow on the same tag does the right thing instead of erroring or double-publishing.

Where the sharp edges are

These controls are cheap, but they are not free of friction, and it is worth naming the tradeoffs before you copy the setup.

  • The first publish is manual. You cannot stage a package that does not exist yet, so the initial version goes through a normal publish before staged publishing applies.
  • Releases are no longer fully hands-off. By design, someone has to approve with 2FA. That is the point, but it means "push a tag and walk away" no longer ends with a live package.
  • Tooling version floors are real. Staged publishing needs npm 11.15.0+ and Node 22.14.0+. I added a guard in CI that fails fast if the runner's npm is too old, because the failure mode otherwise is confusing.
  • Immutability is forward-only. Enabling it does not retroactively protect existing releases, and a mistaken immutable release cannot be edited; you publish a new version instead.
  • OIDC constraints are strict. GitHub-hosted runners only, one trusted publisher per package, and exact case-sensitive matching on org, repo, and workflow filename.

For a widely installed package, that friction is the correct trade. The credential never lives in CI, every release passes a human 2FA check, and a shipped artifact cannot be quietly rewritten. Each control fails independently, so losing one does not collapse the others.


  • Supply Chain Security
  • npm
  • GitHub Actions
  • CI/CD
  • Release Engineering
Made with ❤️ in 🇨🇦 · Copyright © 2026 Valentin Prugnaud
Foxy seeing you here!
Wondering if I'd fit your role?
Logo