From c4e539c7f78eaa6bc28dc5a3d5b2c339a626ae59 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 2 Jul 2026 04:21:21 -0400 Subject: [PATCH] feat: add scripts/release-tag automation helper (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- docs/llm-workflow-runbooks.md | 12 ++ scripts/release-tag | 139 ++++++++++++++ skills/llm-project-workflow/SKILL.md | 6 + .../templates/release-tag.md | 12 ++ tests/test_release_tag.py | 169 ++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100755 scripts/release-tag create mode 100644 tests/test_release_tag.py diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index b7ff2f4..838914b 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -363,6 +363,18 @@ Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`] 6. `git tag -a prgs/master -m ""`. 7. `git push prgs `; add release notes if the forge supports it. +`scripts/release-tag` automates steps 1–7 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 diff --git a/scripts/release-tag b/scripts/release-tag new file mode 100755 index 0000000..2b708d7 --- /dev/null +++ b/scripts/release-tag @@ -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 ] + +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 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" diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index 5f5ca61..aaf2bcf 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -250,3 +250,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. diff --git a/skills/llm-project-workflow/templates/release-tag.md b/skills/llm-project-workflow/templates/release-tag.md index 895695c..4ed7264 100644 --- a/skills/llm-project-workflow/templates/release-tag.md +++ b/skills/llm-project-workflow/templates/release-tag.md @@ -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 +> scripts/release-tag --notes-file +> scripts/release-tag --notes-file --push +> ``` +> +> The manual steps below are the fallback / what the script does. + ```text Task: cut release from master. diff --git a/tests/test_release_tag.py b/tests/test_release_tag.py new file mode 100644 index 0000000..2c7700a --- /dev/null +++ b/tests/test_release_tag.py @@ -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 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()