From e8b5dbb9c0a661d19ead4a51af3413b22274336d Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 31 Jul 2026 11:14:29 -0500 Subject: [PATCH 1/3] Release tooling supports sequential release candidates --- .github/workflows/actions.lock | 1 - .github/workflows/release.yml | 64 +++++---- Makefile | 1 + RELEASING.md | 36 +++-- script/release | 255 +++++++++++++++++++++------------ script/release_test | 41 ++++++ 6 files changed, 266 insertions(+), 132 deletions(-) create mode 100755 script/release_test diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index a2ed652..dcb10ec 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -11,7 +11,6 @@ workflows: - 'actions/dependency-review-action@v4.9.0' '.github/workflows/release.yml': - 'actions/checkout@v4.3.1' - - 'actions/setup-go@v5.6.0' dependencies: 'actions/checkout@v4.3.1': ref: 'v4.3.1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecb3606..141b724 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,47 +3,55 @@ name: Release on: - workflow_dispatch: - inputs: - bump: - description: Version bump for the Go module (go/vX.Y.Z tag) - type: choice - required: true - default: patch - options: - - patch - - minor - - major + push: + tags: + - go/v* permissions: - contents: write + contents: read concurrency: - group: release + group: release-${{ github.ref }} cancel-in-progress: false jobs: - release: - name: Cut go/ release + publish: + name: Publish + permissions: + contents: write runs-on: ubuntu-latest steps: - - name: Guard release branch - if: github.ref != 'refs/heads/main' - run: | - echo "::error::Releases must be cut from main (got $GITHUB_REF)." - exit 1 - - uses: actions/checkout@v4.3.1 with: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-go@v5.6.0 - with: - go-version: '1.19' - cache-dependency-path: go/go.sum - - - name: Release + - name: Verify tag + id: tag + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^go/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 go/vX.Y.Z or go/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 + echo "version=${GITHUB_REF_NAME#go/}" >>"$GITHUB_OUTPUT" + + - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} - run: script/release "${{ inputs.bump }}" + run: | + args=() + if [[ "$GITHUB_REF_NAME" == *-rc.* ]]; then + args+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --verify-tag --generate-notes "${args[@]}" + + - name: Warm Go proxy + run: | + curl -fsSL "https://proxy.golang.org/github.com/github/actions-lockfile/go/@v/${{ steps.tag.outputs.version }}.info" >/dev/null || + echo "::warning::Go proxy warm failed; the proxy will fetch on first use." diff --git a/Makefile b/Makefile index eed9c2b..fb7b25f 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ generate: test: cd go && go test ./... -count=1 + script/release_test lint: cd go && go vet ./... diff --git a/RELEASING.md b/RELEASING.md index 29755c1..6567bf4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,26 +7,31 @@ standalone module so consumers can import it on its own. ## Cutting a release -Releases are cut by CI, not from a maintainer's laptop. Open the **Actions** -tab, run the **Release** workflow from `main`, and choose a bump: +From a clean, current `main`, preview the next release: -- **patch** — backward-compatible fixes -- **minor** — backward-compatible additions -- **major** — breaking changes (see the version-suffix caveat below) - -CI runs `script/release`, which regenerates and verifies the tree, runs the -full build, computes the next version from the latest `go/vX.Y.Z` tag, pushes -the tag, cuts a GitHub Release with generated notes, and warms the Go module -proxy. The first release has no prior tag, so it bases off `v0.0.0` — pick -**minor** to land on `v0.1.0`. +```sh +RELEASE_DRY_RUN=1 script/release patch +``` -`script/release` is the single source of truth and runs locally too. Preview -without touching anything: +Then cut it: ```sh -RELEASE_DRY_RUN=1 script/release minor +script/release patch ``` +Append `--rc` to preview or cut a release candidate, for example +`script/release patch --rc`. Repeating the same bump increments `-rc.N`; omit +`--rc` to release the stable version. + +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 `go/vX.Y.Z[-rc.N]` tag. + +The tag workflow verifies the tag, then creates the GitHub Release and warms +the Go module proxy. + ## Tag conventions The Go sub-module uses path-prefixed semver tags, per Go's multi-module @@ -34,6 +39,7 @@ repository rules: ``` go/v0.1.0 +go/v0.1.1-rc.1 go/v0.1.1 go/v1.0.0 ``` @@ -51,7 +57,7 @@ release script refuses to mint such a tag until `go.mod` carries the suffix. Shared lockfile invariants live outside language implementations: -- `schema/lockfile-v0.0.1.json` is the published schema. +- `schema/lockfile-vX.Y.Z.json` files are the published schemas. - `go/pkg/lockfile/schema_gen.go` is generated from the root schema for Go consumers. Run `make generate` after schema changes; Go tests enforce that the generated value still matches the root schema. diff --git a/script/release b/script/release index 8e89af1..da50c6c 100755 --- a/script/release +++ b/script/release @@ -1,120 +1,199 @@ #!/usr/bin/env bash # -# Cut a release of the Go sub-module by creating a path-prefixed tag -# (go/vX.Y.Z) and a matching GitHub Release. +# Cut a release tag for the Go sub-module. # # Usage: -# script/release +# script/release [--rc] # # The next version is computed from the latest go/vX.Y.Z tag and the chosen -# bump. This script is the single source of truth for the release procedure: -# CI invokes it from .github/workflows/release.yml, and a maintainer can run -# it locally (set RELEASE_DRY_RUN=1 to preview without tagging or pushing). +# 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 -# mutating anything (no tag, no push, no release). +# tagging or pushing. # -# Requires: git, go, make, gh (authenticated, for the non-dry-run path). +# Requires: git, go, make. set -euo pipefail MODULE_PATH="github.com/github/actions-lockfile/go" TAG_PREFIX="go/v" +STABLE_TAG_RE='^go/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +TAG_RE='^go/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 } -bump="${1:-}" -case "$bump" in - patch | minor | major) ;; - *) die "usage: script/release " ;; -esac - -# Run from the repository root regardless of the caller's cwd. -cd "$(dirname "$0")/.." - -# 1. The release commit must already contain generated output. Regenerate and -# fail if anything under go/ drifts — we tag HEAD, not the working tree, so -# a dirty tree means the tag would ship stale generated code. -make generate -if [ -n "$(git status --porcelain -- go/)" ]; then - echo "go/ tree is dirty after 'make generate':" >&2 - git --no-pager status --porcelain -- go/ >&2 - die "commit regenerated output before releasing" -fi +validate_tag() { + [[ "$1" =~ $TAG_RE ]] || + die "invalid release tag '$1'; expected go/vX.Y.Z or go/vX.Y.Z-rc.N" +} -# 2. Gate on a green build at the exact commit being tagged. -make lint -make test - -# 3. Confirm the module path matches what consumers import. -actual_module="$(cd go && go list -m)" -[ "$actual_module" = "$MODULE_PATH" ] || - die "module path is '$actual_module', expected '$MODULE_PATH'" - -# 4. Find the latest plain release tag (ignore prereleases like go/v1.2.0-rc.1). -latest="" -while IFS= read -r t; do - [ -n "$t" ] || continue - if printf '%s' "${t#"$TAG_PREFIX"}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - latest="$t" - break - fi -done < <(git tag --list "${TAG_PREFIX}*" --sort=-v:refname) +fetch_main() { + git fetch --quiet --tags origin refs/heads/main:refs/remotes/origin/main +} -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 +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}" +} -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 - -# 5. Go requires a /vN suffix on the module path for major versions >= 2. -# Until the module path carries that suffix, refuse to mint such a tag. -if [ "$major" -ge 2 ]; then - die "v${major}.x requires the module path to end in /v${major} (it is '$MODULE_PATH'); update go.mod and this script before a major bump" -fi +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}" +} -version="v${major}.${minor}.${patch}" -tag="${TAG_PREFIX}${major}.${minor}.${patch}" +guard_major_version() { + local major="$1" + if [ "$major" -ge 2 ] && [[ "$MODULE_PATH" != */v"$major" ]]; then + die "v${major}.x requires the module path to end in /v${major} (it is '$MODULE_PATH'); update go.mod and this script before release" + fi +} -git rev-parse -q --verify "refs/tags/${tag}" >/dev/null && - die "tag ${tag} already exists" +select_tag() { + local bump="$1" + local prerelease="$2" + local latest="" t + local tags=() + + while IFS= read -r t; do + [ -n "$t" ] || continue + tags+=("$t") + if [ -z "$latest" ] && [[ "$t" =~ $STABLE_TAG_RE ]]; then + latest="$t" + fi + done + + local base + if [ -z "$latest" ]; then + base="0.0.0" + else + base="${latest#"$TAG_PREFIX"}" + fi -echo "Releasing ${tag} (module version ${version}) at $(git rev-parse --short HEAD)" + local major rest minor patch stable_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 + stable_tag="${TAG_PREFIX}${major}.${minor}.${patch}" + + if [ "$prerelease" = "0" ]; then + printf '%s\n' "$stable_tag" + return + fi -if [ "${RELEASE_DRY_RUN:-}" = "1" ]; then - echo "RELEASE_DRY_RUN=1 set; stopping before tag/push/release." - exit 0 -fi + local highest=0 n + for t in "${tags[@]}"; do + case "$t" in + "$stable_tag"-rc.*) + n="${t#"$stable_tag-rc."}" + if [[ "$n" =~ ^[1-9][0-9]*$ ]] && [ "$n" -gt "$highest" ]; then + highest="$n" + fi + ;; + esac + done + printf '%s-rc.%d\n' "$stable_tag" "$((highest + 1))" +} -# 6. Tag HEAD and push. Use -c so we don't mutate the caller's git identity. -git \ - -c user.name="github-actions[bot]" \ - -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ - tag -a "$tag" -m "$tag" -git push origin "refs/tags/${tag}" +cut_tag() { + local bump="$1" + local prerelease="$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)" + + # The tag must contain generated output and pass the full repository checks. + make generate + if [ -n "$(git status --porcelain -- go/)" ]; then + echo "go/ tree is dirty after 'make generate':" >&2 + git --no-pager status --porcelain -- go/ >&2 + die "commit regenerated output before releasing" + fi + make lint + make test + + local actual_module + actual_module="$(cd go && go list -m)" + [ "$actual_module" = "$MODULE_PATH" ] || + die "module path is '$actual_module', expected '$MODULE_PATH'" + + local tag version major + tag="$(git tag --list "${TAG_PREFIX}*" --sort=-v:refname | select_tag "$bump" "$prerelease")" + version="${tag#go/}" + major="${version#v}" + major="${major%%.*}" + guard_major_version "$major" + + 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 -# 7. Cut the GitHub Release from the tag we just pushed. -gh release create "$tag" --title "$tag" --verify-tag --generate-notes + 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." +} -# 8. Best-effort: prime the Go module proxy so `go get ...@${version}` resolves -# immediately instead of on first external fetch. -curl -fsSL "https://proxy.golang.org/${MODULE_PATH}/@v/${version}.info" >/dev/null || - echo "warning: proxy warm failed (non-fatal); the proxy will fetch on first use." >&2 +main() { + cd "$(dirname "$0")/.." + + local bump="${1:-}" + case "$bump" in + patch | minor | major) ;; + *) die "usage: script/release [--rc]" ;; + esac + + local prerelease=0 + case "$#" in + 1) ;; + 2) + [ "$2" = "--rc" ] || + die "usage: script/release [--rc]" + prerelease=1 + ;; + *) die "usage: script/release [--rc]" ;; + esac + + cut_tag "$bump" "$prerelease" +} -echo "Released ${tag}." +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/script/release_test b/script/release_test new file mode 100755 index 0000000..5c2cbb0 --- /dev/null +++ b/script/release_test @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck source=script/release +source "$(dirname "$0")/release" + +check() { + local expected="$1" + local bump="$2" + local prerelease="$3" + shift 3 + + local actual + actual="$(printf '%s\n' "$@" | select_tag "$bump" "$prerelease")" + [ "$actual" = "$expected" ] || { + echo "expected $expected, got $actual" >&2 + exit 1 + } +} + +check go/v0.0.5 patch 0 go/v0.0.4 +check go/v0.0.5-rc.1 patch 1 go/v0.0.4 +check go/v0.0.5-rc.3 patch 1 go/v0.0.5-rc.2 go/v0.0.5-rc.1 go/v0.0.4 +check go/v0.0.5 patch 0 go/v0.0.5-rc.2 go/v0.0.4 +check go/v0.1.0-rc.1 minor 1 go/v0.0.5-rc.2 go/v0.0.4 + +if [ "${1:-}" = "--current" ]; then + [ "$#" -eq 3 ] || { + echo "usage: script/release_test --current " >&2 + exit 1 + } + actual="$(git tag --list "${TAG_PREFIX}*" --sort=-v:refname | select_tag "$3" 1)" + [ "$actual" = "$2" ] || { + echo "expected current tags to select $2, got $actual" >&2 + exit 1 + } + echo "current tags select $actual" +fi + +echo "release tag selection passed" From dd71775505a57b0bd9c46ed8c7aff577dd2de9f7 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 31 Jul 2026 11:21:41 -0500 Subject: [PATCH 2/3] Release workflow rejects tags outside main history --- .github/workflows/release.yml | 5 +++++ script/release_test | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 141b724..23f4128 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,11 @@ jobs: 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 echo "version=${GITHUB_REF_NAME#go/}" >>"$GITHUB_OUTPUT" - name: Create GitHub Release diff --git a/script/release_test b/script/release_test index 5c2cbb0..d386077 100755 --- a/script/release_test +++ b/script/release_test @@ -25,6 +25,16 @@ check go/v0.0.5-rc.3 patch 1 go/v0.0.5-rc.2 go/v0.0.5-rc.1 go/v0.0.4 check go/v0.0.5 patch 0 go/v0.0.5-rc.2 go/v0.0.4 check go/v0.1.0-rc.1 minor 1 go/v0.0.5-rc.2 go/v0.0.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")" +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 + if [ "${1:-}" = "--current" ]; then [ "$#" -eq 3 ] || { echo "usage: script/release_test --current " >&2 From f43ace974eb5086e0f4339a6120ba43ce9f586a0 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 31 Jul 2026 11:33:55 -0500 Subject: [PATCH 3/3] Release tags share canonical verification --- .github/workflows/release.yml | 16 +----- script/release_test | 102 ++++++++++++++++++++++------------ script/verify-release-tag | 32 +++++++++++ 3 files changed, 100 insertions(+), 50 deletions(-) create mode 100755 script/verify-release-tag diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23f4128..31aa2df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,21 +29,7 @@ jobs: - name: Verify tag id: tag run: | - if [[ ! "$GITHUB_REF_NAME" =~ ^go/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 go/vX.Y.Z or go/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 + script/verify-release-tag "$GITHUB_REF_NAME" go/v echo "version=${GITHUB_REF_NAME#go/}" >>"$GITHUB_OUTPUT" - name: Create GitHub Release diff --git a/script/release_test b/script/release_test index d386077..762dfdd 100755 --- a/script/release_test +++ b/script/release_test @@ -1,46 +1,19 @@ #!/usr/bin/env bash - set -euo pipefail -# shellcheck source=script/release -source "$(dirname "$0")/release" - -check() { - local expected="$1" - local bump="$2" - local prerelease="$3" - shift 3 - - local actual - actual="$(printf '%s\n' "$@" | select_tag "$bump" "$prerelease")" - [ "$actual" = "$expected" ] || { - echo "expected $expected, got $actual" >&2 - exit 1 - } -} - -check go/v0.0.5 patch 0 go/v0.0.4 -check go/v0.0.5-rc.1 patch 1 go/v0.0.4 -check go/v0.0.5-rc.3 patch 1 go/v0.0.5-rc.2 go/v0.0.5-rc.1 go/v0.0.4 -check go/v0.0.5 patch 0 go/v0.0.5-rc.2 go/v0.0.4 -check go/v0.1.0-rc.1 minor 1 go/v0.0.5-rc.2 go/v0.0.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")" -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 +root="$(cd "$(dirname "$0")/.." && pwd)" if [ "${1:-}" = "--current" ]; then [ "$#" -eq 3 ] || { echo "usage: script/release_test --current " >&2 exit 1 } - actual="$(git tag --list "${TAG_PREFIX}*" --sort=-v:refname | select_tag "$3" 1)" + actual="$( + cd "$root" + # shellcheck source=script/release + source script/release + git tag --list "${TAG_PREFIX}*" --sort=-v:refname | select_tag "$3" 1 + )" [ "$actual" = "$2" ] || { echo "expected current tags to select $2, got $actual" >&2 exit 1 @@ -48,4 +21,63 @@ if [ "${1:-}" = "--current" ]; then echo "current tags select $actual" fi -echo "release tag selection passed" +tmp="$(mktemp -d)" +trap 'rm -r "$tmp"' EXIT + +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 -p go 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 v1.2.3 -m v1.2.3 +git push --quiet origin refs/tags/v1.2.3 +git tag -a go/v1.2.3 -m go/v1.2.3 +git push --quiet origin refs/tags/go/v1.2.3 + +mkdir "$tmp/bin" +printf '#!/bin/sh\nexit 0\n' >"$tmp/bin/make" +printf '#!/bin/sh\necho github.com/github/actions-lockfile/go\n' >"$tmp/bin/go" +chmod +x "$tmp/bin/make" "$tmp/bin/go" +export PATH="$tmp/bin:$PATH" + +output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" +[[ "$output" == *"Cutting go/v1.2.4-rc.1 "* ]] + +git tag -a go/v1.2.4-rc.1 -m go/v1.2.4-rc.1 +git push --quiet origin refs/tags/go/v1.2.4-rc.1 +output="$(RELEASE_DRY_RUN=1 script/release patch --rc)" +[[ "$output" == *"Cutting go/v1.2.4-rc.2 "* ]] + +output="$(RELEASE_DRY_RUN=1 script/release patch)" +[[ "$output" == *"Cutting go/v1.2.4 "* ]] + +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 +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 tag -a go/v1.2.5 -m go/v1.2.5 "$unrelated" +git push --quiet origin refs/tags/go/v1.2.5 +if script/verify-release-tag go/v1.2.5 go/v 2>/dev/null; then + echo "expected unrelated commit to fail ancestry check" >&2 + exit 1 +fi + +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..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