Files
hermes-agent/tests/scripts/test_release_entrypoint.py
2026-09-22 02:19:38 -04:00

225 lines
9.2 KiB
Python

"""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"