ci: auto-tag released versions when the release PR merges (#13004)

* ci: auto-tag released versions when the release PR merges

The release tag was pushed by hand after merging a release PR, which let
the tag name drift from the version committed on the merge commit. When
they disagree, release.yml's plan job publishes the committed version but
github-release-rust drafts a GitHub release for `v<committed-version>` — a
tag that doesn't exist as a git ref — and the create-release call returns
403 "Resource not accessible by integration" even with contents: write.

Add tag-release.yml: on a merged `release-pr/*` PR it derives each
product's tag from the committed manifest (the same manifests plan reads),
skips versions already tagged on the remote, and pushes the new tags with
UPDATE_LOCKFILE_TOKEN so the push triggers release.yml (a tag pushed with
the default GITHUB_TOKEN would not). Deriving the tag from the committed
version makes the off-by-one impossible, and the tag exists as a real ref
before release.yml runs, so the draft-release step no longer 403s.

A release PR can bump several products at once, landing several tags and
starting a release run apiece; add a `release` concurrency group so they
serialize and plan's already-published gate makes the later runs no-ops.

Drop the now-obsolete "push the version tag after merging" line from the
release PR body.

* ci: harden the auto-tag workflow per review

- Gate on create-release-pr.yml's exact contract (same-repo head branch
  named `release-pr/<base>`) instead of a `release-pr/` prefix, so a fork
  PR or an unrelated same-prefixed branch can't reach the PAT tag push.
- `set -euo pipefail` so a failed `git ls-remote` aborts instead of
  yielding an empty tag set that makes every version look new.
- `git ls-remote --refs` to drop annotated tags' peeled `^{}` entries.
- Reword the header comment to state the current contract rather than
  what it replaces.

* ci: tag pnpr in its own namespace to avoid pnpm tag collisions

pnpr's version starts at 0.x, and pnpm's tag history already holds 166
v0.x tags (v0.1.0, v0.2.0, …) plus the v1.x line. Tagging pnpr as
`v<version>` shares that namespace: once pnpr cuts a stable 0.x that
matches an old pnpm tag, the existence check would treat the ancient
pnpm tag as pnpr's and silently skip it, so pnpr would never get its
trigger tag or release.

Give pnpr its own `pnpr-v<version>` tag (it has no GitHub release and no
external consumer of the tag — it ships via npm and GHCR — so it's free
to move) and trigger/validate release.yml on `pnpr-v*.*.*` alongside
`v*.*.*`. plan reads committed manifests regardless of the triggering
tag, so a pnpr-only release still publishes correctly.

* ci: adopt the Changesets-style pnpr tag scheme

Use the widely-recognized `pnpr@<version>` form (the Changesets/Lerna
convention) for pnpr's release tag instead of the ad hoc `pnpr-v<version>`
from the previous commit. release.yml triggers and validates
`pnpr@*.*.*` alongside `v*.*.*`; pnpm (v11.x) and pacquet (v12.x) keep
`v<version>`, which get.pnpm.io's install scripts depend on.
This commit is contained in:
Zoltan Kochan
2026-07-15 11:39:52 +02:00
committed by GitHub
parent 7f57469f71
commit 5b696ae2c3
3 changed files with 110 additions and 2 deletions

View File

@@ -177,7 +177,7 @@ jobs:
--title "chore(release): ${VERSION}" \
--body "Automated release PR created by the create-release-pr workflow.
Releasing \`${TARGET}\`: ${VERSION}. Merging this PR consumes the pending changesets and records them in the committed \`.changeset/ledger.yaml\`. After merging, push the version tag of a released product to run the release workflow." \
Releasing \`${TARGET}\`: ${VERSION}. Merging this PR consumes the pending changesets and records them in the committed \`.changeset/ledger.yaml\`, then auto-tags each released product (tag-release.yml) to run the release workflow." \
--base "$TARGET" \
--head "$BRANCH"
fi

View File

@@ -11,12 +11,27 @@ name: Release
on:
push:
tags:
# pnpm (TypeScript, v11.x) and pacquet (v12.x) share the `v<version>`
# namespace that get.pnpm.io's install scripts fetch by. pnpr gets its own
# Changesets-style `pnpr@<version>` namespace: pnpm's tag history includes
# v0.x/v1.x that pnpr's low version numbers would otherwise collide with.
- "v*.*.*"
- "pnpr@*.*.*"
# Manual trigger for rerunning a tag release. Dispatching this workflow from a
# branch fails before publishing because release artifacts are only built on
# version tags.
workflow_dispatch:
# A release PR that bumps several products auto-tags each one (tag-release.yml),
# so several version tags can land together and start a run apiece. Serialize
# them: the plan job gates every publish on what npm reports as unpublished, so
# once the first run publishes a product the rest skip it — but only if they
# don't run concurrently and all read "unpublished" at once. Never cancel a
# run mid-publish.
concurrency:
group: release
cancel-in-progress: false
jobs:
validate-release-ref:
runs-on: ubuntu-latest
@@ -26,8 +41,9 @@ jobs:
run: |
case "${GITHUB_REF}" in
refs/tags/v*.*.*) ;;
refs/tags/pnpr@*.*.*) ;;
*)
echo "::error::The release workflow must run from a version tag like v11.9.0. Current ref: ${GITHUB_REF}"
echo "::error::The release workflow must run from a version tag like v11.9.0 or pnpr@0.2.0. Current ref: ${GITHUB_REF}"
exit 1
;;
esac

92
.github/workflows/tag-release.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
name: Tag Release
# When a release PR (opened by create-release-pr.yml) is merged, derive each
# product's version tag from the version committed on the merge commit and push
# it. Deriving the tag from the committed version keeps the tag name in lockstep
# with what release.yml publishes: a tag naming a version that isn't on the
# merge commit would draft a GitHub release for a tag that doesn't exist, which
# the release workflow rejects.
on:
pull_request:
types:
- closed
# The checkout only needs read; the tags are pushed with a PAT (below) so the
# push triggers release.yml — a tag pushed with the default GITHUB_TOKEN would
# not, by GitHub's workflow-recursion guard.
permissions:
contents: read
jobs:
tag-release:
name: Push version tags for the merged release
# Only merged release PRs on the main repository. Match create-release-pr.yml's
# exact contract — a same-repo head branch named `release-pr/<base>` — so a
# fork PR or an unrelated branch that merely starts with `release-pr/` can't
# reach the tag push.
if: >-
github.repository == 'pnpm/pnpm' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.head.ref == format('release-pr/{0}', github.event.pull_request.base.ref)
runs-on: ubuntu-latest
steps:
- name: Checkout the merge commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false
- name: Push version tags for released products
# The manifests read here are exactly the ones release.yml's plan job
# reads to decide what to publish, so each `v<version>` tag matches the
# version its product will publish. A tag that already exists (a product
# whose version didn't change this release) is skipped, so only the
# freshly bumped products get tagged — and release.yml still gates the
# actual publish on what npm reports as unpublished.
env:
GH_TOKEN: ${{ secrets.UPDATE_LOCKFILE_TOKEN }}
SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
set -euo pipefail
REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
# Tags already on the remote, so a re-run or an unchanged product can
# never clobber or repush an existing version tag. `seen` is a
# space-padded set for the ` $tag ` membership test; it also collects
# the tags staged this run so a shared version can't be pushed twice.
# `--refs` drops the peeled `^{}` entries of annotated tags. pipefail
# (above) makes a failed ls-remote abort instead of yielding an empty
# set that would make every version look new.
seen=" $(git ls-remote --tags --refs "$REMOTE" | awk '{ print $2 }' | sed 's#refs/tags/##' | tr '\n' ' ') "
to_push=()
add_tag() {
local manifest="$1" prefix="$2" tag
tag="${prefix}$(jq -r .version "$manifest")"
case "$seen" in
*" $tag "*)
echo "$tag already exists; skipping"
return
;;
esac
git tag "$tag" "$SHA"
to_push+=("$tag")
seen="$seen$tag "
}
# pnpm (TypeScript, v11.x) and pacquet (v12.x) share the `v<version>`
# namespace get.pnpm.io fetches by. pnpr uses the Changesets-style
# `pnpr@<version>` so its low version numbers don't collide with pnpm's
# v0.x/v1.x tag history — release.yml triggers on `pnpr@*.*.*` too.
add_tag pnpm11/pnpm/package.json 'v'
add_tag pnpm/npm/pnpm/package.json 'v'
add_tag pnpr/npm/pnpr/package.json 'pnpr@'
if [ ${#to_push[@]} -eq 0 ]; then
echo "No new version tags to push"
exit 0
fi
echo "Pushing: ${to_push[*]}"
git push "$REMOTE" "${to_push[@]}"