diff --git a/scripts/releases/entrypoint.py b/scripts/releases/entrypoint.py index 0c6287062d..5003658eac 100644 --- a/scripts/releases/entrypoint.py +++ b/scripts/releases/entrypoint.py @@ -1,20 +1,26 @@ -"""The thin release entrypoint: claim a version, cut the draft, dispatch the gate. +"""The thin release entrypoint: claim an attempt, cut the draft, dispatch the gate. -Nothing here builds. The claim is an annotated ``-rc`` tag pushed as exactly -that ref, and a dispatch that never starts is an error — the claim stays, -because a burned version is never retried under the same number. +Nothing here builds. The claim is an annotated attempt ref ``rc.-vX.Y.Z`` +pushed as exactly that ref. A version is spent only by publication: an attempt +that fails is abandoned with a marker ref, and the next cut is attempt N+1 of +the same version. At most one attempt, of any version, is outstanding. """ from __future__ import annotations import json import os import subprocess +import sys import time from pathlib import Path -from scripts.releases.versioning import SEED, derive_next_version +from scripts.releases.versioning import ( + SEED, attempt_ref, derive_next_version, next_attempt, parse_attempt_ref, parse_marker_ref, +) WORKFLOW = "stable-release.yml" +# A fetch refspec may hold one ``*``; the parsers filter what the globs over-match. +_ATTEMPT_GLOBS = ("rc.*", "abandoned-rc.*") class ReleaseRefused(RuntimeError): @@ -26,8 +32,10 @@ def _git(repo: Path, *args: str) -> str: def _claims(repo: Path) -> list[str]: - listed = _git(repo, "tag", "--list", "v*-rc") - return [tag for tag in listed.splitlines() if tag] + """Every local attempt ref and abandon marker ref.""" + listed = _git(repo, "tag", "--list", *_ATTEMPT_GLOBS) + return [ref for ref in listed.splitlines() + if parse_attempt_ref(ref) or parse_marker_ref(ref)] def _claim_commit(repo: Path, tag: str) -> str: @@ -38,7 +46,7 @@ def _refresh_claims(repo: Path, remote: str) -> None: _git( repo, "fetch", remote, "+refs/heads/main:refs/remotes/hermes-release/main", - "+refs/tags/v*-rc:refs/tags/v*-rc", + *(f"+refs/tags/{glob}:refs/tags/{glob}" for glob in _ATTEMPT_GLOBS), ) @@ -65,54 +73,106 @@ def _claim_collision(repo: Path, remote: str, tag: str, error: Exception) -> Rel return ReleaseRefused(f"{tag} was claimed by {actor} at {when} for {commit}") -def _highest_claim(repo: Path) -> tuple[str, str] | None: - """The highest-version outstanding claim, as (version, commit).""" - from scripts.releases.versioning import version_from_tag - - best: tuple[list[int], str, str] | None = None - for tag in _claims(repo): - version = version_from_tag(tag[:-3]) if tag.endswith("-rc") else None - if version is None: +def _outstanding_attempts(repo: Path, remote: str) -> list[tuple[str, int, str]]: + """Attempts with no abandon marker whose version has no final tag on ``remote``.""" + refs = _claims(repo) + cleared = {parsed for parsed in map(parse_marker_ref, refs) if parsed} + published: dict[str, bool] = {} + outstanding = [] + for ref in refs: + parsed = parse_attempt_ref(ref) + if parsed is None or parsed in cleared: continue - key = [int(part) for part in version.split(".")] - if best is None or key > best[0]: - best = (key, version, _claim_commit(repo, tag)) - return None if best is None else (best[1], best[2]) + version, attempt = parsed + if version not in published: + published[version] = bool(_git(repo, "ls-remote", remote, f"refs/tags/v{version}")) + if not published[version]: + outstanding.append((version, attempt, ref)) + return outstanding -def _require_ancestry(repo: Path, commit: str) -> None: - """A claim's commit must descend from the highest outstanding claim's.""" - highest = _highest_claim(repo) - if highest is None: +def _outstanding_attempt(repo: Path, remote: str) -> tuple[str, int, str] | None: + """The one outstanding attempt as ``(version, attempt, ref)``, or None.""" + outstanding = _outstanding_attempts(repo, remote) + if len(outstanding) > 1: + refs = ", ".join(ref for *_rest, ref in outstanding) + raise ReleaseRefused( + f"more than one outstanding attempt ({refs}) — abandon all but one before releasing") + return outstanding[0] if outstanding else None + + +def _attempt_run(execute, repository: str, ref: str, commit: str) -> dict | None: + """The workflow run for ``ref`` at ``commit``, or None when none is listed.""" + raw = execute([ + "gh", "run", "list", "--repo", repository, "--workflow", WORKFLOW, + "--branch", ref, "--json", "databaseId,url,headBranch,headSha,status", + ]) + try: + rows = json.loads(raw or "[]") + except json.JSONDecodeError: + return None + return next((row for row in rows if isinstance(row, dict) and row.get("headSha") == commit + and row.get("url") and row.get("databaseId") is not None), None) + + +def _refuse_outstanding(outstanding: tuple[str, int, str], *, repo: Path, remote: str, + repository: str, execute) -> ReleaseRefused: + """Print the blocking run and both ways out, then return the refusal to raise.""" + version, _attempt, ref = outstanding + run = _attempt_run(execute, repository, ref, _claim_commit(repo, ref)) + lines = [f"{ref} is outstanding: it has no abandon marker and v{version} is not published."] + lines.append(f"Workflow: {run['url']}" if run else f"No workflow run is listed for {ref}.") + lines += ["If the attempt is unfixable, abandon it:", + f" python scripts/release.py abandon --version {version} --remote {remote}"] + if run: + lines += ["If the attempt is fixable, rerun its failed jobs:", + f" gh run rerun {run['databaseId']} --failed --repo {repository}"] + print("\n".join(lines), file=sys.stderr) + return ReleaseRefused(f"{ref} is outstanding — publish or abandon it first") + + +def _require_ancestry(repo: Path, commit: str, published_commit: str | None) -> None: + """A new attempt descends from the published stable head; abandoned attempts do not bind it.""" + if published_commit is None: return - version, claimed = highest ancestor = subprocess.run( - ["git", "merge-base", "--is-ancestor", claimed, commit], cwd=repo, capture_output=True) + ["git", "merge-base", "--is-ancestor", published_commit, commit], cwd=repo, capture_output=True) if ancestor.returncode != 0: raise ReleaseRefused( - f"{version} already claimed at {claimed} — publish or abandon it first") + f"{commit} does not descend from the published stable head {published_commit}") def _next_claim_epoch(repo: Path) -> int: - epochs = _git(repo, "for-each-ref", "refs/tags/v*-rc", "--format=%(taggerdate:unix)") - previous = [int(value) for value in epochs.splitlines() if value.isdigit()] + epochs = _git(repo, "for-each-ref", "refs/tags/rc.*", "--format=%(refname:strip=2) %(taggerdate:unix)") + previous = [int(stamp) for ref, _, stamp in (line.partition(" ") for line in epochs.splitlines()) + if parse_attempt_ref(ref) and stamp.isdigit()] return max(int(time.time()), max(previous, default=0) + 1) def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str, - execute, autopublish: bool = False, published: str = SEED) -> dict: - """Claim the derived version, cut its draft, and start the gate.""" + execute, autopublish: bool = False, + published: tuple[str, str | None] = (SEED, None)) -> dict: + """Claim the next attempt of the derived version, cut its draft, and start the gate. + + ``published`` is the stable channel's ``(version, commit)``; the commit is + None before the first publication. + """ _refresh_claims(repo, remote) _require_remote_main(repo, commit) - _require_ancestry(repo, commit) - version = derive_next_version( - published=published, claims=_claims(repo), bump=bump, - ) - tag = f"v{version}-rc" + outstanding = _outstanding_attempt(repo, remote) + if outstanding is not None: + raise _refuse_outstanding(outstanding, repo=repo, remote=remote, + repository=repository, execute=execute) + published_version, published_commit = published + _require_ancestry(repo, commit, published_commit) + version = derive_next_version(published=published_version, bump=bump) + attempt = next_attempt(version, _claims(repo)) + tag = attempt_ref(version, attempt) claim_epoch = _next_claim_epoch(repo) claim = json.dumps({ "schema": 1, "version": version, + "attempt": attempt, "commit": commit, "autopublish": autopublish, "claimEpoch": claim_epoch, @@ -133,6 +193,10 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str, if (remote_ref.get(ref) != _git(repo, "rev-parse", ref) or remote_ref.get(f"{ref}^{{}}") != commit): raise ReleaseRefused(f"claim {tag} did not persist with exact remote custody") + # The pre-check and the push are not one atomic step: a concurrent cut of a + # different version passes the same check. Re-read before anything starts. + _refresh_claims(repo, remote) + _outstanding_attempt(repo, remote) url = f"https://github.com/{repository}/releases/tag/{tag}" try: execute([ @@ -235,7 +299,7 @@ def next_steps(result: dict) -> str: """Say what started, what the operator waits for, and the next action.""" version = result["version"] lines = [ - f"Claimed v{version}. The release workflow started on {result['tag']}.", + f"Claimed {result['tag']} for v{version}. The release workflow started on {result['tag']}.", f"Workflow: {result['run_url']}" if result.get("run_url") else "Workflow: the run is not listed yet. Open the Actions tab for this claim.", "Wait for that workflow to finish. It builds and tests this commit.", f"When it is green, the release notes are at {result['final_url']}.", @@ -266,11 +330,11 @@ def cmd_release(args) -> None: raise RuntimeError(completed.stderr.strip() or "release command failed") return completed.stdout - from scripts.releases.versioning import published_stable_version + from scripts.releases.versioning import published_stable_identity result = release( commit, bump=args.bump, repo=repo, remote=remote, repository=repository, execute=execute, autopublish=args.autopublish, - published=published_stable_version(repository), + published=published_stable_identity(repository), ) print(result["url"]) print(next_steps(result)) diff --git a/scripts/releases/versioning.py b/scripts/releases/versioning.py index aacd559fef..d03f987c78 100644 --- a/scripts/releases/versioning.py +++ b/scripts/releases/versioning.py @@ -1,8 +1,8 @@ -"""Ref name to version, and the next version from the release family. +"""Ref name to version, and the next version from the published stable head. -The family is the published stable head, then outstanding ``-rc`` claims, then -the seed when both are empty. CalVer tags and receipt tags are excluded: a -CalVer tag is a valid three-component version and would win every ``max()``. +A version is spent only by publication. Attempt refs (``rc.-vX.Y.Z``) and +their abandon markers number attempts within one version; they never move the +line. CalVer tags, canary identities and receipt namespaces are not versions. """ from __future__ import annotations @@ -103,12 +103,6 @@ def marker_ref(version: str, attempt: int) -> str: return _MARKER_PREFIX + attempt_ref(version, attempt) -def _claim_version(tag: str) -> str | None: - if isinstance(tag, str) and tag.endswith("-rc"): - return version_from_tag(tag[:-3]) - return None - - def _bump(version: str, bump: str) -> str: if bump not in BUMPS: raise ValueError(f"unknown bump {bump!r}") @@ -120,13 +114,21 @@ def _bump(version: str, bump: str) -> str: return f"{major}.{minor}.{patch + 1}" -def derive_next_version(*, published: str | None, claims: list[str], bump: str) -> str: - """The next version: max of the family, then the bump. +def derive_next_version(*, published: str | None, bump: str) -> str: + """The next version from the published head, or the seed before one exists. - ``claims`` may contain anything a tag list contains. Only ``v-rc`` - entries count; CalVer labels and receipt tags are ignored, not errors. + Attempts do not move the line. A version is spent by publication, so an + abandoned attempt leaves its version free for the next cut. """ - family = [published] if published else [] - family.extend(version for version in (_claim_version(tag) for tag in claims) if version) - base = max(family, key=lambda version: [int(part) for part in version.split(".")]) if family else SEED - return _bump(base, bump) + return _bump(published or SEED, bump) + + +def next_attempt(version: str, refs: list[str]) -> int: + """One past the highest attempt ref for ``version``. + + Marker refs do not count and do not free a number: an abandoned attempt + keeps its ref, so its number is never cut again. + """ + attempts = [parsed[1] for parsed in map(parse_attempt_ref, refs) + if parsed and parsed[0] == version] + return max(attempts, default=0) + 1 diff --git a/tests/scripts/test_release_entrypoint.py b/tests/scripts/test_release_entrypoint.py index 11313f0e5b..3a37cb8371 100644 --- a/tests/scripts/test_release_entrypoint.py +++ b/tests/scripts/test_release_entrypoint.py @@ -1,7 +1,10 @@ -"""The release entrypoint claims a version, cuts a draft, and dispatches the gate. +"""The release entrypoint claims an attempt, 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. +The claim is the push of one attempt ref, ``rc.-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 @@ -32,19 +35,48 @@ def source(tmp_path): return repo -def _claim(repo, version, commit): +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, "commit": commit, + "schema": 1, "version": version, "attempt": attempt, "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") + ref = f"rc.{attempt}-v{version}" + git(repo, "tag", "-a", ref, commit, "-m", metadata) + git(repo, "push", "--quiet", "origin", f"refs/tags/{ref}") -def test_release_claims_the_derived_version_creates_a_draft_and_dispatches(source): +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 = [] @@ -52,49 +84,52 @@ def test_release_claims_the_derived_version_creates_a_draft_and_dispatches(sourc 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": "v0.21.5-rc", "status": "queued"}]) + "headBranch": "rc.1-v0.21.5", "status": "queued"}]) return "" - result = release(commit, bump="patch", repo=source, remote="origin", - repository="example/hermes-agent", execute=execute, autopublish=True) + result = _release(source, commit, execute=execute, autopublish=True) assert result["version"] == "0.21.5" - assert result["tag"] == "v0.21.5-rc" + assert result["tag"] == "rc.1-v0.21.5" assert result["commit"] == commit - assert result["url"] == "https://github.com/example/hermes-agent/releases/tag/v0.21.5-rc" + assert result["url"] == "https://github.com/example/hermes-agent/releases/tag/rc.1-v0.21.5" assert result["final_url"] == "https://github.com/example/hermes-agent/releases/tag/v0.21.5" - 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 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 == [ - ["gh", "release", "create", "v0.21.5-rc", "--repo", "example/hermes-agent", + ["gh", "release", "create", "rc.1-v0.21.5", "--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"], + ["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", "v0.21.5-rc", "--json", "databaseId,url,headBranch,status"], + "--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/v0.21.5-rc") - assert pushed.startswith(git(source, "rev-parse", "v0.21.5-rc")) + 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": "v0.21.5-rc", "autopublish": False, + result = {"version": "0.21.5", "tag": "rc.1-v0.21.5", "autopublish": False, "run_url": "https://github.com/example/hermes-agent/actions/runs/7", "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 v0.21.5-rc." in text + assert "The release workflow started on rc.1-v0.21.5." in text assert "Wait for that workflow to finish." in text assert result["final_url"] in text assert "python scripts/release.py publish --version 0.21.5 --remote origin" in text @@ -104,53 +139,125 @@ def test_release_output_names_the_wait_and_the_publish_step(): assert "publish --version" not in automatic -def test_a_commit_behind_an_outstanding_claim_is_refused(source): - from scripts.releases.entrypoint import ReleaseRefused, _require_ancestry, release +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 - 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}")) + 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_successive_claims_reserve_increasing_native_epochs(source): - from scripts.releases.entrypoint import release +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) - 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) + result = _release(source, _advance(source, "later"), published=("0.21.5", commit)) - first_epoch = int(git(source, "for-each-ref", "refs/tags/v0.21.5-rc", + 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/v0.21.6-rc", + 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_a_dispatch_that_never_starts_is_an_error(source): - from scripts.releases.entrypoint import ReleaseRefused, release + 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(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") + _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(): @@ -193,7 +300,7 @@ def test_publish_dispatches_the_sequencer_and_abandon_keeps_the_claim(source): assert abandoned["burned"] == "0.21.5" assert abandoned["tag"] == "v0.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") + assert "rc.1-v0.21.5" in git(source, "tag", "--list") with pytest.raises(ReleaseRefused, match="burned or superseded by 0\\.21\\.6"): publish( @@ -204,22 +311,26 @@ def test_publish_dispatches_the_sequencer_and_abandon_keeps_the_claim(source): ) -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") +def _clones(source, tmp_path): 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): + 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) @@ -227,7 +338,7 @@ def test_concurrent_claim_loser_reports_the_remote_winner_and_the_version_stays_ original_git = entrypoint._git def racing_git(repo, *args): - if args[:2] == ("push", "origin") and args[-1] == "refs/tags/v0.21.5-rc": + 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): @@ -244,10 +355,7 @@ def test_concurrent_claim_loser_reports_the_remote_winner_and_the_version_stays_ 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, - ) + outcomes[name] = _release(repo, commit) except Exception as error: outcomes[name] = error @@ -264,9 +372,31 @@ def test_concurrent_claim_loser_reports_the_remote_winner_and_the_version_stays_ 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 + assert git(left, "rev-parse", "rc.1-v0.21.5^{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" + 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()) diff --git a/tests/scripts/test_release_version_from_ref.py b/tests/scripts/test_release_version_from_ref.py index fa264747f1..a07ad7a7fc 100644 --- a/tests/scripts/test_release_version_from_ref.py +++ b/tests/scripts/test_release_version_from_ref.py @@ -1,12 +1,11 @@ """A ref names a version, and the next version is derived, never read from the tree. -Derivation reads the release family in order: the published stable head, then -outstanding ``-rc`` claims, then the seed ``0.21.4`` when both are empty. CalVer -tags and receipt tags are not inputs — a CalVer tag would win every ``max()``. +Derivation reads the published stable head, or the seed ``0.21.4`` before one +exists. Attempt refs number attempts within a version and never move the line. """ import pytest -from scripts.releases.versioning import derive_next_version, version_from_tag +from scripts.releases.versioning import derive_next_version, next_attempt, version_from_tag SEED = "0.21.4" @@ -67,9 +66,12 @@ def test_attempt_ref_refuses_what_it_could_not_parse(version, attempt): attempt_ref(version, attempt) -def test_claim_advances_the_line_but_is_not_a_final_tag(): - assert version_from_tag("v0.21.5-rc") is None - assert derive_next_version(published=None, claims=["v0.21.5-rc"], bump="patch") == "0.21.6" +def test_next_attempt_counts_cleared_attempts_and_skips_other_shapes(): + refs = ["rc.1-v0.21.5", "abandoned-rc.1-v0.21.5", "rc.2-v0.21.6", + "v0.21.5-rc", "v2026.9.21", "abandoned-rc.4-v0.21.5"] + assert next_attempt("0.21.5", refs) == 2 + assert next_attempt("0.21.6", refs) == 3 + assert next_attempt("0.21.7", refs) == 1 def test_canary_compares_equal_to_its_stable(): @@ -78,22 +80,14 @@ def test_canary_compares_equal_to_its_stable(): assert compare("0.21.4+canary.20260922T001400Z", "0.21.4") == 0 -def test_calver_tag_is_never_a_derivation_input(): - assert derive_next_version(published=None, claims=["v2026.9.21"], bump="patch") == "0.21.5" +def test_empty_head_seeds_the_line(): + assert derive_next_version(published=None, bump="patch") == "0.21.5" + assert derive_next_version(published=None, bump="minor") == "0.22.0" + assert derive_next_version(published=None, bump="major") == "1.0.0" -def test_empty_family_seeds_the_line(): - assert derive_next_version(published=None, claims=[], bump="patch") == "0.21.5" - assert derive_next_version(published=None, claims=[], bump="minor") == "0.22.0" - assert derive_next_version(published=None, claims=[], bump="major") == "1.0.0" - - -def test_published_head_beats_the_seed(): - assert derive_next_version(published="0.21.5", claims=[], bump="patch") == "0.21.6" - - -def test_claim_beats_a_lower_published_head(): - assert derive_next_version(published="0.21.5", claims=["v0.21.7-rc"], bump="patch") == "0.21.8" +def test_published_head_spends_its_version(): + assert derive_next_version(published="0.21.5", bump="patch") == "0.21.6" def test_canary_base_comes_from_the_validated_protected_stable_head():