diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7e427d119..c0835da21 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,7 +3,7 @@ name: Create Release on: push: tags: - - "v*" # v0.6.1 or v0.6.1.alpha.9 + - "v*" # v1.1.10.0 or v1.1.11.beta.1 permissions: contents: write @@ -15,6 +15,28 @@ jobs: runs-on: ubuntu-24.04 steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Require version on its release branch + id: release_branch + shell: bash + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + branch=$(bash scripts/release-tag-hook.sh --branch-for-tag "$RELEASE_TAG") + git fetch --no-tags origin \ + '+refs/heads/main:refs/remotes/origin/main' \ + '+refs/heads/dev:refs/remotes/origin/dev' + commit=$(git rev-parse 'HEAD^{commit}') + if ! git merge-base --is-ancestor "$commit" "refs/remotes/origin/$branch"; then + echo "::error::$RELEASE_TAG must point to a commit on $branch. Merge the PR before tagging." + exit 1 + fi + echo "branch=$branch" >> "$GITHUB_OUTPUT" + # Find a successful Build workflow for this exact commit. # # Release intentionally reuses the artifacts from CI rather than @@ -24,6 +46,7 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ steps.release_branch.outputs.branch }} run: | set -euo pipefail @@ -33,12 +56,12 @@ jobs: gh api \ -H "Accept: application/vnd.github+json" \ "/repos/${{ github.repository }}/actions/runs?head_sha=${{ github.sha }}&status=success&per_page=100" \ - --jq '.workflow_runs[] | select(.name == "Build") | .id' \ + --jq ".workflow_runs[] | select(.name == \"Build\" and .event == \"push\" and .head_branch == \"$RELEASE_BRANCH\") | .id" \ | head -n 1 )" if [[ -z "$build_run_id" ]]; then - echo "::error::No successful Build workflow found for commit ${{ github.sha }}." + echo "::error::No successful $RELEASE_BRANCH push Build found for commit ${{ github.sha }}." exit 1 fi diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 000000000..6e81ac448 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,45 @@ +# Create a release + +This checkout has a `reference-transaction` hook installed from `scripts/release-tag-hook.sh`. It rejects creation +or movement of `v*` tags unless the checked-out branch matches the version policy and contains the target commit. Deleting a +mistaken tag is still allowed. This also covers GUI tools that use Git and honor its hooks. + +For a new clone, install it with the following command, after checking for an existing hook to preserve: + +```sh +cp scripts/release-tag-hook.sh "$(git rev-parse --git-path hooks)/reference-transaction" +chmod +x "$(git rev-parse --git-path hooks)/reference-transaction" +``` + +Merge the release PR into `main` or `dev` first. Check out that branch, update it from `origin`, and wait for its +push-triggered Build workflow to succeed. Then create the tag: + +```sh +bash scripts/create-release-tag.sh v1.1.10.0 +git push origin refs/tags/v1.1.10.0 +``` + +The script refuses feature branches, detached checkouts, tracked changes, and a local branch that differs from its +published tip. It creates an annotated tag locally and does not push it. + +| Version | Required branch | +| --- | --- | +| `vMAJOR.MINOR.RELEASE.0`, with an even RELEASE (e.g. `v1.1.10.0`) | `main` | +| Numeric versions with a nonzero PATCH (e.g. `v1.1.10.1`, `v1.1.11.1`) | `dev` | +| Alpha or beta versions (e.g. `v10.1.11.beta.1`) | `dev` | +| Odd RELEASE with PATCH 0 (e.g. `v1.1.11.0`), or any other format | Rejected | + +The third number determines parity, independently of major and minor. The hook, creation script, and workflow use +the same version policy. The workflow checks that the tagged commit is reachable from the required remote branch +and has a successful push-triggered Build on that branch; a successful PR build is insufficient. + +Run `python3 tests/release_tags.py` to check the version policy and Git hook in an isolated repository. + +Tags identify commits, not branches. Direct `git tag` bypasses the creation script but is checked by the installed +hook. GitHub tag creation bypasses local hooks. The workflow +blocks release publication after an invalid tag is pushed; it does not prevent or delete the tag. Tags pointing to +older workflow versions also use those older checks. + +For server-side control, configure a GitHub tag ruleset for `v*` with **Restrict creations**, allowing only designated +release maintainers or automation to bypass it. Those maintainers must use the checked creation path: the ruleset +restricts who can create a tag, rather than which branch contains its commit. diff --git a/scripts/create-release-tag.sh b/scripts/create-release-tag.sh new file mode 100644 index 000000000..b6c30aafa --- /dev/null +++ b/scripts/create-release-tag.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Create a release tag only at the published main or dev tip. Push it explicitly after reviewing it. +set -euo pipefail + +if [[ $# != 1 || "$1" != v* ]]; then + echo "Usage: $0 v" >&2 + exit 1 +fi +tag=$1 +git check-ref-format "refs/tags/$tag" +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +required_branch=$(bash "$script_dir/release-tag-hook.sh" --branch-for-tag "$tag") +branch=$(git symbolic-ref --quiet --short HEAD) || { + echo "Check out main or dev before creating a release tag." >&2 + exit 1 +} +if [[ $branch != "$required_branch" ]]; then + echo "$tag requires $required_branch; current branch: $branch" >&2 + exit 1 +fi +if [[ -n $(git status --porcelain --untracked-files=no) ]]; then + echo "Commit or stash tracked changes before creating a release tag." >&2 + exit 1 +fi +git fetch --no-tags origin "+refs/heads/$branch:refs/remotes/origin/$branch" +if [[ $(git rev-parse HEAD) != $(git rev-parse "refs/remotes/origin/$branch") ]]; then + echo "Local $branch must match origin/$branch. Publish or update the branch before tagging." >&2 + exit 1 +fi +git tag -a "$tag" -m "Release $tag" HEAD +printf 'Created %s on %s. To publish: git push origin refs/tags/%s\n' "$tag" "$branch" "$tag" diff --git a/scripts/release-tag-hook.sh b/scripts/release-tag-hook.sh new file mode 100644 index 000000000..9b8a6508e --- /dev/null +++ b/scripts/release-tag-hook.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Git reference-transaction hook and shared release-version policy. +set -euo pipefail + +ReleaseBranch() +{ + local tag=$1 release patch + if [[ $tag =~ ^v[0-9]+\.[0-9]+\.([0-9]+)\.([0-9]+)$ ]]; then + release=${BASH_REMATCH[1]} + patch=${BASH_REMATCH[2]} + # Inspect decimal digits without shell integer overflow or leading-zero octal interpretation. + if [[ $patch =~ ^0+$ ]]; then + if [[ $release =~ [02468]$ ]]; then + echo main + return + fi + echo "Release tag rejected: odd releases cannot use patch 0; use an alpha/beta tag or a nonzero patch." >&2 + return 1 + fi + echo dev + elif [[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.(alpha|beta)\.[0-9]+$ ]]; then + echo dev + else + echo "Release tag rejected: expected vMAJOR.MINOR.RELEASE.PATCH or vMAJOR.MINOR.RELEASE.alpha/beta.NUMBER." >&2 + return 1 + fi +} + +if [[ ${1:-} == --branch-for-tag ]]; then + [[ $# == 2 ]] || exit 1 + ReleaseBranch "$2" + exit +fi +[[ ${1:-} == prepared ]] || exit 0 + +while read -r old new ref; do + case "$ref" in refs/tags/v*) ;; *) continue ;; esac + # Deleting a mistaken tag must remain possible. + [[ $new =~ ^0+$ ]] && continue + required_branch=$(ReleaseBranch "${ref#refs/tags/}") || exit 1 + branch=$(git symbolic-ref --quiet --short HEAD) || branch='detached HEAD' + if [[ $branch != "$required_branch" ]]; then + echo "Release tag rejected: ${ref#refs/tags/} requires $required_branch (currently $branch)." >&2 + exit 1 + fi + commit=$(git rev-parse --verify "$new^{commit}") || exit 1 + if ! git merge-base --is-ancestor "$commit" "refs/heads/$branch"; then + echo "Release tag rejected: its commit is not on $branch. Merge the PR before tagging." >&2 + exit 1 + fi +done diff --git a/tests/release_tags.py b/tests/release_tags.py new file mode 100644 index 000000000..8a80d7720 --- /dev/null +++ b/tests/release_tags.py @@ -0,0 +1,67 @@ +"""Exercise release version rules and the installed hook without changing this checkout.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile + + +HOOK = Path(__file__).resolve().parents[1] / "scripts/release-tag-hook.sh" +for tag, expected in { + "v1.1.10.0": "main", + "v10.1.12.0": "main", + "v1.1.10.1": "dev", + "v1.1.11.1": "dev", + "v10.1.11.beta.1": "dev", + "v1.1.10.alpha.0": "dev", + "v1.1.11.0": None, + "v1.1.11.00": None, + "v1.1.10": None, + "v1.1.10.rc.1": None, + "v1.1.10.0-extra": None, +}.items(): + result = subprocess.run( + ["bash", str(HOOK), "--branch-for-tag", tag], capture_output=True, text=True + ) + assert (result.returncode == 0) == (expected is not None), (tag, result.stderr) + if expected: + assert result.stdout.strip() == expected, tag + +with tempfile.TemporaryDirectory(prefix="stfc-release-tags-") as directory: + def git(*args, ok=True): + result = subprocess.run( + ["git", "-C", directory, *args], capture_output=True, text=True + ) + assert (result.returncode == 0) == ok, (args, result.stderr) + return result.stdout.strip() + + git("init", "-b", "main") + git("config", "user.name", "Release tests") + git("config", "user.email", "test@example.invalid") + git("commit", "--allow-empty", "-m", "Base") + installed = Path(directory) / ".git/hooks/reference-transaction" + shutil.copyfile(HOOK, installed) + installed.chmod(0o755) + git("tag", "v1.1.10.0") + git("tag", "-a", "v1.1.12.0", "-m", "Stable") + git("tag", "v1.1.10.1", ok=False) + git("tag", "v1.1.11.0", ok=False) + git("tag", "v10.1.11.beta.1", ok=False) + git("switch", "-c", "dev") + git("tag", "-a", "v10.1.11.beta.1", "-m", "Beta") + git("tag", "v1.1.10.1") + git("tag", "v1.1.14.0", ok=False) + git("tag", "v1.1.11.0", ok=False) + git("switch", "-c", "feature/test") + git("commit", "--allow-empty", "-m", "PR only") + feature = git("rev-parse", "HEAD") + git("tag", "v1.1.16.0", ok=False) + git("tag", "v1.1.11.beta.2", ok=False) + git("tag", "-d", "v1.1.10.0") + git("tag", "internal-test") + git("switch", "main") + git("tag", "v1.1.16.0", feature, ok=False) + git("switch", "--detach") + git("tag", "v1.1.16.0", ok=False) + +print("Release policy and Git hook checks passed")