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:
@@ -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()
|
||||
Reference in New Issue
Block a user