From 8a4abb0dd772be97b99e3bb5124c50dacdd88643 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:03:15 -0500 Subject: [PATCH] Refuse to tag a VERSION that is not a release version reusable-tag-on-release-merge tagged whatever VERSION said, verbatim: any string that is a legal git ref became a tag and a GitHub Release. Versions are named by hand everywhere in this org, so a placeholder sitting in VERSION between releases is the normal state, and a burned tag cannot be reused. audiocomponents mitigated this locally with a validate job; every other consumer is unguarded. The grammar check moves into the reusable, in the step that already reads VERSION, so consumers get it without a local job. It is the same grammar the prepare-release PR and the publish chain accept. tests/test_tag_on_release_merge.py lifts the step's shell out of the YAML and runs it in a throwaway git repository under bash -e, the shell GitHub uses. Closes #32 --- .../reusable-tag-on-release-merge.yml | 11 ++ tests/test_tag_on_release_merge.py | 178 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 tests/test_tag_on_release_merge.py diff --git a/.github/workflows/reusable-tag-on-release-merge.yml b/.github/workflows/reusable-tag-on-release-merge.yml index 076c681..09f4975 100644 --- a/.github/workflows/reusable-tag-on-release-merge.yml +++ b/.github/workflows/reusable-tag-on-release-merge.yml @@ -25,6 +25,17 @@ jobs: echo "changed=false" >> "$GITHUB_OUTPUT"; exit 0 fi V=$(tr -d '[:space:]' < VERSION) + # VERSION is tagged verbatim, and any string that is a legal git ref + # would become a tag and a GitHub Release. Versions are named by hand + # everywhere in this org, so a placeholder sitting in VERSION between + # releases is the normal state, not an edge case -- and a burned tag + # cannot be reused. Refuse anything that is not a release version, + # using the same grammar the prepare-release PR and the publish chain + # accept (.github#32). + if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+|\.dev[0-9]+)?$ ]]; then + echo "::error::VERSION is '$V', not a release version X.Y.Z[{a|b|rc}N|.devN]; refusing to tag." + exit 1 + fi if git rev-parse -q --verify "refs/tags/v$V" >/dev/null; then echo "Tag v$V already exists; nothing to do." echo "changed=false" >> "$GITHUB_OUTPUT"; exit 0 diff --git a/tests/test_tag_on_release_merge.py b/tests/test_tag_on_release_merge.py new file mode 100644 index 0000000..9d19804 --- /dev/null +++ b/tests/test_tag_on_release_merge.py @@ -0,0 +1,178 @@ +"""Tests for .github/workflows/reusable-tag-on-release-merge.yml. + +The step under test is shell, not Python, so these lift the `run:` block out +of the YAML and run it in a throwaway git repository under the same shell +GitHub uses (`bash -e`). What is tested is the workflow's own text, not a +transcription of it. +""" + +from __future__ import annotations + +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +TAG_WORKFLOW = REPO / ".github/workflows/reusable-tag-on-release-merge.yml" + + +def detect_step_shell() -> str: + """The `run:` body of the Detect a VERSION change step.""" + text = TAG_WORKFLOW.read_text(encoding="utf-8") + start = text.index("- name: Detect a VERSION change") + block = text[start:] + begin = block.index("run: |\n") + len("run: |\n") + end = block.index("\n\n - name:", begin) + return textwrap.dedent(block[begin:end]) + + +def git(cwd: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True) + + +def run_detect(version: str, *, previous: str = "0.1.0") -> subprocess.CompletedProcess: + """Land `version` in VERSION on top of `previous`, then run the step.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + git(root, "init", "-q", "-b", "main") + git(root, "config", "user.email", "test@example.invalid") + git(root, "config", "user.name", "Test") + + (root / "VERSION").write_text(previous + "\n", encoding="utf-8") + git(root, "add", "VERSION") + git(root, "commit", "-q", "-m", "first") + + (root / "VERSION").write_text(version + "\n", encoding="utf-8") + git(root, "add", "VERSION") + git(root, "commit", "-q", "-m", "release") + + script = root / "detect.sh" + script.write_text(detect_step_shell(), encoding="utf-8") + output = root / "github_output" + output.touch() + + result = subprocess.run( + ["bash", "-e", str(script)], + cwd=root, + env={"PATH": "/usr/bin:/bin", "GITHUB_OUTPUT": str(output)}, + capture_output=True, + text=True, + check=False, + ) + result.github_output = output.read_text(encoding="utf-8") # type: ignore[attr-defined] + return result + + +class RefusedVersionTests(unittest.TestCase): + """.github#32: a placeholder must not become a tag and a Release.""" + + def assert_refused(self, version: str) -> None: + result = run_detect(version) + self.assertEqual(result.returncode, 1, f"{version!r} was not refused") + self.assertIn("refusing to tag", result.stdout + result.stderr) + self.assertNotIn("changed=true", result.github_output) + + def test_placeholder_is_refused(self): + self.assert_refused("0.0.0-PLACEHOLDER-BRAD-NAMES-THIS") + + def test_leading_v_is_refused(self): + self.assert_refused("v0.2.0") + + def test_two_component_version_is_refused(self): + self.assert_refused("0.2") + + def test_empty_version_is_refused(self): + self.assert_refused("") + + def test_pep440_illegal_prerelease_is_refused(self): + self.assert_refused("0.2.0-rc1") + + +class AcceptedVersionTests(unittest.TestCase): + def assert_accepted(self, version: str) -> None: + result = run_detect(version) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed=true", result.github_output) + self.assertIn(f"version={version}", result.github_output) + + def test_release_version(self): + self.assert_accepted("0.2.0") + + def test_release_candidate(self): + self.assert_accepted("0.2.0rc1") + + def test_dev_release(self): + self.assert_accepted("0.2.0.dev1") + + def test_beta_with_multi_digit_components(self): + self.assert_accepted("1.10.3b2") + + +class UnchangedVersionTests(unittest.TestCase): + def test_an_unchanged_version_still_does_nothing(self): + """The guard must not fire on a commit that did not touch VERSION.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + git(root, "init", "-q", "-b", "main") + git(root, "config", "user.email", "test@example.invalid") + git(root, "config", "user.name", "Test") + (root / "VERSION").write_text("0.0.0-PLACEHOLDER\n", encoding="utf-8") + git(root, "add", "VERSION") + git(root, "commit", "-q", "-m", "first") + (root / "README.md").write_text("unrelated\n", encoding="utf-8") + git(root, "add", "README.md") + git(root, "commit", "-q", "-m", "second") + + script = root / "detect.sh" + script.write_text(detect_step_shell(), encoding="utf-8") + output = root / "github_output" + output.touch() + + result = subprocess.run( + ["bash", "-e", str(script)], + cwd=root, + env={"PATH": "/usr/bin:/bin", "GITHUB_OUTPUT": str(output)}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed=false", output.read_text(encoding="utf-8")) + + +class ExistingTagTests(unittest.TestCase): + def test_an_already_tagged_version_is_a_no_op(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + git(root, "init", "-q", "-b", "main") + git(root, "config", "user.email", "test@example.invalid") + git(root, "config", "user.name", "Test") + (root / "VERSION").write_text("0.1.0\n", encoding="utf-8") + git(root, "add", "VERSION") + git(root, "commit", "-q", "-m", "first") + (root / "VERSION").write_text("0.2.0\n", encoding="utf-8") + git(root, "add", "VERSION") + git(root, "commit", "-q", "-m", "release") + git(root, "tag", "v0.2.0") + + script = root / "detect.sh" + script.write_text(detect_step_shell(), encoding="utf-8") + output = root / "github_output" + output.touch() + + result = subprocess.run( + ["bash", "-e", str(script)], + cwd=root, + env={"PATH": "/usr/bin:/bin", "GITHUB_OUTPUT": str(output)}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("changed=false", output.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main()