Files
pnpm/RELEASING.md
T
Zoltan Kochan ea10c041a5 feat(stage): approve staged packages interactively with one OTP (#14140)
`pnpm stage approve` accepted exactly one stage id and wrapped each request in
its own `withOtpHandling`, so releasing a workspace meant one invocation and one
proof of presence per package, in an order the releaser had to work out by hand.

It now takes a list of stage ids, and with none it lists the staged versions and
offers them in a checkbox prompt (the non-interactive case keeps failing with
STAGE_ID_REQUIRED, now with a hint).

The batch runs through a new `OtpSession` in the web-auth package: it keeps the
one-time password a challenge yielded and passes it to every later request,
re-prompting only once the registry stops accepting it — a classic TOTP expires
within a minute, which is exactly the case a whole-workspace approval hits.
`withOtpHandling` is now the single-operation form of that session, so publish
and reject behave as before.

Inside a workspace the selection is sorted through the workspace dependency
graph (the same sequencer the recursive commands use), keyed on the name each
project publishes under, since that is the only name a staged version carries. A
package whose workspace dependency could not be approved is skipped rather than
published against a dependency that never reached the registry. A registry
verdict on one version leaves the rest of the batch running and ends with a
non-zero exit; an authentication failure or a broken connection aborts it.

The registry's description of a staged version decides what a maintainer
publishes, so its id and package name are validated as they came — a hidden
character can neither be stripped into a valid value nor keep a name from
matching a workspace package — and the display-only fields are stripped of the
control characters that could redraw the picker around a selection. The shared
`@pnpm/text.sanitize` package holds that character set for both the staged
picker and `update --interactive`, mirroring the `pnpm-text-sanitize` crate.

Both stacks implement this, down to the partial-batch summary and exit code —
pacquet has no output-with-exit-code channel, so it prints the summary the way
the dispatcher prints command output and exits 1.
2026-08-25 00:10:33 +02:00

8.2 KiB

Releasing

Version tags are created by a maintainer, on their own machine, as signed annotated tags. They are deliberately not created by CI: the tag signature is the one link in the release chain that does not depend on trusting GitHub's infrastructure, so the key that makes it must never live in Actions secrets. See #13578.

Steps

  1. Run the Create release PR workflow (create-release-pr.yml) for the target branch. It bumps versions, consumes the pending changesets, and opens a release-pr/<base> PR.

  2. Review and merge that PR.

  3. Fetch the merge commit and tag each product whose version changed. Tag names must match what release.yml expects — v<version> for the pnpm CLIs, pnpr@<version> for pnpr:

    Product Manifest Tag prefix
    pnpm (TypeScript) pnpm11/pnpm/package.json v
    pacquet (Rust) pnpm/npm/pnpm/package.json v
    pnpr pnpr/npm/pnpr/package.json pnpr@
    git fetch origin
    SHA=<merge-commit-sha>          # the commit the release PR merged as
    git checkout "$SHA"
    
    # Read each version from the manifest at that exact commit, and tag that
    # commit explicitly. Always pass the target commit: a bare `git tag -s <name>`
    # signs whatever HEAD happens to be, which is how a version tag ends up
    # naming code that was never reviewed as part of this release.
    tags=(
      "v$(git show "$SHA:pnpm11/pnpm/package.json" | jq -r .version)"
      "v$(git show "$SHA:pnpm/npm/pnpm/package.json" | jq -r .version)"
      "pnpr@$(git show "$SHA:pnpr/npm/pnpr/package.json" | jq -r .version)"
    )
    
    # Keep only the products whose version actually changed this release —
    # drop the rest from the array before tagging.
    for tag in "${tags[@]}"; do
      git tag -s "$tag" -m "$tag" "$SHA"
    done
    

    -s is what makes the tag verifiable; a lightweight tag (plain git tag <name>) has no object to carry a signature, and git verify-tag fails on it with cannot verify a non-tag object of type commit.

  4. Verify every tag you just created before pushing — a multi-product release must not leave one of its tags unchecked, and a pnpr-only release has no v<version> tag at all. release.yml rejects an unsigned tag anyway, but catching it here saves a failed run:

    for tag in "${tags[@]}"; do git verify-tag "$tag"; done
    
  5. Push. Each tag push starts a release.yml run for the product it names, and several tags pushed together release in parallel:

    git push origin "${tags[@]}"
    
  6. After the workflow finishes, approve the staged npm packages. The TypeScript pnpm release stages @pnpm/exe and then pnpm. The Rust pnpm release stages its native packages, then its @pnpm/napi and @pnpm/exe wrappers, and finally pnpm. Approve them from a maintainer's machine, from a checkout of this repository so the staged packages are approved in workspace dependency order:

    pnpm stage approve
    

    The command lists every staged version, approves the ones selected in the picker, and stops before publishing a package whose workspace dependency failed to be approved. Passing the stage IDs from the completed job's summary (pnpm stage approve <stage-id> ...) approves that set instead.

    Approval requires interactive 2FA, once for the whole selection — pnpm asks for another one-time password only when the registry stops accepting the one it holds. The npm trusted publishers for pnpm, @pnpm/exe, @pnpm/napi, and their platform packages must allow staged publishing only, so CI can stage a release but cannot approve or publish it directly.

Reruns and partial releases

release.yml's plan job asks npm whether each product's gate package is already published — the one that product's publish job publishes last (pnpm for both CLIs, @pnpm/pnpr for pnpr) — and skips the product entirely if it is. A release that ran to completion is therefore a no-op on rerun.

When a run fails and the fix is a code change, commit the fix, move the tag to the new commit and push it again:

TAG=v<version>        # or pnpr@<version> — use the tag of the product that failed
FIXED=<sha-of-the-fix-commit>

git push origin ":refs/tags/$TAG"     # drop the remote tag
git tag -d "$TAG"
git tag -s "$TAG" -m "$TAG" "$FIXED"  # re-sign, at the fixed commit explicitly
git verify-tag "$TAG"
git push origin "$TAG"

Pass $FIXED rather than letting git tag -s default to HEAD: the tag is what release.yml builds and publishes from, so a tag that accidentally names the wrong commit ships unreviewed code under a released version number.

Re-creating the tag this way changes the ref, which fires a push event and starts a fresh run. The tag must be re-signed — git tag -f without -s would quietly replace a signed tag with an unsigned one, which release.yml now rejects outright.

Pushing a tag that is already on the remote unchanged is a different matter: it is a no-op, fires no event, and starts no run. To rerun the same commit without moving the tag, re-run the failed run from the Actions UI, or dispatch the workflow against the release tag itselfvalidate-release-ref rejects a dispatch from a branch, so dispatching from the default branch publishes nothing.

Either way, because the gate package is published last, a run that failed partway leaves the gate unpublished, so the plan job picks the product up again. But the packages that did publish before the failure are immutable on npm and cannot be republished at the same version — read the failed run's logs to see how far it got. If the fix has to change something already published, the release needs a new version rather than a retry.

If a run stopped after staging a package but before it became public, first approve or reject that pending stage from a maintainer's machine. CI cannot inspect or remove it with its stage-only OIDC permission, and trying to stage the same package version again will fail. The stage ID remains available in the stopped run's log and job summary.

Moving a tag is only safe while the release is still failing. Once a release has completed and been announced, the tag is what downstream packagers verify and pin, so it must never be moved.

How the signature is enforced

The signed-tag rule is not a convention. release.yml's verify-release-tag job imports the public keys committed under .github/release-keys/ into a throwaway keyring and runs git verify-tag on the pushed tag. Every publish path descends from plan, and plan descends from that job, so a tag that is lightweight, annotated but unsigned, or signed by any key outside that directory cannot reach a publish step.

This matters because removing the workflow that used to create tags stops CI from making unsigned tags, but on its own would not stop a compromised workflow or a token with tag scope from pushing one and triggering a release.

To add or rotate a maintainer, commit their armored public key to .github/release-keys/ in a reviewed PR. That directory is the release trust root: anyone whose key lands there can cut a release.

Signing setup

A maintainer cutting releases needs a PGP key configured for git and registered with GitHub:

git config --global user.signingkey <fingerprint>
git config --global tag.gpgsign true      # sign tags by default
git config --global commit.gpgsign true   # sign commits by default

# Register the public key so GitHub shows commits and tags as Verified.
gpg --armor --export <fingerprint> | gh gpg-key add -

The key's user ID must carry the same email as user.email, and that address must be verified on the GitHub account — otherwise the signature is valid but GitHub still renders it Unverified.

Known gap

The release commit itself is created by create-release-pr.yml and merged through GitHub, so it carries GitHub's web-flow signature rather than a maintainer's. The signed tag covers the tree, but the full commit chain is not yet maintainer-signed end to end.