From 4dcfc8e9d3833a5ccac72c97379123b96cb33142 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:40:40 +0700 Subject: [PATCH] ci(release): let the floating-tag job run on a dispatch, and say what it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `float-major-tag` could only run on a tag push, so the resume path RELEASING documents — "part-way through the eight crates: re-run release.yml by dispatch" — skipped it. A resumed release therefore finished with `@vN` still pointing at the previous version, which is the exact failure the job was added to prevent. It now runs on a dispatch too, given the tag it is finishing. A dispatch can be handed anything, so the tag is checked to exist and to look like a release tag before a floating pointer is aimed at it. The other half is the ruleset. `protect-release-tags` covered `refs/tags/v*`, which is both kinds of tag, and they want opposite treatment: `v2.0.0` names a published, immutable set of crates, while `v2` is a pointer whose entire contract is to move. Protecting the pointer against being moved is why the job had never once worked — 2.0.0 was its first run, on a creation, and it was rejected outright. Narrowed to `refs/tags/v*.*.*`. Release tags keep every protection they had; the floating majors fall outside, so the job needs no bypass. The alternative — adding the Actions bot to the bypass actors — would have handed the workflow the ability to delete or rewrite a *release* tag, which is a strictly larger permission than the problem needs. RELEASING now documents both, including that 2.0.0's `v2` was pushed by hand, so the next person does not rediscover it. Closes #30. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .github/workflows/release.yml | 30 +++++++++++++++++++++++++++--- docs/RELEASING.md | 24 ++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59ab4a0..c6d5d51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,15 @@ on: # Manual re-run for resuming a rate-limited publish; skips crates whose # version is already on the registry. workflow_dispatch: + inputs: + tag: + description: >- + The release tag this run is finishing, e.g. v2.0.0. Required to + move the floating major tag; a resumed publish that left `vN` + behind is a release whose documented ref still points at the + previous version, which is the failure `float-major-tag` exists + to prevent. + required: false permissions: contents: read @@ -115,7 +124,13 @@ jobs: # `v1` here cannot re-enter this workflow. float-major-tag: needs: publish - if: github.event_name == 'push' && github.ref_type == 'tag' + # A tag push carries the tag in `ref_name`; a dispatch has to be told. + # Runs for both, because RELEASING documents resuming a rate-limited + # publish by dispatch — and a resumed release that skipped this job is a + # release whose documented `@vN` still points at the previous version. + if: >- + (github.event_name == 'push' && github.ref_type == 'tag') + || (github.event_name == 'workflow_dispatch' && inputs.tag != '') runs-on: ubuntu-latest permissions: contents: write # moves refs/tags/vN @@ -123,11 +138,20 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Point vN at ${{ github.ref_name }} + - name: Point vN at the release tag env: - TAG: ${{ github.ref_name }} + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} run: | set -euo pipefail + # A dispatch can be handed anything, so the tag has to exist and be + # a version before a floating pointer is aimed at it. + git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null || { + echo "::error::no such tag: '$TAG'"; exit 1 + } + case "$TAG" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "::error::'$TAG' is not a release tag (expected vX.Y.Z)"; exit 1 ;; + esac major="v${TAG#v}" major="${major%%.*}" case "$major" in diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6d92627..ff57f3f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -48,6 +48,17 @@ git tag vX.Y.Z && git push origin vX.Y.Z Pushing the tag runs `release.yml`, which gates, then publishes each crate in order via Trusted Publishing. +### The floating major tag + +`release.yml` moves `vN` to each release, so `@v2` follows 2.x without a +manual step. It is covered by the `protect-release-tags` ruleset only up to +`refs/tags/v*.*.*` — release tags are immutable, floating pointers are not, +because being moved is the whole of what a floating pointer is for. + +That distinction was learned the hard way: the ruleset originally covered +`refs/tags/v*`, so the job could not create `v2` at all and 2.0.0's floating +tag had to be pushed by hand. + ## After the tag - **The GitHub Release is created by hand**, from the CHANGELOG section: @@ -73,8 +84,17 @@ order via Trusted Publishing. - **Before publish**: fix, delete the tag (`git push --delete origin vX.Y.Z`), re-tag. Nothing was published; the world never saw it. -- **Part-way through the eight crates**: re-run `release.yml` by dispatch. It - skips what is already on the registry. +- **Part-way through the eight crates**: re-run `release.yml` by dispatch, + passing the tag: + + ```sh + gh workflow run release.yml -f tag=vX.Y.Z + ``` + + It skips what is already on the registry, then moves `vN`. The tag is an + input rather than inferred because a dispatch has no tag of its own, and a + resumed release that skipped the floating tag is a release whose documented + `@vN` still points at the previous version. - **After publish**: crates.io is immutable. Ship `X.Y.Z+1`. Yank only if the release is actively harmful — a yanked crate still breaks downstream lockfiles.