Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -24,6 +46,7 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_BRANCH: ${{ steps.release_branch.outputs.branch }}
run: |
set -euo pipefail

Expand All @@ -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

Expand Down
45 changes: 45 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions scripts/create-release-tag.sh
Original file line number Diff line number Diff line change
@@ -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<version>" >&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"
51 changes: 51 additions & 0 deletions scripts/release-tag-hook.sh
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions tests/release_tags.py
Original file line number Diff line number Diff line change
@@ -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")
Loading