Files
hermes-agent/tests/scripts/test_release_entrypoint.py
ethernet c58744e59e fix(release): link the draft at cut time, not a page that 404s until green
The stable cut already creates the draft on the claim ref before it
dispatches the gate, but the output pointed at releases/tag/<claim> and
then at releases/tag/v<version> "when it is green". GitHub serves a draft
only at an untagged-* URL, so neither link showed it. Operators read that
as "the draft appears after the build".

Take the URL gh release create prints (the release's html_url) and print
it up front, with a note that notes edited during the build survive:
edit_draft_release keeps the body and strips only the warning fence. The
v<version> URL stays, labelled as where the release lives once published.

The canary resume path had the same broken releases/tag/<tag> link; it
now uses the create output or the url field of gh release view.
2026-09-23 19:20:27 -04:00

553 lines
23 KiB
Python

"""The release entrypoint claims an attempt, cuts a draft, and dispatches the gate.
The claim is the push of one attempt ref, ``rc.<N>-vX.Y.Z``, and it pushes
exactly that ref. A version is spent only by publication; an abandoned attempt
frees its version for attempt N+1. At most one attempt, of any version, is
outstanding. 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 _advance(repo, message):
git(repo, "commit", "--allow-empty", "--quiet", "-m", message)
git(repo, "push", "--quiet", "origin", "main")
return git(repo, "rev-parse", "HEAD")
def _claim(repo, version, commit, attempt=1):
metadata = json.dumps({
"schema": 1, "version": version, "attempt": attempt, "commit": commit,
"autopublish": False, "claimEpoch": 1_790_000_000,
}, sort_keys=True, separators=(",", ":"))
ref = f"rc.{attempt}-v{version}"
git(repo, "tag", "-a", ref, commit, "-m", metadata)
git(repo, "push", "--quiet", "origin", f"refs/tags/{ref}")
def _mark(repo, version, attempt=1):
ref = f"abandoned-rc.{attempt}-v{version}"
git(repo, "tag", "-a", ref, f"rc.{attempt}-v{version}^{{commit}}", "-m", "abandoned")
git(repo, "push", "--quiet", "origin", f"refs/tags/{ref}")
def _publish_tag(repo, version, commit):
git(repo, "tag", "-a", f"v{version}", commit, "-m", "published")
git(repo, "push", "--quiet", "origin", f"refs/tags/v{version}")
def _release(repo, commit, **overrides):
from scripts.releases.entrypoint import release
arguments = {"bump": "patch", "repo": repo, "remote": "origin",
"repository": "example/hermes-agent", "execute": lambda _command: None}
return release(commit, **{**arguments, **overrides})
def _must_not_execute(command):
if command[:3] != ["gh", "run", "list"]:
pytest.fail(f"a refused release must not run {command}")
return "[]"
def test_release_claims_the_first_attempt_creates_a_draft_and_dispatches(source):
commit = git(source, "rev-parse", "HEAD")
calls = []
def execute(command):
calls.append(command)
if command[:3] == ["gh", "run", "list"]:
return json.dumps([{"databaseId": 7, "url": "https://github.com/example/hermes-agent/actions/runs/7",
"headBranch": "rc.1-v0.21.5", "status": "queued"}])
if command[:3] == ["gh", "release", "create"]:
return "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd\n"
return ""
result = _release(source, commit, execute=execute, autopublish=True)
assert result["version"] == "0.21.5"
assert result["tag"] == "rc.1-v0.21.5"
assert result["commit"] == commit
# A draft lives at the page gh reports, never at releases/tag/<claim>.
assert result["url"] == "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd"
assert result["final_url"] == "https://github.com/example/hermes-agent/releases/tag/v0.21.5"
assert git(source, "rev-parse", "rc.1-v0.21.5^{commit}") == commit
claim = json.loads(git(source, "tag", "-l", "rc.1-v0.21.5", "--format=%(contents)"))
assert isinstance(claim.pop("claimEpoch"), int)
assert claim == {
"attempt": 1,
"autopublish": True,
"commit": commit,
"schema": 1,
"version": "0.21.5",
}
assert calls[0] == ["gh", "api", "repos/example/hermes-agent/releases/generate-notes",
"-f", "tag_name=rc.1-v0.21.5"]
create = calls[1]
assert create[:4] == ["gh", "release", "create", "rc.1-v0.21.5"]
assert "--verify-tag" in create and "--draft" in create
assert "--generate-notes" not in create
assert "--notes-file" in create
assert create[-2:] == ["--title", "Hermes Agent v0.21.5"]
assert calls[2:] == [
["gh", "workflow", "run", "stable-release.yml", "--ref", "rc.1-v0.21.5",
"--repo", "example/hermes-agent", "--raw-field", "tag=rc.1-v0.21.5"],
["gh", "run", "list", "--repo", "example/hermes-agent", "--workflow", "stable-release.yml",
"--branch", "rc.1-v0.21.5", "--json", "databaseId,url,headBranch,status"],
]
assert result["run_url"] == "https://github.com/example/hermes-agent/actions/runs/7"
# The claim push names the claim ref and nothing else.
pushed = git(source, "ls-remote", "origin", "refs/tags/*")
assert pushed.splitlines() == [
f"{git(source, 'rev-parse', 'rc.1-v0.21.5')}\trefs/tags/rc.1-v0.21.5",
f"{commit}\trefs/tags/rc.1-v0.21.5^{{}}",
]
def test_release_output_names_the_wait_and_the_publish_step():
from scripts.releases.entrypoint import next_steps
result = {"version": "0.21.5", "tag": "rc.1-v0.21.5", "autopublish": False,
"run_url": "https://github.com/example/hermes-agent/actions/runs/7",
"url": "https://github.com/example/hermes-agent/releases/tag/untagged-0123abcd",
"final_url": "https://github.com/example/hermes-agent/releases/tag/v0.21.5"}
text = next_steps(result)
assert "Workflow: " + result["run_url"] in text
assert "The release workflow started on rc.1-v0.21.5." in text
# The draft is reachable before the workflow is green, not only after it.
assert text.index(result["url"]) < text.index("Wait for that workflow to finish.")
assert result["final_url"] in text
assert "python scripts/release.py publish --version 0.21.5 --remote origin" in text
automatic = next_steps({**result, "autopublish": True})
assert "Autopublish is on." in automatic
assert "publish --version" not in automatic
def test_second_cut_after_abandon_is_attempt_two_of_the_same_version(source):
first = git(source, "rev-parse", "HEAD")
_claim(source, "0.21.5", first)
_mark(source, "0.21.5")
result = _release(source, _advance(source, "fix"))
assert result["tag"] == "rc.2-v0.21.5"
assert result["version"] == "0.21.5"
@pytest.mark.parametrize("bump", ["patch", "minor"])
def test_an_outstanding_attempt_of_any_version_blocks_the_next_cut(source, bump):
from scripts.releases.entrypoint import ReleaseRefused
_claim(source, "0.21.5", git(source, "rev-parse", "HEAD"))
with pytest.raises(ReleaseRefused, match="publish or abandon it first"):
_release(source, _advance(source, "later"), bump=bump, execute=_must_not_execute)
assert git(source, "tag", "--list", "rc.*") == "rc.1-v0.21.5"
def test_a_published_attempt_no_longer_blocks(source):
commit = git(source, "rev-parse", "HEAD")
_claim(source, "0.21.5", commit)
_publish_tag(source, "0.21.5", commit)
result = _release(source, _advance(source, "later"), published=("0.21.5", commit))
assert result["tag"] == "rc.1-v0.21.6"
def test_new_attempt_must_descend_from_the_published_head(source):
from scripts.releases.entrypoint import ReleaseRefused
earlier = git(source, "rev-parse", "HEAD")
published = _advance(source, "published")
with pytest.raises(ReleaseRefused, match="does not descend from the published stable head"):
_release(source, earlier, published=("0.21.4", published), execute=_must_not_execute)
assert git(source, "tag", "--list", "rc.*") == ""
def test_an_abandoned_attempt_does_not_constrain_the_next_cut(source):
root = git(source, "rev-parse", "HEAD")
good = _advance(source, "good")
bad = _advance(source, "bad")
_claim(source, "0.21.5", bad)
_mark(source, "0.21.5")
result = _release(source, good, published=("0.21.4", root))
assert result["tag"] == "rc.2-v0.21.5"
assert result["commit"] == good
def test_refusal_names_the_run_and_both_ways_out(source, capsys):
from scripts.releases.entrypoint import ReleaseRefused
blocked = git(source, "rev-parse", "HEAD")
_claim(source, "0.21.5", blocked)
def execute(command):
assert command == ["gh", "run", "list", "--repo", "example/hermes-agent", "--workflow",
"stable-release.yml", "--branch", "rc.1-v0.21.5",
"--json", "databaseId,url,headBranch,headSha,status"]
return json.dumps([
{"databaseId": 98, "url": "https://github.com/example/hermes-agent/actions/runs/98",
"headBranch": "rc.1-v0.21.5", "headSha": "0" * 40, "status": "completed"},
{"databaseId": 99, "url": "https://github.com/example/hermes-agent/actions/runs/99",
"headBranch": "rc.1-v0.21.5", "headSha": blocked, "status": "completed"},
])
with pytest.raises(ReleaseRefused):
_release(source, _advance(source, "later"), bump="minor", execute=execute)
text = capsys.readouterr().err
assert "https://github.com/example/hermes-agent/actions/runs/99" in text
assert "runs/98" not in text
# The abandon command names the outstanding attempt's version, not the derived 0.22.0.
assert "python scripts/release.py abandon --version 0.21.5 --remote origin" in text
assert "gh run rerun 99 --failed --repo example/hermes-agent" in text
def test_refusal_for_an_attempt_that_never_started_offers_only_abandon(source, capsys):
from scripts.releases.entrypoint import ReleaseRefused
_claim(source, "0.21.5", git(source, "rev-parse", "HEAD"))
with pytest.raises(ReleaseRefused):
_release(source, _advance(source, "later"), execute=_must_not_execute)
text = capsys.readouterr().err
assert "No workflow run is listed for rc.1-v0.21.5." in text
assert "python scripts/release.py abandon --version 0.21.5 --remote origin" in text
assert "gh run rerun" not in text
def test_successive_attempts_reserve_increasing_native_epochs(source):
_release(source, git(source, "rev-parse", "HEAD"))
_mark(source, "0.21.5")
_release(source, _advance(source, "next"))
first_epoch = int(git(source, "for-each-ref", "refs/tags/rc.1-v0.21.5",
"--format=%(taggerdate:unix)"))
second_epoch = int(git(source, "for-each-ref", "refs/tags/rc.2-v0.21.5",
"--format=%(taggerdate:unix)"))
assert second_epoch > first_epoch
def test_draft_body_is_fenced_at_top_and_bottom():
from scripts.releases.draft_warning import (
WARNING_CLOSE, WARNING_OPEN, draft_body,
)
body = draft_body(version="0.21.5", attempt_ref="rc.2-v0.21.5",
notes="## What's changed\n- x")
assert body.startswith(WARNING_OPEN) and body.rstrip().endswith(WARNING_CLOSE)
assert body.count(WARNING_OPEN) == 2 and body.count(WARNING_CLOSE) == 2
assert "python scripts/release.py publish --version 0.21.5" in body
assert "python scripts/release.py abandon --version 0.21.5" in body
assert "## What's changed\n- x" in body
# The warning names the attempt and the version it would burn.
assert "rc.2-v0.21.5" in body
def test_release_fetches_generated_notes_and_creates_the_fenced_draft(source):
from scripts.releases.draft_warning import WARNING_CLOSE, WARNING_OPEN
commit = git(source, "rev-parse", "HEAD")
seen = {}
def execute(command):
if command[1:3] == ["api", "repos/example/hermes-agent/releases/generate-notes"]:
return json.dumps({"body": "## What's changed\n- x", "name": "release"})
if command[:3] == ["gh", "release", "create"]:
notes_path = command[command.index("--notes-file") + 1]
with open(notes_path, encoding="utf-8") as file:
seen["body"] = file.read()
if command[:3] == ["gh", "run", "list"]:
return json.dumps([{"databaseId": 7, "url": "https://github.com/example/hermes-agent/actions/runs/7",
"headBranch": "rc.1-v0.21.5", "status": "queued"}])
return ""
result = _release(source, commit, execute=execute)
assert result["tag"] == "rc.1-v0.21.5"
# The notes are fetched from GitHub and the draft is created from a file,
# because --generate-notes cannot place a warning below the notes.
body = seen["body"]
assert body.startswith(WARNING_OPEN) and body.rstrip().endswith(WARNING_CLOSE)
assert "## What's changed\n- x" in body
assert "python scripts/release.py publish --version 0.21.5" in body
assert "python scripts/release.py abandon --version 0.21.5" in body
def test_a_dispatch_that_never_starts_is_an_error(source):
from scripts.releases.entrypoint import ReleaseRefused
def refuse(command):
if command[1:3] == ["workflow", "run"]:
raise RuntimeError("workflow dispatch rejected")
with pytest.raises(ReleaseRefused, match="never started"):
_release(source, git(source, "rev-parse", "HEAD"), execute=refuse)
# The attempt stands outstanding until someone abandons it.
assert "rc.1-v0.21.5" in git(source, "tag", "--list")
def test_publish_and_abandon_output_name_the_result():
from scripts.releases.entrypoint import abandon_steps, publish_steps
published = publish_steps({"version": "0.21.5", "repository": "example/hermes-agent",
"run_url": "https://github.com/example/hermes-agent/actions/runs/9"})
assert "Requested publication of v0.21.5." in published
assert "Workflow: https://github.com/example/hermes-agent/actions/runs/9" in published
assert "moves the stable channel" in published
abandoned = abandon_steps({"version": "0.21.5", "tag": "rc.1-v0.21.5",
"marker": "abandoned-rc.1-v0.21.5"})
assert "Cleared rc.1-v0.21.5." in abandoned
assert "abandoned-rc.1-v0.21.5" in abandoned
assert "The next cut is rc.2-v0.21.5." in abandoned
assert "cannot be reused" not in abandoned
def test_publish_dispatches_the_sequencer():
from scripts.releases.entrypoint import ReleaseRefused, publish
calls = []
published = publish("0.21.5", repository="example/hermes-agent", dispatch=calls.append)
assert published["requested"] == "v0.21.5"
assert published["version"] == "0.21.5"
assert published["repository"] == "example/hermes-agent"
assert calls == [
["gh", "workflow", "run", "stable-release-publication.yml",
"--repo", "example/hermes-agent", "--raw-field", "version=0.21.5"],
]
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_publish_preflight_finds_the_draft_on_the_outstanding_attempt(source):
from scripts.releases.entrypoint import ReleaseRefused, publish
_claim(source, "0.21.5", git(source, "rev-parse", "HEAD"))
calls = []
def inspect(command):
tag = command[3]
if tag != "rc.1-v0.21.5":
raise ReleaseRefused("release not found")
return json.dumps({"tagName": tag, "isDraft": True, "isPrerelease": False})
publish("0.21.5", repository="example/hermes-agent", dispatch=calls.append,
inspect=inspect, head_version=lambda: None,
repo=source, remote="origin")
assert calls[-1][-1] == "version=0.21.5"
# A draft left on the old v{version}-rc shape is not the publish draft.
def old_shape(command):
tag = command[3]
if tag != "v0.21.5-rc":
raise ReleaseRefused("release not found")
return json.dumps({"tagName": tag, "isDraft": True, "isPrerelease": False})
with pytest.raises(ReleaseRefused, match="burned or has no release draft"):
publish("0.21.5", repository="example/hermes-agent",
dispatch=lambda _command: pytest.fail("must not dispatch"),
inspect=old_shape, head_version=lambda: None,
repo=source, remote="origin")
def _abandon(repo, version, *, draft=None, calls=None):
from scripts.releases.entrypoint import ReleaseRefused, abandon
def inspect(command):
if draft is None or command[3] != draft["tagName"]:
raise ReleaseRefused("release not found")
return json.dumps(draft)
return abandon(version, repo=repo, remote="origin", repository="example/hermes-agent",
delete=(calls if calls is not None else []).append, inspect=inspect)
def test_abandon_of_a_draft_deletes_it_writes_the_marker_and_frees_the_version(source):
_claim(source, "0.21.5", git(source, "rev-parse", "HEAD"))
calls = []
result = _abandon(source, "0.21.5", calls=calls,
draft={"tagName": "rc.1-v0.21.5", "isDraft": True, "isPrerelease": False})
assert result == {"version": "0.21.5", "tag": "rc.1-v0.21.5",
"marker": "abandoned-rc.1-v0.21.5", "repository": "example/hermes-agent"}
assert calls == [["gh", "release", "delete", "rc.1-v0.21.5", "--repo", "example/hermes-agent", "--yes"]]
remote = git(source, "ls-remote", "origin", "refs/tags/*")
assert "refs/tags/abandoned-rc.1-v0.21.5" in remote
assert "refs/tags/rc.1-v0.21.5" in remote
marker = json.loads(git(source, "tag", "-l", "abandoned-rc.1-v0.21.5", "--format=%(contents)"))
assert marker == {"attempt": 1, "attemptRef": "rc.1-v0.21.5", "schema": 1, "version": "0.21.5"}
assert git(source, "rev-parse", "abandoned-rc.1-v0.21.5^{commit}") == git(
source, "rev-parse", "rc.1-v0.21.5^{commit}")
assert _release(source, _advance(source, "fix"))["tag"] == "rc.2-v0.21.5"
def test_abandon_of_a_draftless_burned_attempt_writes_the_marker(source):
_claim(source, "0.21.5", git(source, "rev-parse", "HEAD"))
calls = []
assert _abandon(source, "0.21.5", calls=calls)["marker"] == "abandoned-rc.1-v0.21.5"
assert calls == []
@pytest.mark.parametrize("version", ["0.21.5", "0.22.0"])
def test_abandon_refuses_without_an_outstanding_attempt_of_that_version(source, version):
from scripts.releases.entrypoint import ReleaseRefused
_claim(source, "0.21.5", git(source, "rev-parse", "HEAD"))
if version == "0.21.5":
_mark(source, "0.21.5")
with pytest.raises(ReleaseRefused, match="no outstanding attempt to abandon"):
_abandon(source, version)
def test_abandon_refuses_a_release_that_was_published(source):
from scripts.releases.entrypoint import ReleaseRefused
_claim(source, "0.21.5", git(source, "rev-parse", "HEAD"))
with pytest.raises(ReleaseRefused, match="published and cannot be abandoned"):
_abandon(source, "0.21.5",
draft={"tagName": "rc.1-v0.21.5", "isDraft": False, "isPrerelease": False})
assert "abandoned-rc" not in git(source, "ls-remote", "origin", "refs/tags/*")
def test_abandon_clears_one_of_two_attempts_a_concurrent_cut_left(source):
from scripts.releases.entrypoint import ReleaseRefused
commit = git(source, "rev-parse", "HEAD")
_claim(source, "0.21.5", commit)
_claim(source, "0.22.0", commit)
assert _abandon(source, "0.22.0")["marker"] == "abandoned-rc.1-v0.22.0"
with pytest.raises(ReleaseRefused, match="rc.1-v0.21.5 is outstanding"):
_release(source, _advance(source, "later"), execute=_must_not_execute)
def _clones(source, tmp_path):
origin = git(source, "remote", "get-url", "origin")
clones = []
for name in ("left", "right"):
clone = tmp_path / name
git(tmp_path, "clone", "--quiet", origin, str(clone))
git(clone, "config", "user.name", "Test")
git(clone, "config", "user.email", "test@example.test")
clones.append(clone)
return origin, clones
def test_concurrent_claim_loser_reports_the_remote_winner_and_the_version_stays_free(
source, tmp_path, monkeypatch):
from scripts.releases import entrypoint
from scripts.releases.versioning import derive_next_version, next_attempt
old = git(source, "rev-parse", "HEAD")
new = _advance(source, "later")
origin, (left, right) = _clones(source, tmp_path)
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/rc.1-v0.21.5":
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] = _release(repo, commit)
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", "rc.1-v0.21.5^{commit}") == new
fresh = tmp_path / "fresh"
git(tmp_path, "clone", "--quiet", origin, str(fresh))
refs = git(fresh, "tag", "--list", "rc.*").splitlines()
assert derive_next_version(published=None, bump="patch") == "0.21.5"
assert next_attempt("0.21.5", refs) == 2
def test_a_concurrent_cut_of_another_version_is_stopped_before_dispatch(
source, tmp_path, monkeypatch):
from scripts.releases import entrypoint
commit = git(source, "rev-parse", "HEAD")
_origin, (left, right) = _clones(source, tmp_path)
original_git = entrypoint._git
def interleaved_git(repo, *args):
# The other maintainer cuts 0.22.0 after this cut passed its pre-check.
if repo == left and args[:2] == ("push", "origin") and args[-1] == "refs/tags/rc.1-v0.21.5":
monkeypatch.setattr(entrypoint, "_git", original_git)
_release(right, commit, bump="minor")
return original_git(repo, *args)
monkeypatch.setattr(entrypoint, "_git", interleaved_git)
with pytest.raises(entrypoint.ReleaseRefused, match="more than one outstanding attempt"):
_release(left, commit, execute=lambda command: pytest.fail(f"must not run {command}"))
assert {"rc.1-v0.21.5", "rc.1-v0.22.0"} <= set(git(left, "tag", "--list", "rc.*").splitlines())