From 365924c664a0972a45cab83a0eae68169e0be4b9 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 30 Jul 2026 10:06:43 -0400 Subject: [PATCH] feat(release): version-bump automation + shared release-readiness gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/bump-version.sh [--pr] turns the whole version-bump chore into one command: validates the version (shape, no downgrade, clean tree), rolls CHANGELOG.md's [Unreleased] notes into a dated `## [X.Y.Z]` section (refusing when no notes were written), stamps every packaging site via version-sync.sh, and with --pr opens the release/vX.Y.Z PR whose body carries the rolled-over notes and the post-merge playbook. .github/workflows/version-bump.yml exposes the same flow as a workflow_dispatch so a bump can be started from the GitHub UI. Documented caveat: PRs opened with GITHUB_TOKEN don't trigger pull_request CI (the same event suppression that shaped release.yml's topology) — close/reopen the PR or push to its branch to kick checks; running the script locally is the preferred path. scripts/release-lint.sh is the single source of truth for the release gate, shared by CI and the Release workflow so the two can't drift: - version coherence: version-sync.sh must be a no-op (catches a hand-edited version in any single packaging site); - CHANGELOG has a non-empty section for the version; - --tag-check: the tag doesn't exist at a commit other than HEAD (same-commit = release retry, allowed). ci.yml gains a release-readiness job: coherence-only on ordinary PRs/pushes, the full gate (incl. tag check) on PRs that change the workspace version. release.yml's version job now calls the same script in place of its inline tag/CHANGELOG checks. The full playbook lives in docs/releasing.md. Note: awk -v escape-processes its values, so the section matchers use plain-string prefix matching instead of -v regexes — a -v pattern like "\[3\.4\.0\]" silently mangles and misreads a populated section as empty (caught by the scratch-clone round-trip test). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 39 +++++++ .github/workflows/release.yml | 46 ++------- .github/workflows/version-bump.yml | 52 ++++++++++ CHANGELOG.md | 17 +++- docs/releasing.md | 65 ++++++++++++ scripts/bump-version.sh | 158 +++++++++++++++++++++++++++++ scripts/release-lint.sh | 145 ++++++++++++++++++++++++++ 7 files changed, 485 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/version-bump.yml create mode 100644 docs/releasing.md create mode 100755 scripts/bump-version.sh create mode 100755 scripts/release-lint.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39c56bab..8e41f5c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,45 @@ jobs: # same surface. shellcheck is pre-installed on ubuntu-latest. run: shellcheck --shell=sh scripts/install.sh + - name: Shell — shellcheck the release scripts + run: shellcheck scripts/version-sync.sh scripts/bump-version.sh scripts/release-lint.sh + + # Release-readiness gate (scripts/release-lint.sh — the same checks the + # Release workflow's `version` job runs before publishing anything): + # - every PR/push: version coherence — version-sync.sh must be a no-op, + # so a hand-edited version in any single packaging site fails CI here + # instead of surfacing mid-release; + # - PRs that bump the workspace version (release/vX.Y.Z bump PRs): the + # full gate — CHANGELOG has a dated, non-empty section for the new + # version and the tag doesn't already exist. + release-readiness: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Lint release readiness + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + # Compare the workspace version against the PR base to detect a + # version bump. The shallow checkout doesn't have the base + # commit; fetch just that object. + git fetch --quiet --depth 1 origin "$BASE_SHA" + BASE_VERSION="$(git show "$BASE_SHA:Cargo.toml" | grep '^version = ' | head -1 | sed 's/version = "\(.*\)"/\1/')" + HEAD_VERSION="$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')" + if [ "$BASE_VERSION" != "$HEAD_VERSION" ]; then + echo "Version bump PR detected ($BASE_VERSION -> $HEAD_VERSION); running the full release gate." + bash scripts/release-lint.sh --tag-check + exit 0 + fi + fi + bash scripts/release-lint.sh --sync-only + test: strategy: fail-fast: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc155aa2..221339df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,42 +64,16 @@ jobs: echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" echo "Release version: $VERSION" - - name: Check tag does not exist at a different commit - env: - VERSION: ${{ steps.read.outputs.VERSION }} - run: | - # The checkout above is shallow and tagless, so local tag lookups - # prove nothing — ask the remote directly (stateless). Fail only - # when the tag exists at a DIFFERENT commit: a tag already at - # $GITHUB_SHA means a previous run of this workflow got as far as - # tagging, and re-running it (idempotent retry) must be allowed. - EXISTING_SHA="$(git ls-remote origin "refs/tags/v${VERSION}" | cut -f1)" - if [ -z "$EXISTING_SHA" ]; then - echo "Tag v${VERSION} does not exist yet." - elif [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "::notice::Tag v${VERSION} already exists at this commit; continuing as a retry of a previous run." - else - echo "::error::Tag v${VERSION} already exists at ${EXISTING_SHA} (this run is at ${GITHUB_SHA}). Bump the version in a PR first." - exit 1 - fi - - - name: Check CHANGELOG.md has release notes for version - env: - VERSION: ${{ steps.read.outputs.VERSION }} - run: | - if [ ! -f CHANGELOG.md ]; then - echo "::error::CHANGELOG.md does not exist at the repository root." - exit 1 - fi - # A release requires an explicit `## [X.Y.Z]` / `## X.Y.Z` heading - # (notes written by hand in the version-bump PR). - if grep -qE "^## \[?${VERSION}\]?( |$)" CHANGELOG.md; then - echo "Found explicit CHANGELOG heading for ${VERSION}." - exit 0 - fi - echo "::error::CHANGELOG.md has no release notes for ${VERSION}." - echo "::error::Add a \`## [${VERSION}] — $(date +%Y-%m-%d)\` heading with release notes before re-running." - exit 1 + - name: Release-readiness gate + # scripts/release-lint.sh is the single source of truth for the + # version chores, shared with CI's release-readiness job (which runs + # it on the version-bump PR, so failures surface at PR time, not + # here). Checks: version coherence (version-sync.sh is a no-op), + # CHANGELOG has a non-empty section for this version, and — via + # --tag-check, asked of the remote since this checkout is shallow + # and tagless — the tag doesn't exist at a different commit (a tag + # already at $GITHUB_SHA is a retry of a previous run and passes). + run: bash scripts/release-lint.sh --tag-check build: needs: version diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 00000000..d2489c0c --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,52 @@ +name: Version Bump + +# Opens the version-bump PR that precedes a release: runs +# scripts/bump-version.sh, which stamps the new version into every packaging +# site (scripts/version-sync.sh), rolls CHANGELOG.md's [Unreleased] notes into +# a dated `## [X.Y.Z]` section, and opens a `release/vX.Y.Z` PR. +# +# CAVEAT — PRs opened by this workflow's GITHUB_TOKEN do NOT trigger +# pull_request CI (GitHub suppresses events caused by that token, the same +# rule that shaped release.yml's one-workflow topology). To get CI on the PR: +# close and reopen it from the UI, or push any commit to the branch. Running +# `scripts/bump-version.sh --pr` from a developer machine avoids +# the problem entirely and is the preferred path; this workflow exists so a +# bump can be started from the GitHub UI alone. See docs/releasing.md. + +on: + workflow_dispatch: + inputs: + version: + description: 'New version (X.Y.Z, no leading v)' + required: true + type: string + +permissions: {} + +jobs: + bump: + runs-on: ubuntu-latest + permissions: + contents: write # push the release/vX.Y.Z branch + pull-requests: write # open the bump PR + steps: + - name: Checkout + # Intentionally persists credentials: bump-version.sh pushes the + # release branch with this workflow's GITHUB_TOKEN. + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Open the bump PR + env: + # Never interpolate the input into the script text (zizmor + # template-injection); bump-version.sh validates it is a plain + # X.Y.Z version before doing anything. + VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + bash scripts/bump-version.sh "$VERSION" --pr + + - name: Remind about the CI caveat + run: | + echo "::notice title=Bump PR opened::pull_request CI does not run on PRs opened with GITHUB_TOKEN — close/reopen the PR (or push to its branch) to trigger the checks before merging." diff --git a/CHANGELOG.md b/CHANGELOG.md index 743858dd..ae097917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ history. For full per-release detail, see the [GitHub releases page](https://github.com/SocketDev/socket-patch/releases). The `Release` workflow refuses to publish a version that does not appear -in this file — see `.github/workflows/release.yml` (`version` job). +in this file — see `scripts/release-lint.sh` (run by the `version` job in +`.github/workflows/release.yml` and by CI on version-bump PRs). Bump PRs +are opened by `scripts/bump-version.sh`, which rolls `[Unreleased]` over +into the new version's section — see docs/releasing.md. ## [Unreleased] @@ -44,6 +47,18 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Added +- **Version-bump automation + release-readiness gate.** + `scripts/bump-version.sh --pr` performs the whole bump chore — + stamps every packaging site via `version-sync.sh`, rolls `[Unreleased]` + into a dated `## [X.Y.Z]` CHANGELOG section, and opens the `release/vX.Y.Z` + PR (also dispatchable from the Actions tab as the **Version Bump** + workflow). A new `release-readiness` CI job runs `scripts/release-lint.sh` + on every PR: version-coherence always (version-sync must be a no-op, so a + hand-edited version in any one packaging site fails CI), plus the full + gate — non-empty CHANGELOG section, no pre-existing tag — on PRs that bump + the workspace version. The `Release` workflow's `version` job now runs the + same script, so the publish gate and the PR gate cannot drift. Playbook: + docs/releasing.md. - **Maven Central and NuGet distribution.** Two new install channels for the CLI. Maven Central: `dev.socket:socket-patch`, a dependency-free launcher jar — run via `java -jar` (fetch it with `mvn dependency:copy`) or in one diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..02313134 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,65 @@ +# Releasing socket-patch + +One release = one version-bump PR + one dispatch of the **Release** workflow. +Every ecosystem package (crates.io, npm, PyPI, RubyGems ×2, Packagist, Maven +Central, NuGet) publishes from that single dispatch. + +## 1. Open the version-bump PR + +From a developer machine (preferred — CI runs on the PR normally): + +```sh +scripts/bump-version.sh 3.4.0 --pr +``` + +This stamps `3.4.0` into every packaging site (`scripts/version-sync.sh`), +rolls `CHANGELOG.md`'s `[Unreleased]` notes into a dated `## [3.4.0]` section +(it refuses to run if `[Unreleased]` is empty — write the notes first), and +opens a `release/v3.4.0` PR whose body carries the rolled-over notes. + +Alternatively, dispatch the **Version Bump** workflow from the Actions tab +(input: the new version). Caveat: a PR opened by a workflow's `GITHUB_TOKEN` +does not trigger `pull_request` CI — close/reopen the PR (or push any commit +to its branch) to kick the checks. + +CI's `release-readiness` job runs the full release gate on the bump PR +(`scripts/release-lint.sh`): version coherence across all packaging sites, +a non-empty CHANGELOG section for the new version, and no pre-existing tag. +On every *other* PR the same job runs the coherence check only, so a +hand-edited version in any single site fails CI immediately. + +## 2. Merge, then dispatch **Release** + +Actions → **Release** → Run workflow (on the default branch). Optionally run +once with `dry-run: true` — that builds all 14 targets but skips tagging and +publishing. + +The real run: re-verifies the release gate → builds the matrix → creates and +pushes `v` → creates the GitHub release with `SHA256SUMS` → fans out +to all registries in parallel (OIDC everywhere except Maven Central, which +has no trusted-publishing option and uses the portal token + GPG key from the +`maven-central` environment). + +## 3. Approve npm (the one manual step) + +The npm job *stages* rather than publishes. Approve with 2FA — **platform +packages first, then `@socketsecurity/socket-patch`** — via the link in the +run's step summary, so optionalDependencies resolution never sees the main +package without its binaries. The launcher channels (gem, composer, maven, +nuget) go live without human action: they fetch binaries from the GitHub +release at run time. + +## If a job fails mid-release + +Fix the cause and use **"Re-run failed jobs"** on the same run. Every job is +idempotent: the tag re-push is a no-op, the GitHub release re-uploads with +`--clobber`, and each registry job probes for an already-published version +and skips it. A partial release never requires deleting tags or re-bumping. + +## One-time registry setup + +Environments, trusted publishers, the `dev.socket` namespace claim, the GPG +key, and the nuget.org policy are listed in the checklist of +[PR #138](https://github.com/SocketDev/socket-patch/pull/138). Until a +registry's credentials exist, its job skips with a `::notice` instead of +failing the release. diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 00000000..061e175b --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# One-command version bump: stamps the new version into every packaging site +# (scripts/version-sync.sh), rolls CHANGELOG.md's [Unreleased] section over +# into a dated `## [X.Y.Z]` heading, and (with --pr) opens the release PR. +# +# The Release workflow refuses to publish until these chores are done (see +# scripts/release-lint.sh, run by CI on the bump PR and again by the `version` +# job in release.yml), so this script is the intended way to start a release: +# +# scripts/bump-version.sh 3.4.0 --pr +# +# or dispatch the "Version Bump" workflow (.github/workflows/version-bump.yml), +# which runs this script on a fresh checkout of main. Running it locally is +# preferred: a PR opened by the workflow's GITHUB_TOKEN does not trigger +# pull_request CI (GitHub suppresses events caused by that token). +# +# Usage: bump-version.sh [--pr] [--base ] +# --pr create branch release/vX.Y.Z, commit, push, open the PR (gh CLI) +# --base PR base branch (default: the repo's default branch) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +VERSION="" +OPEN_PR=false +BASE="" +while [ $# -gt 0 ]; do + case "$1" in + --pr) OPEN_PR=true ;; + --base) + shift + BASE="${1:?--base needs a branch name}" + ;; + -*) + echo "bump-version: unknown flag: $1" >&2 + exit 2 + ;; + *) VERSION="$1" ;; + esac + shift +done +: "${VERSION:?Usage: bump-version.sh [--pr] [--base ]}" + +fail() { + echo "bump-version: error: $*" >&2 + exit 1 +} + +# ── preconditions ──────────────────────────────────────────────────────────── + +printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || fail "'$VERSION' is not a plain X.Y.Z release version" + +CURRENT="$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')" +[ "$VERSION" != "$CURRENT" ] || fail "already at version $CURRENT" +# sort -V puts the higher version last; refuse downgrades so a typo like +# bumping 3.3.0 -> 3.1.0 is caught here rather than at the release gate. +HIGHEST="$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -1)" +[ "$HIGHEST" = "$VERSION" ] || fail "$VERSION is lower than the current version $CURRENT" + +[ -z "$(git status --porcelain)" ] \ + || fail "working tree is not clean — commit or discard changes first" + +grep -qE '^## \[Unreleased\]$' CHANGELOG.md \ + || fail "CHANGELOG.md has no '## [Unreleased]' heading to roll over" +VERSION_RE="$(printf '%s' "$VERSION" | sed 's/\./\\./g')" +! grep -qE "^## \[?${VERSION_RE}\]?( |$)" CHANGELOG.md \ + || fail "CHANGELOG.md already has a section for $VERSION" + +# The release notes come from what accumulated under [Unreleased]; an empty +# section means nobody wrote any, and the release gate would (rightly) refuse +# an empty notes section anyway. +UNRELEASED_LINES="$(awk ' + /^## \[Unreleased\]$/ { in_section = 1; next } + in_section && /^## / { exit } + in_section && NF > 0 { count++ } + END { print count + 0 } +' CHANGELOG.md)" +[ "$UNRELEASED_LINES" -gt 0 ] \ + || fail "CHANGELOG.md's [Unreleased] section is empty — write the release notes first" + +# ── the chores ─────────────────────────────────────────────────────────────── + +TODAY="$(date +%Y-%m-%d)" + +# Roll [Unreleased] over: the accumulated notes become the new version's +# section, and an empty [Unreleased] heading stays on top for the next cycle. +awk -v heading="## [$VERSION] — $TODAY" ' + /^## \[Unreleased\]$/ { + print + print "" + print heading + next + } + { print } +' CHANGELOG.md > CHANGELOG.md.tmp +mv CHANGELOG.md.tmp CHANGELOG.md + +bash scripts/version-sync.sh "$VERSION" + +echo +echo "Bumped $CURRENT -> $VERSION:" +git diff --stat + +# ── the PR ─────────────────────────────────────────────────────────────────── + +if [ "$OPEN_PR" = "false" ]; then + echo + echo "Review the diff, then commit and open the PR (or re-run with --pr)." + exit 0 +fi + +command -v gh >/dev/null || fail "--pr needs the gh CLI" +if [ -z "$BASE" ]; then + BASE="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name)" +fi + +BRANCH="release/v${VERSION}" +git checkout -b "$BRANCH" +git add -A +git commit -m "chore(release): bump version to ${VERSION}" +git push -u origin "$BRANCH" + +# The PR body carries the release notes that just rolled over, plus the +# operator playbook for after the merge. Matched by string prefix, not an +# awk -v regex — awk escape-processes -v values, mangling \[ and \. patterns. +NOTES="$(awk -v ver="$VERSION" ' + !found { + if (index($0, "## [" ver "] ") == 1) found = 1 + next + } + /^## / { exit } + { print } +' CHANGELOG.md)" + +gh pr create --base "$BASE" --title "chore(release): bump version to ${VERSION}" --body "$(cat <` is a no-op — +# every stamped site (npm/pypi/gem/composer/maven/nuget/cargo) already +# carries the workspace version. Catches hand-edited drift in any single +# site. NOTE: this runs version-sync, which refreshes the npm lockfile +# (network); files the sync touches are restored afterwards, so the tree +# is left as found — but the tree must be CLEAN before the check runs. +# 3. CHANGELOG.md has a `## [X.Y.Z]` heading with non-empty release notes +# (skipped with --sync-only). +# 4. With --tag-check: the tag v does not already exist at a commit +# other than HEAD (existing at HEAD is allowed — that is a re-run of a +# release that already tagged; mirrors the Release workflow semantics). +# +# Usage: release-lint.sh [--sync-only] [--tag-check] [] +# defaults to the workspace version in Cargo.toml. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +SYNC_ONLY=false +TAG_CHECK=false +VERSION="" +for arg in "$@"; do + case "$arg" in + --sync-only) SYNC_ONLY=true ;; + --tag-check) TAG_CHECK=true ;; + -*) + echo "release-lint: unknown flag: $arg" >&2 + exit 2 + ;; + *) VERSION="$arg" ;; + esac +done + +FAILED=0 +fail() { + FAILED=1 + # ::error:: annotates the run + PR when under GitHub Actions. + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + echo "::error title=release-lint::$*" + else + echo "release-lint: error: $*" >&2 + fi +} +note() { + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + echo "::notice title=release-lint::$*" + else + echo "release-lint: $*" + fi +} + +# ── 1. version shape + Cargo.toml agreement ───────────────────────────────── + +CARGO_VERSION="$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')" +if [ -z "$VERSION" ]; then + VERSION="$CARGO_VERSION" +fi + +if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + fail "'$VERSION' is not a plain X.Y.Z release version" +fi +if [ "$VERSION" != "$CARGO_VERSION" ]; then + fail "requested version $VERSION != Cargo.toml workspace version $CARGO_VERSION (run scripts/version-sync.sh $VERSION)" +fi + +# ── 2. version coherence: version-sync must be a no-op ────────────────────── + +if [ -n "$(git status --porcelain)" ]; then + fail "working tree is not clean — the coherence check runs version-sync and needs a clean tree to compare against" +else + bash scripts/version-sync.sh "$VERSION" >/dev/null + DRIFTED="$(git status --porcelain | awk '{print $2}')" + if [ -n "$DRIFTED" ]; then + fail "version-sync.sh $VERSION is not a no-op — these files carried a stale version: $(echo "$DRIFTED" | tr '\n' ' ')" + # The tree was clean before the sync, so restoring exactly the files the + # sync touched leaves it as found. + echo "$DRIFTED" | xargs git checkout -- + else + note "version coherence OK: every stamped site already carries $VERSION" + fi +fi + +# ── 3. CHANGELOG heading + non-empty notes ────────────────────────────────── + +if [ "$SYNC_ONLY" = "false" ]; then + VERSION_RE="$(printf '%s' "$VERSION" | sed 's/\./\\./g')" + # Accept `## [X.Y.Z] — date` and the bracketless `## X.Y.Z` variant, the + # same shapes the Release workflow historically accepted. + if ! grep -qE "^## \[?${VERSION_RE}\]?( |$)" CHANGELOG.md; then + fail "CHANGELOG.md has no '## [$VERSION]' heading — roll [Unreleased] over with scripts/bump-version.sh $VERSION (or write the section by hand)" + else + # Non-empty: at least one non-blank line between the heading and the next + # `## ` heading (or EOF). The heading is matched by string prefix, not an + # awk -v regex — awk applies escape processing to -v values, which + # silently mangles \[ and \. into a wrong pattern. + BODY_LINES="$(awk -v ver="$VERSION" ' + !found { + if ($0 == "## [" ver "]" || index($0, "## [" ver "] ") == 1 || + $0 == "## " ver || index($0, "## " ver " ") == 1) { + found = 1 + } + next + } + /^## / { exit } + NF > 0 { count++ } + END { print count + 0 } + ' CHANGELOG.md)" + if [ "$BODY_LINES" -eq 0 ]; then + fail "CHANGELOG.md's [$VERSION] section is empty — a release needs written notes" + else + note "CHANGELOG OK: [$VERSION] section present with $BODY_LINES lines of notes" + fi + fi +fi + +# ── 4. tag collision (opt-in: needs the remote) ───────────────────────────── + +if [ "$TAG_CHECK" = "true" ]; then + # HEAD is the release commit in the Release workflow (GITHUB_SHA) and the + # PR merge commit in CI; in both cases an existing tag at any OTHER commit + # means this version was already released from different code. + EXISTING_SHA="$(git ls-remote origin "refs/tags/v${VERSION}" | cut -f1)" + HEAD_SHA="$(git rev-parse HEAD)" + if [ -z "$EXISTING_SHA" ]; then + note "tag v${VERSION} does not exist yet" + elif [ "$EXISTING_SHA" = "$HEAD_SHA" ]; then + note "tag v${VERSION} already points at HEAD — a retry of a previous release run" + else + fail "tag v${VERSION} already exists at ${EXISTING_SHA} (HEAD is ${HEAD_SHA}) — bump to a new version" + fi +fi + +if [ "$FAILED" -ne 0 ]; then + exit 1 +fi +note "all checks passed for $VERSION"