"""The release entrypoint claims a version, cuts a draft, and dispatches the gate. The claim is the tag push, and it pushes exactly the claim ref. A release that never starts is an error, not a warning the operator has to notice. """ import json import subprocess import threading import pytest def git(repo, *args): return subprocess.check_output(["git", *args], cwd=repo, text=True, encoding="utf-8").strip() @pytest.fixture def source(tmp_path): origin = tmp_path / "origin.git" repo = tmp_path / "source" origin.mkdir() repo.mkdir() git(origin, "init", "--bare", "--quiet") git(repo, "init", "--initial-branch=main", "--quiet") git(repo, "config", "user.name", "Test") git(repo, "config", "user.email", "test@example.test") (repo / "pyproject.toml").write_text('[project]\nname="fixture"\nversion="0.0.0"\n', encoding="utf-8") git(repo, "add", "pyproject.toml") git(repo, "commit", "--quiet", "-m", "Initial") git(repo, "remote", "add", "origin", str(origin)) git(repo, "push", "--quiet", "-u", "origin", "main") return repo def _claim(repo, version, commit): metadata = json.dumps({ "schema": 1, "version": version, "commit": commit, "autopublish": False, "claimEpoch": 1_790_000_000, }, sort_keys=True, separators=(",", ":")) git(repo, "tag", "-a", f"v{version}-rc", commit, "-m", metadata) git(repo, "push", "--quiet", "origin", f"refs/tags/v{version}-rc") def test_release_claims_the_derived_version_creates_a_draft_and_dispatches(source): from scripts.releases.entrypoint import release commit = git(source, "rev-parse", "HEAD") calls = [] result = release(commit, bump="patch", repo=source, remote="origin", repository="example/hermes-agent", execute=calls.append, autopublish=True) assert result["version"] == "0.21.5" assert result["tag"] == "v0.21.5-rc" assert result["commit"] == commit assert result["url"] == "https://github.com/example/hermes-agent/releases/tag/v0.21.5-rc" assert git(source, "rev-parse", "v0.21.5-rc^{commit}") == commit claim = json.loads(git(source, "tag", "-l", "v0.21.5-rc", "--format=%(contents)")) assert isinstance(claim.pop("claimEpoch"), int) assert claim == { "autopublish": True, "commit": commit, "schema": 1, "version": "0.21.5", } assert calls == [ ["gh", "release", "create", "v0.21.5-rc", "--repo", "example/hermes-agent", "--verify-tag", "--draft", "--generate-notes", "--title", "Hermes Agent v0.21.5"], ["gh", "workflow", "run", "stable-release.yml", "--ref", "v0.21.5-rc", "--repo", "example/hermes-agent", "--raw-field", "tag=v0.21.5-rc"], ] # The claim push names the claim ref and nothing else. pushed = git(source, "ls-remote", "origin", "refs/tags/v0.21.5-rc") assert pushed.startswith(git(source, "rev-parse", "v0.21.5-rc")) def test_a_commit_behind_an_outstanding_claim_is_refused(source): from scripts.releases.entrypoint import ReleaseRefused, _require_ancestry, release earlier = git(source, "rev-parse", "HEAD") git(source, "commit", "--allow-empty", "--quiet", "-m", "later") git(source, "push", "--quiet", "origin", "main") _claim(source, "0.21.5", git(source, "rev-parse", "HEAD")) with pytest.raises(ReleaseRefused, match="0\\.21\\.5 already claimed"): release(earlier, bump="patch", repo=source, remote="origin", repository="example/hermes-agent", execute=lambda _cmd: pytest.fail("must not execute")) assert "v0.21.6-rc" not in git(source, "tag", "--list") _require_ancestry(source, git(source, "rev-parse", "v0.21.5-rc^{commit}")) def test_successive_claims_reserve_increasing_native_epochs(source): from scripts.releases.entrypoint import release first = git(source, "rev-parse", "HEAD") release(first, bump="patch", repo=source, remote="origin", repository="example/hermes-agent", execute=lambda _command: None) git(source, "commit", "--allow-empty", "--quiet", "-m", "next") git(source, "push", "--quiet", "origin", "main") second = git(source, "rev-parse", "HEAD") release(second, bump="patch", repo=source, remote="origin", repository="example/hermes-agent", execute=lambda _command: None) first_epoch = int(git(source, "for-each-ref", "refs/tags/v0.21.5-rc", "--format=%(taggerdate:unix)")) second_epoch = int(git(source, "for-each-ref", "refs/tags/v0.21.6-rc", "--format=%(taggerdate:unix)")) assert second_epoch > first_epoch def test_a_dispatch_that_never_starts_is_an_error(source): from scripts.releases.entrypoint import ReleaseRefused, release def refuse(command): if command[1:3] == ["workflow", "run"]: raise RuntimeError("workflow dispatch rejected") with pytest.raises(ReleaseRefused, match="never started"): release(git(source, "rev-parse", "HEAD"), bump="patch", repo=source, remote="origin", repository="example/hermes-agent", execute=refuse) # The claim stands unresolved; reconciliation burns it only after its grace period. assert "v0.21.5-rc" in git(source, "tag", "--list") def test_publish_dispatches_the_sequencer_and_abandon_keeps_the_claim(source): from scripts.releases.entrypoint import ReleaseRefused, abandon, publish commit = git(source, "rev-parse", "HEAD") _claim(source, "0.21.5", commit) calls = [] published = publish("0.21.5", repository="example/hermes-agent", dispatch=calls.append) assert published["requested"] == "v0.21.5" assert calls == [[ "gh", "workflow", "run", "stable-release-publication.yml", "--repo", "example/hermes-agent", "--raw-field", "version=0.21.5", ]] def inspect(command): if "v0.21.5" in command: return json.dumps({"tagName": "v0.21.5", "isDraft": True, "isPrerelease": False}) raise ReleaseRefused("not found") abandoned = abandon("0.21.5", repo=source, repository="example/hermes-agent", delete=calls.append, inspect=inspect) assert abandoned["burned"] == "0.21.5" assert calls[-1] == ["gh", "release", "delete", "v0.21.5", "--repo", "example/hermes-agent", "--yes"] assert "v0.21.5-rc" in git(source, "tag", "--list") with pytest.raises(ReleaseRefused, match="burned or superseded by 0\\.21\\.6"): publish( "0.21.5", repository="example/hermes-agent", dispatch=lambda _command: pytest.fail("superseded publish must not dispatch"), inspect=lambda _command: pytest.fail("superseded publish must not inspect drafts"), head_version=lambda: "0.21.6", ) def test_concurrent_claim_loser_reports_the_remote_winner_and_the_version_stays_spent( source, tmp_path, monkeypatch): from scripts.releases import entrypoint from scripts.releases.versioning import derive_next_version old = git(source, "rev-parse", "HEAD") git(source, "commit", "--allow-empty", "--quiet", "-m", "later") git(source, "push", "--quiet", "origin", "main") new = git(source, "rev-parse", "HEAD") origin = git(source, "remote", "get-url", "origin") left, right = tmp_path / "left", tmp_path / "right" git(tmp_path, "clone", "--quiet", origin, str(left)) git(tmp_path, "clone", "--quiet", origin, str(right)) for clone in (left, right): git(clone, "config", "user.name", "Test") git(clone, "config", "user.email", "test@example.test") git(left, "checkout", "--quiet", old) barrier = threading.Barrier(2) winner_pushed = threading.Event() original_git = entrypoint._git def racing_git(repo, *args): if args[:2] == ("push", "origin") and args[-1] == "refs/tags/v0.21.5-rc": barrier.wait(timeout=10) if repo == left: if not winner_pushed.wait(timeout=10): raise TimeoutError("winning claim did not finish") else: try: return original_git(repo, *args) finally: winner_pushed.set() return original_git(repo, *args) monkeypatch.setattr(entrypoint, "_git", racing_git) outcomes = {} def claim(name, repo, commit): try: outcomes[name] = entrypoint.release( commit, bump="patch", repo=repo, remote="origin", repository="example/hermes-agent", execute=lambda _command: None, ) except Exception as error: outcomes[name] = error threads = [ threading.Thread(target=claim, args=("old", left, old)), threading.Thread(target=claim, args=("new", right, new)), ] for thread in threads: thread.start() for thread in threads: thread.join(timeout=15) assert outcomes["new"]["commit"] == new assert isinstance(outcomes["old"], entrypoint.ReleaseRefused) assert "was claimed by Test" in str(outcomes["old"]) assert new in str(outcomes["old"]) assert git(left, "rev-parse", "v0.21.5-rc^{commit}") == new fresh = tmp_path / "fresh" git(tmp_path, "clone", "--quiet", origin, str(fresh)) claims = git(fresh, "tag", "--list", "v*-rc").splitlines() assert derive_next_version(published=None, claims=claims, bump="patch") == "0.21.6"