From 2efcd62f1442e3639e3484c669ab547def1a6147 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 31 Jul 2026 11:14:51 -0500 Subject: [PATCH 1/4] Release tooling: add guarded candidate tags --- .github/workflows/release.yml | 37 +++++++-- RELEASING.md | 51 ++++++++++++ script/release | 150 ++++++++++++++++++++++++++++++++++ script/release-test | 48 +++++++++++ 4 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 RELEASING.md create mode 100755 script/release create mode 100755 script/release-test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62920f5..c470e9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,46 @@ # This workflow is managed by gh actions-lock. -name: release +name: Release + on: push: tags: - - "v*" + - v* permissions: - contents: write - id-token: write - attestations: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - release: + publish: + name: Publish + permissions: + contents: write + id-token: write + attestations: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Verify tag + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$ ]]; then + echo "::error::Invalid release tag '$GITHUB_REF_NAME'; expected vX.Y.Z or vX.Y.Z-rc.N." + exit 1 + fi + commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")" + remote_commit="$(git ls-remote --tags origin "refs/tags/${GITHUB_REF_NAME}^{}" | awk 'NR == 1 { print $1 }')" + if [ "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" != "tag" ] || [ "$remote_commit" != "$commit" ]; then + echo "::error::Release tag '$GITHUB_REF_NAME' is not an annotated remote tag." + exit 1 + fi + - uses: cli/gh-extension-precompile@v2.1.0 with: generate_attestations: true diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..d1031dc --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,51 @@ +# Releasing `gh-actions-lock` + +## Cutting a release + +From a clean, current `main`, preview the next release: + +```sh +RELEASE_DRY_RUN=1 script/release patch +``` + +Then cut it: + +```sh +script/release patch +``` + +To cut a release candidate instead: + +```sh +script/release patch --rc +``` + +The first candidate is `vX.Y.Z-rc.1`; repeating the same bump increments +`rc.N`. Run the bump without `--rc` to publish the stable `vX.Y.Z`. + +Use `patch` for compatible fixes, `minor` for compatible additions, and `major` +for breaking changes. + +The script validates the repository, checks the current branch and `origin/main`, +then pushes and verifies an annotated `vX.Y.Z` tag. + +The tag workflow verifies the tag, then publishes the binaries, attestations, +and GitHub release. + +## Dependabot compatibility + +Before releasing, decide whether +[Dependabot's CLI integration](https://github.com/dependabot/dependabot-core/blob/main/github_actions/lib/dependabot/github_actions/lockfile/cli_engine.rb) +must move with the release. Update `dependabot-core` when the release changes: + +- CLI flags used by Dependabot. +- Findings JSON or exit codes. +- Relocking behavior. +- The lockfile schema. + +Dependabot +[pins the CLI version and binary checksums](https://github.com/dependabot/dependabot-core/blob/main/github_actions/Dockerfile) +and currently +[accepts only lockfile schema `v0.0.2`](https://github.com/dependabot/dependabot-core/blob/main/github_actions/lib/dependabot/github_actions/constants.rb). +Add schema support there before this CLI emits a new version. Unrelated releases +do not need a Dependabot bump. diff --git a/script/release b/script/release new file mode 100755 index 0000000..18da589 --- /dev/null +++ b/script/release @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# +# Cut a release tag for the Go sub-module. +# +# Usage: +# script/release +# script/release --rc +# +# The next version is computed from the latest vX.Y.Z tag and the chosen +# bump. Maintainers run this locally to validate and push an annotated tag. +# The tag workflow publishes it. +# +# Environment: +# RELEASE_DRY_RUN=1 Compute and print the next version, then stop before +# tagging or pushing. +# +# Requires: git, go, make. + +set -euo pipefail + +TAG_PREFIX="v" +STABLE_TAG_RE='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +TAG_RE='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$' + +die() { + echo "error: $*" >&2 + exit 1 +} + +# Run from the repository root regardless of the caller's cwd. +cd "$(dirname "$0")/.." + +validate_tag() { + [[ "$1" =~ $TAG_RE ]] || + die "invalid release tag '$1'; expected vX.Y.Z or vX.Y.Z-rc.N" +} + +fetch_main() { + git fetch --quiet --tags origin refs/heads/main:refs/remotes/origin/main +} + +require_current_main() { + local commit="$1" + local main + fetch_main + main="$(git rev-parse refs/remotes/origin/main)" + [ "$commit" = "$main" ] || + die "release commit ${commit} is not current main ${main}" +} + +require_clean() { + if [ -n "$(git status --porcelain)" ]; then + git --no-pager status --short >&2 + die "working tree is not clean" + fi +} + +require_remote_annotated_tag() { + local tag="$1" + local commit="$2" + local remote_commit + remote_commit="$(git ls-remote --tags origin "refs/tags/${tag}^{}" | awk 'NR == 1 { print $1 }')" + [ "$remote_commit" = "$commit" ] || + die "remote tag ${tag} is missing, is not annotated, or does not point to ${commit}" +} + +cut_tag() { + local bump="$1" + local release_candidate="$2" + + local branch + branch="$(git symbolic-ref --quiet --short HEAD || true)" + [ "$branch" = "main" ] || die "releases must be cut from main (got ${branch:-detached HEAD})" + require_clean + require_current_main "$(git rev-parse HEAD)" + + make fmt-check vet test test-stub + + local latest="" t + while IFS= read -r t; do + [ -n "$t" ] || continue + if [[ "$t" =~ $STABLE_TAG_RE ]]; then + latest="$t" + break + fi + done < <(git tag --list "${TAG_PREFIX}*" --sort=-v:refname) + + local base + if [ -z "$latest" ]; then + base="0.0.0" + echo "No existing ${TAG_PREFIX}* release tag; basing bump on v0.0.0." + else + base="${latest#"$TAG_PREFIX"}" + fi + + local major rest minor patch version tag + major="${base%%.*}" + rest="${base#*.}" + minor="${rest%%.*}" + patch="${rest#*.}" + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + + version="v${major}.${minor}.${patch}" + tag="${TAG_PREFIX}${major}.${minor}.${patch}" + if [ "$release_candidate" = "1" ]; then + local candidate rc=0 n + while IFS= read -r candidate; do + [[ "$candidate" =~ $TAG_RE ]] || continue + n="${candidate##*.}" + [ "$n" -gt "$rc" ] && rc="$n" + done < <(git tag --list "${tag}-rc.*") + tag="${tag}-rc.$((rc + 1))" + fi + validate_tag "$tag" + git rev-parse -q --verify "refs/tags/${tag}" >/dev/null && + die "tag ${tag} already exists" + + echo "Cutting ${tag} (module version ${version}) at $(git rev-parse --short HEAD)" + if [ "${RELEASE_DRY_RUN:-}" = "1" ]; then + echo "RELEASE_DRY_RUN=1 set; stopping before tag and push." + return + fi + + git tag -a "$tag" -m "$tag" + local commit + commit="$(git rev-parse "${tag}^{commit}")" + require_current_main "$commit" + git push origin "refs/tags/${tag}" + require_remote_annotated_tag "$tag" "$commit" + echo "Pushed ${tag}; the tag workflow will create the release." +} + +case "${1:-}" in + patch | minor | major) + if [ "$#" -eq 1 ]; then + cut_tag "$1" 0 + elif [ "$#" -eq 2 ] && [ "$2" = "--rc" ]; then + cut_tag "$1" 1 + else + die "usage: script/release [--rc]" + fi + ;; + *) + die "usage: script/release [--rc]" + ;; +esac diff --git a/script/release-test b/script/release-test new file mode 100755 index 0000000..dbc557c --- /dev/null +++ b/script/release-test @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -r "$tmp"' EXIT + +latest="" +while IFS= read -r tag; do + if [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + latest="$tag" + break + fi +done < <(git -C "$root" tag --list 'v*' --sort=-v:refname) +[[ -n "$latest" ]] +IFS=. read -r major minor patch <<< "${latest#v}" +next="v${major}.${minor}.$((patch + 1))" + +git init --quiet --bare "$tmp/origin.git" +git clone --quiet "$tmp/origin.git" "$tmp/repo" +cd "$tmp/repo" +git config user.name test +git config user.email test@example.com +git switch --quiet -c main +mkdir script +cp "$root/script/release" script/release +touch tracked +git add . +git commit --quiet -m initial +git push --quiet -u origin main +git tag -a "$latest" -m "$latest" + +mkdir "$tmp/bin" +printf '#!/bin/sh\nexit 0\n' >"$tmp/bin/make" +chmod +x "$tmp/bin/make" +export PATH="$tmp/bin:$PATH" + +output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" +[[ "$output" == *"Cutting ${next}-rc.1 "* ]] + +git tag -a "${next}-rc.1" -m "${next}-rc.1" +output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" +[[ "$output" == *"Cutting ${next}-rc.2 "* ]] + +output="$(RELEASE_DRY_RUN=1 script/release patch)" +[[ "$output" == *"Cutting ${next} "* ]] + +echo "release tag selection: ${latest} -> ${next}-rc.1 -> ${next}-rc.2 -> ${next}" From 0658dc7c8c38750ce44bcd8c39845cf8746bb205 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 31 Jul 2026 11:21:40 -0500 Subject: [PATCH 2/4] Release tooling: trust remote tags and main history --- .github/workflows/release.yml | 12 +---------- .github/workflows/test.yml | 2 +- Makefile | 7 +++++-- RELEASING.md | 2 +- script/release | 12 ++++++++--- script/release-test | 39 ++++++++++++++++++++--------------- script/verify-release-tag | 23 +++++++++++++++++++++ 7 files changed, 62 insertions(+), 35 deletions(-) create mode 100755 script/verify-release-tag diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c470e9f..958b27b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,17 +29,7 @@ jobs: fetch-tags: true - name: Verify tag - run: | - if [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$ ]]; then - echo "::error::Invalid release tag '$GITHUB_REF_NAME'; expected vX.Y.Z or vX.Y.Z-rc.N." - exit 1 - fi - commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")" - remote_commit="$(git ls-remote --tags origin "refs/tags/${GITHUB_REF_NAME}^{}" | awk 'NR == 1 { print $1 }')" - if [ "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" != "tag" ] || [ "$remote_commit" != "$commit" ]; then - echo "::error::Release tag '$GITHUB_REF_NAME' is not an annotated remote tag." - exit 1 - fi + run: script/verify-release-tag "$GITHUB_REF_NAME" - uses: cli/gh-extension-precompile@v2.1.0 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 183f365..fded297 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: exit 1 fi - run: go vet ./... - - run: go test -race -count=1 ./... + - run: make test integration-stub: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 5706463..d3f0629 100644 --- a/Makefile +++ b/Makefile @@ -7,14 +7,17 @@ EXT_DIR := $(XDG_DATA_HOME)/gh/extensions/$(EXT_NAME) RUBY := $(shell command -v /opt/homebrew/opt/ruby/bin/ruby 2>/dev/null || echo ruby) -.PHONY: build test vet fmt fmt-check test-integration test-shell test-live test-smoke test-stub test-real install reinstall uninstall +.PHONY: build test test-release vet fmt fmt-check test-integration test-shell test-live test-smoke test-stub test-real install reinstall uninstall build: go build -o $(BIN) ./cmd/gh-actions-lock -test: +test: test-release go test -race -count=1 ./... +test-release: + script/release-test + vet: go vet ./... diff --git a/RELEASING.md b/RELEASING.md index d1031dc..b89910a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,7 +27,7 @@ Use `patch` for compatible fixes, `minor` for compatible additions, and `major` for breaking changes. The script validates the repository, checks the current branch and `origin/main`, -then pushes and verifies an annotated `vX.Y.Z` tag. +then pushes and verifies an annotated `vX.Y.Z` or `vX.Y.Z-rc.N` tag. The tag workflow verifies the tag, then publishes the binaries, attestations, and GitHub release. diff --git a/script/release b/script/release index 18da589..d9f6ed1 100755 --- a/script/release +++ b/script/release @@ -36,7 +36,7 @@ validate_tag() { } fetch_main() { - git fetch --quiet --tags origin refs/heads/main:refs/remotes/origin/main + git fetch --quiet --no-tags origin refs/heads/main:refs/remotes/origin/main } require_current_main() { @@ -64,6 +64,12 @@ require_remote_annotated_tag() { die "remote tag ${tag} is missing, is not annotated, or does not point to ${commit}" } +remote_tags() { + git ls-remote --tags --refs origin "refs/tags/$1" | + awk '{ sub("refs/tags/", "", $2); print $2 }' | + sort -Vr +} + cut_tag() { local bump="$1" local release_candidate="$2" @@ -83,7 +89,7 @@ cut_tag() { latest="$t" break fi - done < <(git tag --list "${TAG_PREFIX}*" --sort=-v:refname) + done < <(remote_tags "${TAG_PREFIX}*") local base if [ -z "$latest" ]; then @@ -112,7 +118,7 @@ cut_tag() { [[ "$candidate" =~ $TAG_RE ]] || continue n="${candidate##*.}" [ "$n" -gt "$rc" ] && rc="$n" - done < <(git tag --list "${tag}-rc.*") + done < <(remote_tags "${tag}-rc.*") tag="${tag}-rc.$((rc + 1))" fi validate_tag "$tag" diff --git a/script/release-test b/script/release-test index dbc557c..6a318e3 100755 --- a/script/release-test +++ b/script/release-test @@ -5,17 +5,6 @@ root="$(cd "$(dirname "$0")/.." && pwd)" tmp="$(mktemp -d)" trap 'rm -r "$tmp"' EXIT -latest="" -while IFS= read -r tag; do - if [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - latest="$tag" - break - fi -done < <(git -C "$root" tag --list 'v*' --sort=-v:refname) -[[ -n "$latest" ]] -IFS=. read -r major minor patch <<< "${latest#v}" -next="v${major}.${minor}.$((patch + 1))" - git init --quiet --bare "$tmp/origin.git" git clone --quiet "$tmp/origin.git" "$tmp/repo" cd "$tmp/repo" @@ -24,11 +13,14 @@ git config user.email test@example.com git switch --quiet -c main mkdir script cp "$root/script/release" script/release +cp "$root/script/verify-release-tag" script/verify-release-tag touch tracked git add . git commit --quiet -m initial git push --quiet -u origin main -git tag -a "$latest" -m "$latest" +git tag -a v1.2.3 -m v1.2.3 +git push --quiet origin refs/tags/v1.2.3 +git tag -a v99.0.0 -m v99.0.0 mkdir "$tmp/bin" printf '#!/bin/sh\nexit 0\n' >"$tmp/bin/make" @@ -36,13 +28,26 @@ chmod +x "$tmp/bin/make" export PATH="$tmp/bin:$PATH" output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" -[[ "$output" == *"Cutting ${next}-rc.1 "* ]] +[[ "$output" == *"Cutting v1.2.4-rc.1 "* ]] -git tag -a "${next}-rc.1" -m "${next}-rc.1" +git tag -a v1.2.4-rc.1 -m v1.2.4-rc.1 +git push --quiet origin refs/tags/v1.2.4-rc.1 +git tag -a v1.2.4-rc.99 -m v1.2.4-rc.99 output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" -[[ "$output" == *"Cutting ${next}-rc.2 "* ]] +[[ "$output" == *"Cutting v1.2.4-rc.2 "* ]] output="$(RELEASE_DRY_RUN=1 script/release patch)" -[[ "$output" == *"Cutting ${next} "* ]] +[[ "$output" == *"Cutting v1.2.4 "* ]] + +script/verify-release-tag v1.2.4-rc.1 + +echo feature >tracked +git commit --quiet -am feature +git tag -a v1.2.4-rc.2 -m v1.2.4-rc.2 +git push --quiet origin refs/tags/v1.2.4-rc.2 +if script/verify-release-tag v1.2.4-rc.2 2>/dev/null; then + echo "feature-branch release tag unexpectedly passed verification" >&2 + exit 1 +fi -echo "release tag selection: ${latest} -> ${next}-rc.1 -> ${next}-rc.2 -> ${next}" +echo "release tag selection and verification: ok" diff --git a/script/verify-release-tag b/script/verify-release-tag new file mode 100755 index 0000000..15465c1 --- /dev/null +++ b/script/verify-release-tag @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1:-}" +tag_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$' + +if [[ ! "$tag" =~ $tag_re ]]; then + echo "error: invalid release tag '$tag'; expected vX.Y.Z or vX.Y.Z-rc.N" >&2 + exit 1 +fi + +commit="$(git rev-list -n 1 "$tag")" +remote_commit="$(git ls-remote --tags origin "refs/tags/${tag}^{}" | awk 'NR == 1 { print $1 }')" +if [ "$(git cat-file -t "refs/tags/$tag")" != "tag" ] || [ "$remote_commit" != "$commit" ]; then + echo "error: release tag '$tag' is not an annotated remote tag" >&2 + exit 1 +fi + +git fetch --quiet --no-tags origin refs/heads/main:refs/remotes/origin/main +if ! git merge-base --is-ancestor "$commit" refs/remotes/origin/main; then + echo "error: release tag '$tag' does not point to a commit in origin/main" >&2 + exit 1 +fi From 8421525f22571e12c23006b2003125de6e13cc84 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 31 Jul 2026 11:27:25 -0500 Subject: [PATCH 3/4] Release workflow: match main ancestry guard --- .github/workflows/release.yml | 17 ++++++++++++++++- script/release-test | 16 +++++++--------- script/verify-release-tag | 23 ----------------------- 3 files changed, 23 insertions(+), 33 deletions(-) delete mode 100755 script/verify-release-tag diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 958b27b..aacac75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,22 @@ jobs: fetch-tags: true - name: Verify tag - run: script/verify-release-tag "$GITHUB_REF_NAME" + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$ ]]; then + echo "::error::Invalid release tag '$GITHUB_REF_NAME'; expected vX.Y.Z or vX.Y.Z-rc.N." + exit 1 + fi + commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")" + remote_commit="$(git ls-remote --tags origin "refs/tags/${GITHUB_REF_NAME}^{}" | awk 'NR == 1 { print $1 }')" + if [ "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" != "tag" ] || [ "$remote_commit" != "$commit" ]; then + echo "::error::Release tag '$GITHUB_REF_NAME' is not an annotated remote tag." + exit 1 + fi + git fetch --quiet origin refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$commit" refs/remotes/origin/main; then + echo "::error::Release tag '$GITHUB_REF_NAME' is not in origin/main history." + exit 1 + fi - uses: cli/gh-extension-precompile@v2.1.0 with: diff --git a/script/release-test b/script/release-test index 6a318e3..2045fcf 100755 --- a/script/release-test +++ b/script/release-test @@ -13,7 +13,6 @@ git config user.email test@example.com git switch --quiet -c main mkdir script cp "$root/script/release" script/release -cp "$root/script/verify-release-tag" script/verify-release-tag touch tracked git add . git commit --quiet -m initial @@ -39,14 +38,13 @@ output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" output="$(RELEASE_DRY_RUN=1 script/release patch)" [[ "$output" == *"Cutting v1.2.4 "* ]] -script/verify-release-tag v1.2.4-rc.1 - -echo feature >tracked -git commit --quiet -am feature -git tag -a v1.2.4-rc.2 -m v1.2.4-rc.2 -git push --quiet origin refs/tags/v1.2.4-rc.2 -if script/verify-release-tag v1.2.4-rc.2 2>/dev/null; then - echo "feature-branch release tag unexpectedly passed verification" >&2 +tree="$(git rev-parse "HEAD^{tree}")" +ancestor="$(printf 'ancestor\n' | git -c user.name=test -c user.email=test@example.com commit-tree "$tree")" +descendant="$(printf 'descendant\n' | git -c user.name=test -c user.email=test@example.com commit-tree "$tree" -p "$ancestor")" +unrelated="$(printf 'unrelated\n' | git -c user.name=test -c user.email=test@example.com commit-tree "$tree")" +git merge-base --is-ancestor "$ancestor" "$descendant" +if git merge-base --is-ancestor "$unrelated" "$descendant"; then + echo "expected unrelated commit to fail ancestry check" >&2 exit 1 fi diff --git a/script/verify-release-tag b/script/verify-release-tag deleted file mode 100755 index 15465c1..0000000 --- a/script/verify-release-tag +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -tag="${1:-}" -tag_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$' - -if [[ ! "$tag" =~ $tag_re ]]; then - echo "error: invalid release tag '$tag'; expected vX.Y.Z or vX.Y.Z-rc.N" >&2 - exit 1 -fi - -commit="$(git rev-list -n 1 "$tag")" -remote_commit="$(git ls-remote --tags origin "refs/tags/${tag}^{}" | awk 'NR == 1 { print $1 }')" -if [ "$(git cat-file -t "refs/tags/$tag")" != "tag" ] || [ "$remote_commit" != "$commit" ]; then - echo "error: release tag '$tag' is not an annotated remote tag" >&2 - exit 1 -fi - -git fetch --quiet --no-tags origin refs/heads/main:refs/remotes/origin/main -if ! git merge-base --is-ancestor "$commit" refs/remotes/origin/main; then - echo "error: release tag '$tag' does not point to a commit in origin/main" >&2 - exit 1 -fi From 26a6e76ad3825b6235a9dba7b02568c2d7cead1b Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 31 Jul 2026 11:29:44 -0500 Subject: [PATCH 4/4] Release verification: share the tag ancestry guard --- .github/workflows/release.yml | 17 +---------------- script/release-test | 22 ++++++++++++++++++---- script/verify-release-tag | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 20 deletions(-) create mode 100755 script/verify-release-tag diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aacac75..c96a655 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,22 +29,7 @@ jobs: fetch-tags: true - name: Verify tag - run: | - if [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$ ]]; then - echo "::error::Invalid release tag '$GITHUB_REF_NAME'; expected vX.Y.Z or vX.Y.Z-rc.N." - exit 1 - fi - commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")" - remote_commit="$(git ls-remote --tags origin "refs/tags/${GITHUB_REF_NAME}^{}" | awk 'NR == 1 { print $1 }')" - if [ "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" != "tag" ] || [ "$remote_commit" != "$commit" ]; then - echo "::error::Release tag '$GITHUB_REF_NAME' is not an annotated remote tag." - exit 1 - fi - git fetch --quiet origin refs/heads/main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$commit" refs/remotes/origin/main; then - echo "::error::Release tag '$GITHUB_REF_NAME' is not in origin/main history." - exit 1 - fi + run: script/verify-release-tag "$GITHUB_REF_NAME" v - uses: cli/gh-extension-precompile@v2.1.0 with: diff --git a/script/release-test b/script/release-test index 2045fcf..c8311d4 100755 --- a/script/release-test +++ b/script/release-test @@ -13,6 +13,7 @@ git config user.email test@example.com git switch --quiet -c main mkdir script cp "$root/script/release" script/release +cp "$root/script/verify-release-tag" script/verify-release-tag touch tracked git add . git commit --quiet -m initial @@ -38,12 +39,25 @@ output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" output="$(RELEASE_DRY_RUN=1 script/release patch)" [[ "$output" == *"Cutting v1.2.4 "* ]] -tree="$(git rev-parse "HEAD^{tree}")" -ancestor="$(printf 'ancestor\n' | git -c user.name=test -c user.email=test@example.com commit-tree "$tree")" +ancestor="$(git rev-parse HEAD)" +tree="$(git rev-parse "${ancestor}^{tree}")" descendant="$(printf 'descendant\n' | git -c user.name=test -c user.email=test@example.com commit-tree "$tree" -p "$ancestor")" +git update-ref refs/heads/main "$descendant" +git push --quiet origin refs/heads/main +script/verify-release-tag v1.2.3 v +git tag -a go/v1.2.3 -m go/v1.2.3 "$ancestor" +git push --quiet origin refs/tags/go/v1.2.3 +script/verify-release-tag go/v1.2.3 go/v + +if script/verify-release-tag v1.2.3 invalid 2>/dev/null; then + echo "expected invalid prefix to fail verification" >&2 + exit 1 +fi + unrelated="$(printf 'unrelated\n' | git -c user.name=test -c user.email=test@example.com commit-tree "$tree")" -git merge-base --is-ancestor "$ancestor" "$descendant" -if git merge-base --is-ancestor "$unrelated" "$descendant"; then +git tag -a v1.2.5 -m v1.2.5 "$unrelated" +git push --quiet origin refs/tags/v1.2.5 +if script/verify-release-tag v1.2.5 v 2>/dev/null; then echo "expected unrelated commit to fail ancestry check" >&2 exit 1 fi diff --git a/script/verify-release-tag b/script/verify-release-tag new file mode 100755 index 0000000..2b8bf8c --- /dev/null +++ b/script/verify-release-tag @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1:-}" +prefix="${2:-}" + +case "$prefix" in + v | go/v) ;; + *) + echo "error: invalid release tag prefix '$prefix'; expected v or go/v" >&2 + exit 1 + ;; +esac + +tag_re="^${prefix}(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$" +if [[ ! "$tag" =~ $tag_re ]]; then + echo "error: invalid release tag '$tag'; expected ${prefix}X.Y.Z or ${prefix}X.Y.Z-rc.N" >&2 + exit 1 +fi + +commit="$(git rev-list -n 1 "$tag")" +remote_commit="$(git ls-remote --tags origin "refs/tags/${tag}^{}" | awk 'NR == 1 { print $1 }')" +if [ "$(git cat-file -t "refs/tags/$tag")" != "tag" ] || [ "$remote_commit" != "$commit" ]; then + echo "error: release tag '$tag' is not an annotated remote tag" >&2 + exit 1 +fi + +git fetch --quiet --no-tags origin refs/heads/main:refs/remotes/origin/main +if ! git merge-base --is-ancestor "$commit" refs/remotes/origin/main; then + echo "error: release tag '$tag' does not point to a commit in origin/main" >&2 + exit 1 +fi