From 5b696ae2c3cd43e2de87507fb7580b76a4c488d4 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 15 Jul 2026 11:39:52 +0200 Subject: [PATCH] ci: auto-tag released versions when the release PR merges (#13004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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` — 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/`) 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` 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` 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@` form (the Changesets/Lerna convention) for pnpr's release tag instead of the ad hoc `pnpr-v` from the previous commit. release.yml triggers and validates `pnpr@*.*.*` alongside `v*.*.*`; pnpm (v11.x) and pacquet (v12.x) keep `v`, which get.pnpm.io's install scripts depend on. --- .github/workflows/create-release-pr.yml | 2 +- .github/workflows/release.yml | 18 ++++- .github/workflows/tag-release.yml | 92 +++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tag-release.yml diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index e7b64d2309..e8f290d14c 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1fe50bf7e..aedcfd5f42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,27 @@ name: Release on: push: tags: + # pnpm (TypeScript, v11.x) and pacquet (v12.x) share the `v` + # namespace that get.pnpm.io's install scripts fetch by. pnpr gets its own + # Changesets-style `pnpr@` 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 diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000000..54fbdbead8 --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -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/` — 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` 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` + # namespace get.pnpm.io fetches by. pnpr uses the Changesets-style + # `pnpr@` 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[@]}"