RELEASING.md
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.
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.
Review and merge that PR.
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.
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
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[@]}"
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 itself — validate-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.
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.
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.
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.
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.