diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml new file mode 100644 index 000000000..97d4a55d0 --- /dev/null +++ b/.github/workflows/create-rc.yml @@ -0,0 +1,46 @@ +name: Create RC + +on: + push: + branches: + - 'releases/v*' + +permissions: + contents: write + +jobs: + create-tag: + runs-on: ubuntu-latest + # The default pending slot cancels older queued RCs on rapid pushes. + concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + queue: max + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Tag the triggering commit even if the branch advances while queued. + ref: ${{ github.sha }} + fetch-depth: 0 + fetch-tags: true + + - name: Create or reuse RC tag + env: + RELEASE_BRANCH: ${{ github.ref_name }} + run: ./scripts/release/create-tag.sh "$RELEASE_BRANCH" rc + + - name: Summary + run: | + TAG=$(cat "$RUNNER_TEMP/release_tag") + { + echo "## RC tag ready" + echo "" + echo "**Tag**: \`${TAG}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..d23fbfa26 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,79 @@ +name: Publish Release + +on: + workflow_dispatch: + inputs: + version: + description: Version to release (X.Y.Z, without a v prefix) + required: true + type: string + +permissions: + contents: write + +concurrency: + group: publish-release-${{ inputs.version }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout release tools + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Preserve release tools + # Existing release branches may not contain the release scripts. + run: cp -R scripts/release "$RUNNER_TEMP/release-scripts" + + - name: Validate and checkout release branch + env: + VERSION: ${{ inputs.version }} + run: | + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version format: $VERSION (expected X.Y.Z)" + exit 1 + fi + RELEASE_BRANCH="releases/v${VERSION}" + git fetch origin "refs/heads/$RELEASE_BRANCH" || { + echo "::error::Branch $RELEASE_BRANCH not found. Run Start Release first." + exit 1 + } + git checkout --detach FETCH_HEAD + + - name: Create or reuse final tag + env: + VERSION: ${{ inputs.version }} + run: bash "$RUNNER_TEMP/release-scripts/create-tag.sh" "releases/v${VERSION}" final + + - name: Create or reuse draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + TAG=$(cat "$RUNNER_TEMP/release_tag") + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Release $TAG already exists; reusing it." + else + gh release create "$TAG" --draft --generate-notes --title "$TAG" --verify-tag + fi + + - name: Summary + run: | + TAG=$(cat "$RUNNER_TEMP/release_tag") + { + echo "## Release ready" + echo "" + echo "**Tag**: \`${TAG}\`" + echo "" + echo "Review the generated notes and publish the draft on GitHub. An existing release is left unchanged." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-branch-ci.yml b/.github/workflows/release-branch-ci.yml new file mode 100644 index 000000000..8b798cd64 --- /dev/null +++ b/.github/workflows/release-branch-ci.yml @@ -0,0 +1,38 @@ +name: Release Branch CI + +on: + pull_request: + # `edited` covers retargeting the PR to another base branch. + types: [opened, synchronize, reopened, edited] + branches: + - 'releases/v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.base_ref }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + finalized-release: + name: Finalized Release + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout release tools + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Check release is not finalized + env: + RELEASE_BRANCH: ${{ github.base_ref }} + run: ./scripts/release/check-finalized.sh "$RELEASE_BRANCH" diff --git a/.github/workflows/release-tests.yml b/.github/workflows/release-tests.yml new file mode 100644 index 000000000..df657d837 --- /dev/null +++ b/.github/workflows/release-tests.yml @@ -0,0 +1,40 @@ +name: Release Tests + +on: + pull_request: + paths: + - 'scripts/release/**' + - '.github/workflows/*release*.yml' + - '.github/workflows/create-rc.yml' + - 'justfile' + push: + branches: + - main + paths: + - 'scripts/release/**' + - '.github/workflows/*release*.yml' + - '.github/workflows/create-rc.yml' + - 'justfile' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Test release scripts + run: ./scripts/release/test-release.sh + + - name: Check release shell scripts + run: shellcheck scripts/release/*.sh diff --git a/.github/workflows/start-release.yml b/.github/workflows/start-release.yml new file mode 100644 index 000000000..2b7dbfe18 --- /dev/null +++ b/.github/workflows/start-release.yml @@ -0,0 +1,62 @@ +name: Start Release + +on: + workflow_dispatch: + inputs: + bump_type: + description: Version bump type + required: true + type: choice + options: + - minor + - patch + - major + default: minor + +permissions: + contents: write + +concurrency: + group: start-release + cancel-in-progress: false + +jobs: + start: + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout release tools + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Preserve release tools + # Patch bases can predate the release scripts. + run: cp -R scripts/release "$RUNNER_TEMP/release-scripts" + + - name: Create release branch and first RC + env: + BUMP_TYPE: ${{ inputs.bump_type }} + run: bash "$RUNNER_TEMP/release-scripts/start-release.sh" "$BUMP_TYPE" + + - name: Summary + run: | + CURRENT=$(cat "$RUNNER_TEMP/current_version") + NEW=$(cat "$RUNNER_TEMP/new_version") + BRANCH=$(cat "$RUNNER_TEMP/release_branch") + TAG=$(cat "$RUNNER_TEMP/release_tag") + { + echo "## Release branch created" + echo "" + echo "**Version**: v${CURRENT} to v${NEW}" + echo "**Branch**: \`${BRANCH}\`" + echo "**First RC**: \`${TAG}\`" + echo "" + echo "Next: test the RC, then run **Publish Release**." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index b1aa36e80..ccd953bd0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This repo contains the contracts and scripts for Base. For contract deployment artifacts, see [base-org/contract-deployments](https://github.com/base-org/contract-deployments). +For branches, release candidates, and publishing, see the [release process](docs/RELEASE.md). diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 000000000..c74fc06c4 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,113 @@ +# Release process + +Release versions come from branch names, not a repository version file. Release +management creates branches, tags, and draft GitHub releases. It does not build +artifacts or change individual contracts' versions or semver snapshots. + +## Start a release + +Run **Start Release** in GitHub Actions from `main` and select a bump type: + +- `minor` is the default. It increments the minor version and resets the patch. +- `patch` increments the patch version. +- `major` increments the major version and resets the minor and patch. + +The workflow uses the highest final `vX.Y.Z` tag by version order. RC tags of +both formats are ignored. For example, `v8.10.0` comes after `v8.9.0`, even if a +`v9.0.0-rc.1` tag also exists. + +Major and minor releases branch from `main`. Patch releases branch from the +highest existing `releases/vX.Y.*` branch for the latest final tag's major and +minor version. Starting a release fails if the new branch already exists or if +a patch release has no matching base branch. + +Start Release pushes `releases/vX.Y.Z` and an annotated first RC tag at its head +atomically. Both refs become available together, before subsequent Create RC jobs +can allocate a tag. Branch pushes made with `GITHUB_TOKEN` do not trigger other +workflows, so Start Release cannot rely on Create RC to make that first tag. +There is no version-sync step. + +## Create release candidates + +Every push to a `releases/v*` branch containing the automation runs **Create RC**. +It tags the triggering commit, even if newer commits arrive while it waits. +New tags use `vX.Y.Z-rc.N`. + +Legacy tags use `vX.Y.Z.rcN`. Both formats reserve RC numbers for their exact +version. For example, with `v8.4.0.rc1` and `v8.4.0.rc2` present, the next RC is +`v8.4.0-rc.3`. Numbering is numeric, so `rc.10` follows `rc.9`. Tags for other +versions do not affect the count. + +Subsequent RC tag allocation is serialized per release branch with +`cancel-in-progress: false` +and `queue: max`. GitHub allows up to 100 pending jobs in that queue. A full +queue cancels additional jobs. Jobs enter the queue when they start waiting, +which need not match commit order. Each job still tags its own triggering commit. + +### Retries + +Rerun a failed Create RC workflow. If a new-format RC tag for that version already +points at the triggering commit, the script reuses the highest such tag instead +of allocating another number. Legacy tags at that commit count toward numbering +but are not reused. + +Start Release refuses to reuse an existing release branch. Its atomic push +publishes neither the branch nor the first RC if either ref is rejected. If the +workflow fails after the push succeeds, verify the branch and tag rather than +rerunning Start Release. Do not move an existing tag to repair a failed run. + +## Publish the final release + +Run **Publish Release** from `main` and enter `X.Y.Z`, without a `v` or `releases/` +prefix. The workflow: + +- Validates the version and checks that `releases/vX.Y.Z` exists. +- Checks out the release branch head and creates `vX.Y.Z` there. +- Creates a draft GitHub release with GitHub-generated "What's Changed" notes. + +Review the notes and publish the draft on GitHub. There are no Docker images, +binaries, or other release assets to wait for. + +Publishing is serialized per version. If the final tag already points at the +release branch head, a retry reuses it. A final tag at a different commit causes +a failure; the workflow never moves it. If a GitHub release already exists, +the workflow leaves its notes and draft or published state unchanged. + +When a pull request is opened, reopened, updated, or edited (including +retargeting its base branch), **Release Branch CI** checks the repository's tags using +tools from `main`. Its Finalized Release check fails for a `releases/vX.Y.Z` +target once `vX.Y.Z` exists, even if the GitHub release is still a draft. Keep +this check required in branch protection. Creating the final tag does not rerun +checks on open pull requests, so an earlier passing check can stay green. After +finalizing, rerun checks on open pull requests into that branch or lock the +branch via branch protection to prevent merges. The check does not prevent +direct pushes. + +## Existing release branches + +Push-triggered workflows only run on release branches that contain their +workflow files. Updating `main` does not install automation on existing branches. +Branches such as `releases/v8.4.0` keep the manual release process unless the +release workflows and scripts are backported. Patch branches created from an +older release branch inherit that limitation, although Start Release still +creates their initial RC using the tools from `main`. + +To adopt automatic RCs and the finalized-release check on an active branch, +backport the release workflows and `scripts/release/`. Existing queued or running +workflows keep the configuration they started with. Release Branch CI loads its +tools from `main`, so the backport can pass even when the target branch has no +release scripts. Publish Release also uses tools from `main` and can tag an +existing branch without that backport. + +## Quick reference + +| Action | Workflow | Trigger | Output | +|--------|----------|---------|--------| +| Start a release | Start Release | Manual bump type | Release branch and first RC tag | +| Create an RC | Create RC | Push to `releases/v*` | RC tag at the triggering commit | +| Finalize a release | Publish Release | Manual version | Final tag and draft GitHub release | +| Guard a finalized branch | Release Branch CI | Pull request to `releases/v*` | Fails if the final tag exists | + +Run `just test-release` to test version selection, legacy RC numbering, retry +behavior, atomic branch and first-RC publication, and final-tag protection using +temporary local repositories without network access. diff --git a/justfile b/justfile index edd425a42..945ca5ac8 100644 --- a/justfile +++ b/justfile @@ -81,6 +81,10 @@ clean: # TEST # ######################################################## +# Tests release versioning and tag safety without network access. +test-release: + ./scripts/release/test-release.sh + # Runs standard contract tests. test *ARGS: build-go-ffi forge test {{ARGS}} diff --git a/scripts/release/check-finalized.sh b/scripts/release/check-finalized.sh new file mode 100755 index 000000000..3333f30d1 --- /dev/null +++ b/scripts/release/check-finalized.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck source-path=SCRIPTDIR +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +RELEASE_BRANCH="${1:-}" + +if [[ -z "$RELEASE_BRANCH" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +emit_error() { + local message="$1" + if [[ "${GITHUB_ACTIONS:-}" == true ]]; then + echo "::error::$message" >&2 + else + echo "Error: $message" >&2 + fi +} + +main() { + local version + if ! version=$(parse_branch_version "$RELEASE_BRANCH"); then + emit_error "Invalid release branch: $RELEASE_BRANCH (expected releases/v..)" + exit 1 + fi + + local final_tag="v${version}" + if tag_exists "$final_tag"; then + emit_error "Final release tag $final_tag already exists for $RELEASE_BRANCH" + exit 1 + fi + + echo "No final release tag $final_tag found for $RELEASE_BRANCH." +} + +main diff --git a/scripts/release/common.sh b/scripts/release/common.sh new file mode 100755 index 000000000..7275c7470 --- /dev/null +++ b/scripts/release/common.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +set -euo pipefail + +parse_branch_version() { + local branch="$1" + if [[ "$branch" =~ ^releases/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "${BASH_REMATCH[1]}" + else + echo "Error: Invalid branch name format: $branch" >&2 + echo "Expected: releases/v.." >&2 + return 1 + fi +} + +get_latest_final_version() { + local tag + while IFS= read -r tag; do + if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + done < <(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname) + echo "Error: No final release tags found. Cannot determine current version." >&2 + return 1 +} + +get_next_version() { + local bump_type="$1" current_version major minor patch + if [[ "$bump_type" != major && "$bump_type" != minor && "$bump_type" != patch ]]; then + echo "Error: bump_type must be 'major', 'minor', or 'patch'" >&2 + return 1 + fi + current_version=$(get_latest_final_version) || return 1 + IFS='.' read -r major minor patch <<< "$current_version" + major=$((10#$major)); minor=$((10#$minor)); patch=$((10#$patch)) + case "$bump_type" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + echo "${major}.${minor}.${patch}" +} + +get_next_rc_number() { + local version="$1" tag suffix number latest_rc=0 + while IFS= read -r tag; do + suffix=${tag#"v${version}"} + if [[ "$suffix" =~ ^(-rc\.|\.rc)([0-9]+)$ ]]; then + number=$((10#${BASH_REMATCH[2]})) + if (( number > latest_rc )); then + latest_rc=$number + fi + fi + done < <(git tag -l "v${version}-rc.*" "v${version}.rc*") + echo "$((latest_rc + 1))" +} + +get_rc_tag() { + local version="$1" tag suffix rc_number + # Only new-format tags are reused; legacy tags still reserve their RC numbers. + while IFS= read -r tag; do + suffix=${tag#"v${version}"} + if [[ "$suffix" =~ ^-rc\.[0-9]+$ ]]; then + echo "$tag" + return 0 + fi + done < <(git tag --list "v${version}-rc.*" --points-at HEAD --sort=-version:refname) + rc_number=$(get_next_rc_number "$version") + echo "v${version}-rc.${rc_number}" +} + +tag_exists() { + local tag="$1" + git rev-parse --verify "refs/tags/$tag" >/dev/null 2>&1 +} + +configure_git() { + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" +} + +create_and_push_tag() { + local tag="$1" + local message="${2:-Release $tag}" + + git tag -a "$tag" -m "$message" + git push origin "refs/tags/$tag" +} diff --git a/scripts/release/create-tag.sh b/scripts/release/create-tag.sh new file mode 100755 index 000000000..8ae38fcd0 --- /dev/null +++ b/scripts/release/create-tag.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck source-path=SCRIPTDIR +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +RELEASE_BRANCH="${1:-}" +RELEASE_TYPE="${2:-}" + +if [[ -z "$RELEASE_BRANCH" || -z "$RELEASE_TYPE" ]]; then + echo "Usage: $0 (rc|final)" >&2 + exit 1 +fi + +if [[ "$RELEASE_TYPE" != rc && "$RELEASE_TYPE" != final ]]; then + echo "Error: release_type must be 'rc' or 'final'" >&2 + exit 1 +fi + +main() { + echo "=== Create Release Tag ===" + echo "Branch: $RELEASE_BRANCH" + echo "Type: $RELEASE_TYPE" + + VERSION=$(parse_branch_version "$RELEASE_BRANCH") + echo "Version: $VERSION" + + local TAG TAG_SHA HEAD_SHA + if [[ "$RELEASE_TYPE" == rc ]]; then + TAG=$(get_rc_tag "$VERSION") + echo "Using RC tag: $TAG" + else + TAG="v${VERSION}" + echo "Using final release tag: $TAG" + fi + + if tag_exists "$TAG"; then + TAG_SHA=$(git rev-parse "refs/tags/$TAG^{}") + HEAD_SHA=$(git rev-parse HEAD) + if [[ "$TAG_SHA" == "$HEAD_SHA" ]]; then + echo "Tag $TAG already exists at HEAD, skipping creation" + else + echo "Error: Tag $TAG already exists but points at $TAG_SHA, not HEAD ($HEAD_SHA)" >&2 + exit 1 + fi + else + configure_git + create_and_push_tag "$TAG" + echo "=== Tag $TAG created and pushed ===" + fi + + if [[ -n "${RUNNER_TEMP:-}" ]]; then + echo "$TAG" > "${RUNNER_TEMP}/release_tag" + fi +} + +main diff --git a/scripts/release/start-release.sh b/scripts/release/start-release.sh new file mode 100755 index 000000000..2b6bd0623 --- /dev/null +++ b/scripts/release/start-release.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# shellcheck source-path=SCRIPTDIR +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +BUMP_TYPE="${1:-}" + +if [[ -z "$BUMP_TYPE" ]]; then + echo "Usage: $0 (major|minor|patch)" >&2 + exit 1 +fi + +main() { + echo "=== Start Release (bump: $BUMP_TYPE) ===" + + CURRENT_VERSION=$(get_latest_final_version) + NEW_VERSION=$(get_next_version "$BUMP_TYPE") + RELEASE_BRANCH="releases/v${NEW_VERSION}" + + local base=main major minor tag + if [[ "$BUMP_TYPE" == patch ]]; then + IFS='.' read -r major minor _ <<< "$CURRENT_VERSION" + base=$(git ls-remote --heads origin "releases/v${major}.${minor}.*" \ + | awk '{print $2}' \ + | sed -nE "s|^refs/heads/(releases/v${major}\\.${minor}\\.[0-9]+)$|\\1|p" \ + | sort -V | tail -1) + if [[ -z "$base" ]]; then + echo "Error: No existing releases/v${major}.${minor}.* branch found on remote." >&2 + echo "Cannot create a patch release without a prior release branch." >&2 + exit 1 + fi + fi + + echo "Current version: $CURRENT_VERSION" + echo "New version: $NEW_VERSION" + echo "Release branch: $RELEASE_BRANCH" + echo "Base: $base" + + if [[ -n "$(git ls-remote --heads origin "refs/heads/$RELEASE_BRANCH")" ]]; then + echo "Error: Branch $RELEASE_BRANCH already exists on remote." >&2 + exit 1 + fi + + configure_git + git fetch origin "refs/heads/$base:refs/remotes/origin/$base" + git checkout -b "$RELEASE_BRANCH" "origin/$base" + tag="v${NEW_VERSION}-rc.$(get_next_rc_number "$NEW_VERSION")" + git tag -a "$tag" -m "Release $tag" + git push --atomic origin "refs/heads/$RELEASE_BRANCH" "refs/tags/$tag" + + if [[ -n "${RUNNER_TEMP:-}" ]]; then + echo "$CURRENT_VERSION" > "${RUNNER_TEMP}/current_version" + echo "$NEW_VERSION" > "${RUNNER_TEMP}/new_version" + echo "$RELEASE_BRANCH" > "${RUNNER_TEMP}/release_branch" + echo "$tag" > "${RUNNER_TEMP}/release_tag" + fi + + echo "=== Branch $RELEASE_BRANCH and tag $tag created (v$CURRENT_VERSION to v$NEW_VERSION) ===" +} + +main diff --git a/scripts/release/test-release.sh b/scripts/release/test-release.sh new file mode 100755 index 000000000..471f47cd9 --- /dev/null +++ b/scripts/release/test-release.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source-path=SCRIPTDIR +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export RUNNER_TEMP="$TEMP_DIR" +git init -q "$TEMP_DIR/repo" +cd "$TEMP_DIR/repo" +git config user.name 'Release test' +git config user.email 'release-test@example.com' +git config commit.gpgsign false +git config tag.gpgsign false +git config core.hooksPath /dev/null +git commit --allow-empty -qm 'Initial commit' + +assert_equal() { + local expected="$1" actual="$2" scenario="$3" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $scenario: expected '$expected', got '$actual'" >&2 + exit 1 + fi + echo "PASS: $scenario" +} + +assert_rejected() { + local expected="$1" + shift + if "$@" >"$TEMP_DIR/output" 2>&1; then + echo "FAIL: expected rejection from $*" >&2 + exit 1 + fi + if ! grep -Fq "$expected" "$TEMP_DIR/output"; then + echo "FAIL: expected error '$expected', got '$(cat "$TEMP_DIR/output")'" >&2 + exit 1 + fi + echo "PASS: $* rejects with '$expected'" +} + +test_rc_numbering() { + git tag v8.4.0.rc1 + git tag v8.4.0.rc2 + git tag v8.4.1-rc.5 + git tag v8.4.10.rc7 + assert_equal 3 "$(get_next_rc_number 8.4.0)" 'legacy RC numbering continues and excludes other versions' + assert_equal 1 "$(get_next_rc_number 8.5.0)" 'a version without RCs starts at one' + assert_equal v8.4.0-rc.3 "$(get_rc_tag 8.4.0)" 'legacy RCs produce the next tag in the new format' + assert_equal v8.5.0-rc.1 "$(get_rc_tag 8.5.0)" 'a version without RCs produces the first tag' + + git tag v8.4.0-rc.9 + assert_equal 10 "$(get_next_rc_number 8.4.0)" 'RC numbering is numeric' + git tag v8.4.0-rc.10 + git tag v8.4.0.rc12 + git tag v8.4.0-rc.invalid + git tag v8.4.0.rc99-extra + assert_equal 13 "$(get_next_rc_number 8.4.0)" 'mixed RC formats use the highest valid number' + + git tag -a v8.4.0-rc.11 -m 'Annotated RC' + assert_equal v8.4.0-rc.11 "$(get_rc_tag 8.4.0)" 'retries reuse the highest new-format tag at HEAD' + bash "$SCRIPT_DIR/create-tag.sh" releases/v8.4.0 rc + assert_equal v8.4.0-rc.11 "$(cat "$RUNNER_TEMP/release_tag")" 'RC script retries without a remote or push' + + git commit --allow-empty -qm 'Next candidate' + git tag v8.4.0.rc15 + assert_equal v8.4.0-rc.16 "$(get_rc_tag 8.4.0)" 'legacy tags at HEAD are counted but not reused' +} + +test_version_bumps() { + git tag v8.9.0 + git tag v8.10.0 + git tag v8.10.1-rc.9 + git tag v9.0.0.rc2 + git tag v99.0.0-preview + assert_equal 8.10.0 "$(get_latest_final_version)" 'latest final ignores both RC formats and uses version order' + assert_equal 9.0.0 "$(get_next_version major)" 'major bumps reset minor and patch' + assert_equal 8.11.0 "$(get_next_version minor)" 'minor bumps reset patch' + assert_equal 8.10.1 "$(get_next_version patch)" 'patch bumps increment patch' + git tag v8.10.4 + assert_equal 9.0.0 "$(get_next_version major)" 'major bumps reset a nonzero patch' + assert_equal 8.11.0 "$(get_next_version minor)" 'minor bumps reset a nonzero patch' + assert_equal 8.10.5 "$(get_next_version patch)" 'patch bumps retain major and minor' + assert_rejected 'bump_type must be' get_next_version invalid + + git tag -d v8.9.0 v8.10.0 v8.10.4 >/dev/null + assert_rejected 'No final release tags found' get_latest_final_version + assert_rejected 'No final release tags found' get_next_version minor +} + +test_final_tags() { + assert_equal 8.4.0 "$(parse_branch_version releases/v8.4.0)" 'version comes from the release branch' + assert_rejected 'Invalid branch name format' parse_branch_version releases/v8.4.0-extra + bash "$SCRIPT_DIR/check-finalized.sh" releases/v8.4.0 + git tag -a v8.4.0 -m 'Final release' + bash "$SCRIPT_DIR/create-tag.sh" releases/v8.4.0 final + assert_equal v8.4.0 "$(cat "$RUNNER_TEMP/release_tag")" 'final script reuses an annotated tag at HEAD without pushing' + assert_rejected 'Final release tag v8.4.0 already exists' bash "$SCRIPT_DIR/check-finalized.sh" releases/v8.4.0 + git commit --allow-empty -qm 'After final release' + assert_rejected 'not HEAD' bash "$SCRIPT_DIR/create-tag.sh" releases/v8.4.0 final +} + +test_start_release() ( + local remote="$TEMP_DIR/start-origin.git" + git init -q --bare "$remote" + git init -q -b main "$TEMP_DIR/start-repo" + cd "$TEMP_DIR/start-repo" + configure_git + git commit --allow-empty -qm 'Initial release base' + git tag v8.4.0 + git tag v8.4.1.rc2 + git remote add origin "$remote" + git push -q origin main main:refs/heads/releases/v8.4.0 --tags + local base_sha + base_sha=$(git rev-parse HEAD) + + bash "$SCRIPT_DIR/start-release.sh" minor + assert_equal v8.5.0-rc.1 "$(cat "$RUNNER_TEMP/release_tag")" 'a fresh release starts at RC one' + assert_equal "$base_sha" "$(git --git-dir="$remote" rev-parse refs/heads/releases/v8.5.0)" 'the release branch is published at its base' + assert_equal "$base_sha" "$(git --git-dir="$remote" rev-parse 'refs/tags/v8.5.0-rc.1^{}')" 'the first RC is published at the release head' + assert_equal tag "$(git --git-dir="$remote" cat-file -t refs/tags/v8.5.0-rc.1)" 'the first RC is annotated' + + bash "$SCRIPT_DIR/start-release.sh" patch + assert_equal v8.4.1-rc.3 "$(cat "$RUNNER_TEMP/release_tag")" 'the first RC continues legacy numbering' + assert_equal "$base_sha" "$(git --git-dir="$remote" rev-parse 'refs/tags/v8.4.1-rc.3^{}')" 'a patch RC is published from a branch without release tools' + + tee "$remote/hooks/update" >/dev/null <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == refs/tags/* ]]; then + echo 'Tag rejected' >&2 + exit 1 +fi +EOF + chmod +x "$remote/hooks/update" + assert_rejected 'Tag rejected' bash "$SCRIPT_DIR/start-release.sh" major + assert_equal '' "$(git ls-remote origin refs/heads/releases/v9.0.0 refs/tags/v9.0.0-rc.1)" 'a rejected atomic push publishes neither the branch nor its first RC' +) + +case "${1:-all}" in + rc) test_rc_numbering ;; + version) test_version_bumps ;; + start) test_start_release ;; + all) + test_version_bumps + test_rc_numbering + test_final_tags + test_start_release + ;; + *) echo "Usage: $0 [rc|version|start|all]" >&2; exit 1 ;; +esac + +echo 'Release tests passed.'