feat: add scripts/release-tag automation helper (#50)

Automate the documented release-tag checklist (#48) without bypassing safety
gates.

scripts/release-tag:
- Requires a SemVer tag (vMAJOR.MINOR.PATCH); validates before any git/network.
- Fetch/prune first, then refuses: dirty worktree, non-master branch, local
  master != remote master, HEAD not on remote master, and an existing local or
  remote tag of the same name.
- Runs the full suite by default; --skip-tests is an explicit opt-out that warns.
- Creates an ANNOTATED tag (git tag -a), never lightweight.
- Safe by default: no push unless --push; --dry-run prints planned actions and
  changes nothing. Supports --notes-file <path> for the annotation message.
- Prints: commit, tag, tests_run, tag_created, tag_pushed.
- Env injection points for testing/CI: RELEASE_TAG_REMOTE, RELEASE_TAG_TEST_CMD.

tests/test_release_tag.py (14 cases): valid SemVer dry-run; invalid version;
dirty worktree; non-master; master/remote mismatch; existing tag; missing
notes-file; annotated-not-lightweight; no-push-without-flag; push-only-with-flag;
notes-file message; --skip-tests warns; default runs tests (fail blocks tag,
pass tags). Each test builds a throwaway repo with a LOCAL bare remote (cloned,
not pushed) and stubs the test command — no network, no real tags, no pushing
from the project repo.

Docs: reference scripts/release-tag from the runbook, SKILL, and the release-tag
template (script preferred; manual steps are the fallback).

Full suite 305 passed / 0 failures; bash -n clean; git diff --check clean; no
secrets.

Closes #50. Refs #48.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 04:21:21 -04:00
parent ec9ddb09a7
commit 4e43347b2d
5 changed files with 338 additions and 0 deletions
+12
View File
@@ -368,6 +368,18 @@ Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`]
6. `git tag -a <vX.Y.Z> prgs/master -m "<notes referencing #issues / PRs>"`.
7. `git push prgs <vX.Y.Z>`; add release notes if the forge supports it.
`scripts/release-tag` automates steps 17 with these gates built in (SemVer
check, fetch/prune, on-master, clean tree, local==remote master, HEAD on remote
master, no duplicate tag, tests run unless `--skip-tests`, annotated tag only).
It is **safe by default** — no push unless `--push`, and `--dry-run` changes
nothing:
```bash
scripts/release-tag --dry-run v0.4.0
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
```
## Safety notes
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
# release-tag — create an annotated release tag safely from remote master.
# Enforces the documented tagging policy (see docs/llm-workflow-runbooks.md and
# skills/llm-project-workflow/SKILL.md). Never pushes unless --push is given.
#
# Test/CI injection points (env):
# RELEASE_TAG_REMOTE git remote name (default: prgs)
# RELEASE_TAG_TEST_CMD test command run before tagging
# (default: ./venv/bin/python -m pytest tests/ -q)
REMOTE="${RELEASE_TAG_REMOTE:-prgs}"
TEST_CMD="${RELEASE_TAG_TEST_CMD:-./venv/bin/python -m pytest tests/ -q}"
usage() {
cat <<'EOF'
usage: scripts/release-tag [--dry-run] [--skip-tests] [--push]
[--notes-file <path>] <vMAJOR.MINOR.PATCH>
Create an annotated release tag from remote master, only when the tree/branch
are clean and tests pass. Safe by default: no push unless --push; --dry-run
changes nothing.
Options:
--dry-run Print planned actions; create/push nothing.
--skip-tests Skip the test suite (explicit opt-out; prints a warning).
--push Push the tag to the remote after creating it.
--notes-file <path> Use this file's contents as the annotated-tag message.
--help Show this help.
Examples:
scripts/release-tag --dry-run v0.4.0
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
EOF
}
fail() { printf 'release-tag: %s\n' "$1" >&2; exit "${2:-1}"; }
dry_run=0
skip_tests=0
push=0
notes_file=""
version=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
--skip-tests) skip_tests=1 ;;
--push) push=1 ;;
--notes-file) shift; notes_file="${1:-}"; [[ -n "$notes_file" ]] || fail "--notes-file needs a path" 2 ;;
--help) usage; exit 0 ;;
-*) usage >&2; exit 2 ;;
*) if [[ -z "$version" ]]; then version="$1"; else usage >&2; exit 2; fi ;;
esac
shift
done
[[ -n "$version" ]] || { usage >&2; exit 2; }
# 1. SemVer validation (before any git/network work).
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
fail "invalid version '$version' (expected vMAJOR.MINOR.PATCH, e.g. v0.4.0)" 2
fi
if [[ -n "$notes_file" && ! -f "$notes_file" ]]; then
fail "notes file not found: $notes_file" 2
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
git_c=(git -C "$repo_root")
# 2. Fetch/prune first.
"${git_c[@]}" fetch "$REMOTE" --prune
# 3. Must be on master.
current_branch="$("${git_c[@]}" symbolic-ref --quiet --short HEAD || echo DETACHED)"
[[ "$current_branch" == "master" ]] || fail "not on master (on '$current_branch'); tag only from master"
# 4. Clean worktree.
[[ -z "$("${git_c[@]}" status --porcelain)" ]] || fail "worktree is dirty; commit/stash before tagging"
# 5. Local master must equal remote master.
local_sha="$("${git_c[@]}" rev-parse master)"
remote_sha="$("${git_c[@]}" rev-parse "$REMOTE/master")"
[[ "$local_sha" == "$remote_sha" ]] || fail "local master ($local_sha) != $REMOTE/master ($remote_sha)"
# 6. HEAD must be that same commit (present on remote master).
head_sha="$("${git_c[@]}" rev-parse HEAD)"
[[ "$head_sha" == "$remote_sha" ]] || fail "HEAD ($head_sha) is not $REMOTE/master; tag only commits on remote master"
# 7. Tag must not already exist locally or on the remote.
if "${git_c[@]}" rev-parse -q --verify "refs/tags/$version" >/dev/null 2>&1; then
fail "tag $version already exists locally"
fi
if "${git_c[@]}" ls-remote --tags "$REMOTE" "refs/tags/$version" | grep -q .; then
fail "tag $version already exists on $REMOTE"
fi
# Annotation message: notes file, or a minimal default.
if [[ -n "$notes_file" ]]; then
notes_arg=(-F "$notes_file")
else
notes_arg=(-m "$version")
fi
# Tests (default on; explicit --skip-tests warns). Not executed in dry-run.
tests_run="no"
if [[ "$skip_tests" -eq 1 ]]; then
printf 'release-tag: WARNING --skip-tests set; NOT running the test suite before tagging.\n' >&2
elif [[ "$dry_run" -eq 1 ]]; then
printf 'release-tag: [dry-run] would run tests: %s\n' "$TEST_CMD"
else
tests_run="yes"
( cd "$repo_root" && eval "$TEST_CMD" ) || fail "tests failed; refusing to tag"
fi
# Create (and optionally push) the annotated tag.
tag_created="no"
tag_pushed="no"
if [[ "$dry_run" -eq 1 ]]; then
printf 'release-tag: [dry-run] would create annotated tag %s at %s\n' "$version" "$head_sha"
[[ "$push" -eq 1 ]] && printf 'release-tag: [dry-run] would push %s to %s\n' "$version" "$REMOTE"
else
"${git_c[@]}" tag -a "$version" "$head_sha" "${notes_arg[@]}"
tag_created="yes"
if [[ "$push" -eq 1 ]]; then
"${git_c[@]}" push "$REMOTE" "$version"
tag_pushed="yes"
fi
fi
printf 'commit: %s\n' "$head_sha"
printf 'tag: %s\n' "$version"
printf 'tests_run: %s\n' "$tests_run"
printf 'tag_created: %s\n' "$tag_created"
printf 'tag_pushed: %s\n' "$tag_pushed"
+6
View File
@@ -274,3 +274,9 @@ Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
6. Create the annotated tag on remote `master` with release notes.
7. Push the tag.
8. Create/update release notes if the forge supports it.
Where present, `scripts/release-tag` automates this with all gates built in
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
explicit and warns.
@@ -3,6 +3,18 @@
Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is
irreversible-ish and outward-facing — fail closed on any doubt.
> If the project ships `scripts/release-tag`, prefer it — it enforces every gate
> below automatically and is safe by default (no push without `--push`,
> `--dry-run` changes nothing):
>
> ```bash
> scripts/release-tag --dry-run <vX.Y.Z>
> scripts/release-tag <vX.Y.Z> --notes-file <path>
> scripts/release-tag <vX.Y.Z> --notes-file <path> --push
> ```
>
> The manual steps below are the fallback / what the script does.
```text
Task: cut release <vX.Y.Z> from master.
+169
View File
@@ -0,0 +1,169 @@
"""Tests for scripts/release-tag (#50).
Each test builds a throwaway git repo with a LOCAL bare remote named per
RELEASE_TAG_REMOTE — no network, no pushing from the project repo, no real tags
created here. The test suite gate is stubbed via RELEASE_TAG_TEST_CMD (true =
pass, false = fail) so no real pytest/venv is needed inside the temp repo.
"""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SCRIPT_SRC = REPO / "scripts" / "release-tag"
REMOTE = "prgs"
def _git(cwd, *args):
return subprocess.run(["git", *args], cwd=str(cwd),
capture_output=True, text=True)
class _ReleaseTagCase(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.work = self.tmp / "work"
self.bare = self.tmp / "remote.git"
self.work.mkdir()
_git(self.work, "init", "-b", "master")
_git(self.work, "config", "user.email", "t@example.invalid")
_git(self.work, "config", "user.name", "Test")
(self.work / "README").write_text("hello\n")
# Install the script under test and commit it so the worktree is clean.
(self.work / "scripts").mkdir()
dst = self.work / "scripts" / "release-tag"
shutil.copy(SCRIPT_SRC, dst)
dst.chmod(0o755)
_git(self.work, "add", "README", "scripts/release-tag")
_git(self.work, "commit", "-m", "initial")
# Seed the bare remote by cloning the work repo (already has master +
# the script). Avoids `git push <remote> master`, which the harness blocks.
_git(self.tmp, "clone", "--bare", str(self.work), str(self.bare))
_git(self.work, "remote", "add", REMOTE, str(self.bare))
_git(self.work, "fetch", REMOTE, "--prune")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def rt(self, *args, test_cmd="true"):
env = dict(os.environ, RELEASE_TAG_REMOTE=REMOTE,
RELEASE_TAG_TEST_CMD=test_cmd)
proc = subprocess.run(
["bash", str(self.work / "scripts" / "release-tag"), *args],
cwd=str(self.work), capture_output=True, text=True, env=env)
return proc.returncode, proc.stdout, proc.stderr
def tag_type(self, name):
r = _git(self.work, "cat-file", "-t", name)
return r.stdout.strip()
def local_tags(self):
return _git(self.work, "tag").stdout.split()
def remote_has_tag(self, name):
r = _git(self.work, "ls-remote", "--tags", REMOTE, f"refs/tags/{name}")
return bool(r.stdout.strip())
class TestValidation(_ReleaseTagCase):
def test_accepts_valid_semver_dry_run(self):
rc, out, err = self.rt("--dry-run", "v0.4.0")
self.assertEqual(rc, 0, err)
self.assertIn("would create annotated tag v0.4.0", out)
self.assertEqual(self.local_tags(), []) # dry-run creates nothing
def test_rejects_invalid_version(self):
for bad in ("v1.2", "1.0.0", "v1.0", "release-1", "vx.y.z"):
rc, _, err = self.rt(bad)
self.assertEqual(rc, 2, bad)
self.assertIn("invalid version", err)
def test_rejects_dirty_worktree(self):
(self.work / "README").write_text("dirty\n")
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("dirty", err)
def test_rejects_non_master_branch(self):
_git(self.work, "checkout", "-b", "feat/issue-1-x")
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("not on master", err)
def test_rejects_master_remote_mismatch(self):
(self.work / "extra").write_text("x\n")
_git(self.work, "add", "extra")
_git(self.work, "commit", "-m", "local-only") # not pushed
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("master", err)
def test_rejects_existing_local_tag(self):
_git(self.work, "tag", "-a", "v0.4.0", "-m", "pre-existing")
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("already exists", err)
def test_missing_notes_file_rejected(self):
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file",
str(self.tmp / "nope.md"))
self.assertEqual(rc, 2)
self.assertIn("notes file not found", err)
class TestTagging(_ReleaseTagCase):
def test_creates_annotated_tag_not_lightweight(self):
rc, out, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 0, err)
self.assertEqual(self.tag_type("v0.4.0"), "tag") # annotated, not "commit"
self.assertIn("tag_created: yes", out)
def test_no_push_without_flag(self):
rc, out, _ = self.rt("v0.5.0", "--skip-tests")
self.assertEqual(rc, 0)
self.assertIn("tag_pushed: no", out)
self.assertFalse(self.remote_has_tag("v0.5.0"))
def test_push_only_with_flag(self):
rc, out, err = self.rt("v0.6.0", "--skip-tests", "--push")
self.assertEqual(rc, 0, err)
self.assertIn("tag_pushed: yes", out)
self.assertTrue(self.remote_has_tag("v0.6.0"))
def test_notes_file_used_as_message(self):
notes = self.tmp / "notes.md"
notes.write_text("Release v0.4.0\n\n- #50 release-tag helper\n")
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file", str(notes))
self.assertEqual(rc, 0, err)
msg = _git(self.work, "tag", "-n99", "-l", "v0.4.0").stdout
self.assertIn("release-tag helper", msg)
class TestTestsGate(_ReleaseTagCase):
def test_skip_tests_warns_and_skips(self):
rc, out, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 0)
self.assertIn("WARNING", err)
self.assertIn("tests_run: no", out)
def test_default_runs_tests_and_failure_blocks_tag(self):
rc, out, err = self.rt("v0.4.0", test_cmd="false") # tests "fail"
self.assertEqual(rc, 1)
self.assertIn("tests failed", err)
self.assertEqual(self.local_tags(), []) # no tag on failure
def test_default_runs_tests_and_passes(self):
rc, out, err = self.rt("v0.4.0", test_cmd="true")
self.assertEqual(rc, 0, err)
self.assertIn("tests_run: yes", out)
self.assertEqual(self.tag_type("v0.4.0"), "tag")
if __name__ == "__main__":
unittest.main()