From 1f8ffd82823b25737f507fa0a6da3a2e4f3ae1f8 Mon Sep 17 00:00:00 2001 From: project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:12:52 -0500 Subject: [PATCH 1/4] ci: add release workflow with floating major tag --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fce42c4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create release + if: ${{ contains(github.ref_name, '.') }} + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + + move-major-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Move major tag to latest release + run: | + if [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + TAG="${GITHUB_REF#refs/tags/}" + MAJOR="${TAG%.*.*}" + git tag -f "$MAJOR" "$TAG" + git push origin "$MAJOR" --force + fi From f67b3e8529996114c2471bffc2ef727de34bba1a Mon Sep 17 00:00:00 2001 From: project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:14:33 -0500 Subject: [PATCH 2/4] Update release.yml bump actions to latest --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fce42c4..25e18ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,18 +12,18 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Create release if: ${{ contains(github.ref_name, '.') }} - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: generate_release_notes: true move-major-tag: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Move major tag to latest release run: | From f11a0fc58413335e59e0537a706f92b507c6606b Mon Sep 17 00:00:00 2001 From: project516 <138796702+Project516@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:17:27 -0500 Subject: [PATCH 3/4] ci: drop auto-release job; releases are manual --- .github/workflows/release.yml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e18ab..45e0fea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Move major tag on: push: @@ -9,17 +9,6 @@ permissions: contents: write jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Create release - if: ${{ contains(github.ref_name, '.') }} - uses: softprops/action-gh-release@v3 - with: - generate_release_notes: true - move-major-tag: runs-on: ubuntu-latest steps: From 3c5644cd152bf96d7a88de58d06ce25d048992b8 Mon Sep 17 00:00:00 2001 From: project516 <138796702+Project516@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:21:55 -0500 Subject: [PATCH 4/4] ci: harden the major-tag move against the review findings Five things CodeRabbit flagged, all real: Least privilege. The workflow granted contents: write to everything; now nothing gets it by default and only the one job that pushes a tag asks for it. No walking backward. The job moved the major onto whatever tag fired it, so re-pushing an old v1.0.0 would have dragged v1 back off v1.0.1. It now picks the greatest stable tag in the series and does nothing if that is not the tag that triggered the run. No leading zeroes. v01.0.1 matched the old pattern and would have produced a v01 major tag. Serialized. Two tag pushes can no longer both decide where the same major belongs. Pinned. actions/checkout rides a SHA rather than the mutable v7. --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45e0fea..c405161 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,20 +5,54 @@ on: tags: - 'v*' -permissions: - contents: write +# One run at a time, so two tag pushes cannot both decide where a major tag +# belongs. Queued rather than cancelled: every push should get its turn. +concurrency: + group: move-major-tag + cancel-in-progress: false + +permissions: {} jobs: move-major-tag: runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - - name: Move major tag to latest release + - name: Move the major tag to the newest stable release in that major + env: + TAG: ${{ github.ref_name }} run: | - if [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - TAG="${GITHUB_REF#refs/tags/}" - MAJOR="${TAG%.*.*}" - git tag -f "$MAJOR" "$TAG" - git push origin "$MAJOR" --force + set -euo pipefail + + # Strict semver only: no leading zeroes, no pre-release suffix, so a + # v1.0.1-rc.1 push cannot put v1 on a release candidate. + if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "$TAG is not a stable vX.Y.Z tag; leaving the major tag alone" + exit 0 fi + + MAJOR="v${BASH_REMATCH[1]}" + + # Point the major at the greatest stable tag in the series rather than + # at whatever tag triggered this run. Re-pushing an old tag would + # otherwise walk the major backward onto a version consumers have + # already moved off. + git fetch --force --tags origin + NEWEST="$(git tag --list "${MAJOR}.*" \ + | grep -E "^${MAJOR}\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$" \ + | sort -V \ + | tail -n 1)" + + if [[ "$NEWEST" != "$TAG" ]]; then + echo "$NEWEST is newer than $TAG, so $MAJOR stays where it is" + exit 0 + fi + + git tag -f "$MAJOR" "$TAG" + git push --force origin "refs/tags/$MAJOR" + echo "$MAJOR now points at $TAG"