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[@]}"