feat(release): publish writes the receipt and retargets the release

The final tag is the custody receipt of one publication pass: publish
hashes the candidate manifest from the attempt archive (nothing records
the digest earlier), resolves the image digest from the attempt-ref tag,
and binds both with the claim and archive path into v{version}. The
draft is retargeted onto the receipt tag and stripped while it is still
a draft, and only then does a final, separate call make it public —
immutable releases take no edits afterwards. The needs_retarget recovery
reruns exactly that draft edit; a public release can never be repaired.
The stable/latest Docker aliases move onto the attempt's image in the
same pass as the feed pointers, and no bytes are copied to a v-tag path.
This commit is contained in:
ethernet
2026-09-23 12:42:38 -04:00
parent a6e16f0934
commit e64c163c8a
9 changed files with 480 additions and 78 deletions

View File

@@ -210,16 +210,22 @@ def stable_head_version(env: dict) -> str | None:
return manifest["request"]["version"]
def read_archive_bytes(key: str) -> bytes:
"""Read one immutable object from the release archive."""
store = R2ChannelStore(*r2.credentials())
found = store.get(key)
if found is None:
raise ChannelError(f"Release archive object is unavailable: {key}")
return found[0]
def advance_stable(env: dict, release: dict, root: Path) -> dict:
"""Advance one published release from its immutable tag-scoped receipts."""
creds, base, bucket = r2.credentials()
store = R2ChannelStore(creds, base, bucket)
public_base = r2.public_base_url()
key = f"releases/tag/{release['claim_tag']}/release-candidates.json"
found = store.get(key)
if found is None:
raise ChannelError("Stable candidate manifest is unavailable")
digest = hashlib.sha256(found[0]).hexdigest()
digest = hashlib.sha256(read_archive_bytes(key)).hexdigest()
if digest != release.get("candidate_manifest_sha256"):
raise ChannelError("Stable candidate manifest differs from the final release receipt")
scoped_env = {

View File

@@ -133,6 +133,20 @@ def promote_stable(tag: str, digest: str, *, run=output, sleep=time.sleep) -> No
raise DockerReleaseError(f"Docker {alias} alias read-back mismatch")
def published_digest(tag: str, run=output) -> str:
"""The manifest-list digest of the attempt's published image, read at publish.
The image was pushed under the attempt ref when its own tests passed; the
receipt tag binds this digest, and the stable/latest aliases move onto it
in the publication pass.
"""
require_stable_tag(tag)
digest = _inspect(f"{IMAGE}:{tag}", run)
if not re.fullmatch(r"sha256:[a-f0-9]{64}", digest):
raise DockerReleaseError("Published image manifest digest is invalid")
return digest
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)

View File

@@ -0,0 +1,58 @@
"""Fenced draft-body warning and its strip, shared by the entrypoint and publish.
Immutable releases take no edits after publication, so the draft carries a
caution against publishing it by hand at both ends of the body and the
publication pass strips both fenced blocks while the release is still a draft.
The helpers live here so the entrypoint and the publish step can import them
without a cycle.
"""
from __future__ import annotations
WARNING_OPEN = "<!-- hermes-release:draft-warning -->"
WARNING_CLOSE = "<!-- /hermes-release:draft-warning -->"
_WARNING = """> [!CAUTION]
> ## **DO NOT PUBLISH THIS RELEASE FROM GITHUB.**
> **This is attempt `{attempt_ref}`. Publishing it here skips the `v{version}` receipt tag, the update feeds, the Docker aliases, and the Store release. Releases are immutable, so a release published here cannot be fixed.**
>
> **Run this instead:**
> ```
> python scripts/release.py publish --version {version}
> ```
> **To drop this attempt:** `python scripts/release.py abandon --version {version}`"""
def draft_body(*, version: str, attempt_ref: str, notes: str) -> str:
"""The draft body: warning block, generated notes, warning block."""
block = _WARNING.format(version=version, attempt_ref=attempt_ref)
return "\n".join([block, notes, block])
def strip_draft_warning(body: str) -> str:
"""Remove each fenced warning block, fence lines included.
The fences must pair up in order; anything else means the draft body was
edited underneath the tool, and publish refuses rather than publishing a
mangled body.
"""
kept: list[str] = []
inside = False
for line in body.splitlines():
stripped = line.strip()
if inside:
if stripped == WARNING_OPEN:
raise ValueError("draft warning fences are unbalanced")
if stripped == WARNING_CLOSE:
inside = False
continue
else:
if stripped == WARNING_OPEN:
inside = True
continue
if stripped == WARNING_CLOSE:
raise ValueError("draft warning fences are unbalanced")
kept.append(line)
text = "\n".join(kept)
if inside or WARNING_OPEN in text or WARNING_CLOSE in text:
raise ValueError("draft warning fences are unbalanced")
return text

View File

@@ -254,18 +254,34 @@ def _release_view(tag: str, repository: str, inspect) -> dict | None:
raise
def _preflight_publish(version: str, repository: str, inspect, head_version) -> None:
def _outstanding_ref(repo: Path, version: str) -> str:
"""The attempt ref whose draft the publication pass must find."""
attempt = next_attempt(version, _claims(repo)) - 1
if attempt < 1:
raise ReleaseRefused(f"stable {version} has no claimed attempt to publish")
return attempt_ref(version, attempt)
def _preflight_publish(version: str, repository: str, inspect, head_version,
repo: Path | None, remote: str | None) -> None:
requested = tuple(map(int, version.split(".")))
head = head_version()
if head and tuple(map(int, head.split("."))) >= requested:
raise ReleaseRefused(f"stable {version} is burned or superseded by {head}")
rows = [row for tag in (f"v{version}", f"v{version}-rc")
if repo is None or remote is None:
raise ValueError("preflight needs the release repository to find the attempt ref")
# The draft lives on the attempt ref, never on the final tag and never on
# the old v{version}-rc shape.
_refresh_claims(repo, remote)
attempt_ref = _outstanding_ref(repo, version)
rows = [(tag, row) for tag in (f"v{version}", attempt_ref)
if (row := _release_view(tag, repository, inspect)) is not None]
if len(rows) != 1:
if len(rows) != 1 or rows[0][0] != attempt_ref or rows[0][1].get("isDraft") is not True:
raise ReleaseRefused(f"stable {version} is burned or has no release draft")
def publish(version: str, *, repository: str, dispatch, inspect=None, head_version=None) -> dict:
def publish(version: str, *, repository: str, dispatch, inspect=None, head_version=None,
repo: Path | None = None, remote: str | None = None) -> dict:
"""Request ordered publication through the one production sequencer."""
tag = f"v{version}"
from hermes_cli.update_channel import STABLE_TAG_RE
@@ -273,7 +289,7 @@ def publish(version: str, *, repository: str, dispatch, inspect=None, head_versi
if not STABLE_TAG_RE.fullmatch(tag):
raise ReleaseRefused(f"{version} is not a stable version")
if inspect is not None and head_version is not None:
_preflight_publish(version, repository, inspect, head_version)
_preflight_publish(version, repository, inspect, head_version, repo, remote)
dispatch([
"gh", "workflow", "run", "stable-release-publication.yml",
"--repo", repository, "--raw-field", f"version={version}",
@@ -403,12 +419,13 @@ def abandon_steps(result: dict) -> str:
def cmd_publish(args) -> None:
repo, _remote, repository = _command_repository(args)
repo, remote, repository = _command_repository(args)
from scripts.releases.versioning import published_stable_version
result = publish(args.version, repository=repository,
dispatch=lambda command: _execute(repo, command),
inspect=lambda command: _inspect(repo, command),
head_version=lambda: published_stable_version(repository))
head_version=lambda: published_stable_version(repository),
repo=repo, remote=remote)
listed = _inspect(repo, [
"gh", "run", "list", "--repo", repository, "--workflow", "stable-release-publication.yml",
"--json", "databaseId,url,headBranch,status", "--limit", "1",

View File

@@ -279,13 +279,14 @@ def discover(repository: str, run=output) -> list[dict]:
raise ValueError(f"{tag} points at a different commit than {claim_tag}")
final = _tag_message(tag, final_ref["object"], run)
expected_final = {
"schema": 1, "version": version, "attempt": attempt, "commit": commit,
"schema": 1, "version": version, "commit": commit,
"claimTag": claim_tag, "claimTagObject": claim_ref["object"],
"autopublish": claim["autopublish"],
"claimEpoch": claim_epoch,
"releaseId": final.get("releaseId"),
"candidateManifestSha256": final.get("candidateManifestSha256"),
"dockerManifestDigest": final.get("dockerManifestDigest"),
"archive": f"releases/tag/{claim_tag}/",
}
if (final != expected_final
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
@@ -319,6 +320,7 @@ def discover(repository: str, run=output) -> list[dict]:
"autopublish": claim["autopublish"],
"claim_tag": claim_tag,
"claim_object": claim_ref["object"],
"claim_epoch": claim_epoch,
"tag": tag,
"commit": commit,
"release_id": release.get("id") if release else None,
@@ -331,11 +333,15 @@ def discover(repository: str, run=output) -> list[dict]:
return sorted(records, key=lambda record: _key(record["version"]))
def reconcile(env: dict, *, run=output, read_head=None, advance_head=None) -> list[dict]:
def reconcile(env: dict, *, run=output, read_head=None, advance_head=None,
read_archive=None) -> list[dict]:
"""Converge GitHub publication and protected heads oldest-first."""
from scripts.releases import channel_releases, docker, stable
repository = env["GITHUB_REPOSITORY"]
# The archive copy is the only authority for the candidate manifest digest;
# nothing records it before the publication pass hashes it.
read_archive = read_archive or channel_releases.read_archive_bytes
records = discover(repository, run)
retries = retry_due(records)
if retries:
@@ -358,8 +364,13 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None) -> li
return [{"retry": retry["version"], "attempt": retry["attempt"]} for retry in retries]
for record in records:
if record["needs_retarget"]:
stable.retarget_release(repository, record["release_id"], record["tag"],
record["commit"], publish=False, run=run)
# Recovery for a publish that died after the receipt tag: the tag
# exists and the release is still a draft, so the retarget and the
# publication rerun. A public release can never be repaired.
stable.edit_draft_release(repository, record["release_id"], record["tag"],
record["commit"], run=run)
stable.publish_release_draft(repository, record["release_id"], record["tag"],
run=run)
if any(record["needs_retarget"] for record in records):
records = discover(repository, run)
@@ -371,16 +382,21 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None) -> li
if advance_head is None:
def production_advance(record: dict) -> None:
docker.promote_stable(record["tag"], record["docker_manifest_digest"])
with tempfile.TemporaryDirectory() as directory:
channel_releases.advance_stable(env, record, Path(directory))
# The stable/latest aliases move onto the attempt's image here, in
# the publication pass with the feed pointer, never in the green
# build that pushed the image under the attempt ref.
docker.promote_stable(record["claim_tag"], record["docker_manifest_digest"])
# The Store release joins the pass here (after the feeds and
# aliases move), not before.
advance_head = production_advance
for step in steps:
if "flip" in step:
record = by_version[step["flip"]]
stable.retarget_release(repository, record["release_id"], record["tag"],
record["commit"], publish=True, run=run)
record["docker_manifest_digest"] = stable.publish_attempt(
record, repository=repository, run=run, read_archive=read_archive)
else:
advance_head(by_version[step["advance"]])

View File

@@ -15,6 +15,7 @@ from pathlib import Path
from urllib.parse import unquote, urlsplit
from hermes_cli.update_channel import STABLE_TAG_RE
from scripts.releases.draft_warning import strip_draft_warning
SHA = re.compile(r"[a-f0-9]{40}")
DIGEST = re.compile(r"[a-f0-9]{64}")
DESKTOP_TARGETS = ("windows/x64", "windows/arm64", "macos/x64", "macos/arm64")
@@ -318,6 +319,7 @@ def final_context(env: dict, run=output) -> tuple[str, str, dict]:
"releaseId": final.get("releaseId"),
"candidateManifestSha256": final.get("candidateManifestSha256"),
"dockerManifestDigest": final.get("dockerManifestDigest"),
"archive": f"releases/tag/{claim_tag}/",
}
if (final != expected
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
@@ -447,6 +449,7 @@ def _final_metadata(tag: str, commit: str, claim: dict, candidate_manifest_sha25
"releaseId": release_id,
"candidateManifestSha256": candidate_manifest_sha256,
"dockerManifestDigest": docker_manifest_digest,
"archive": f"releases/tag/{claim['claim_tag']}/",
}
@@ -493,23 +496,79 @@ def ensure_final_tag(tag: str, commit: str, claim: dict, *, candidate_manifest_s
return tag_object
def retarget_release(repository: str, release_id: int, tag: str, commit: str, *, publish: bool,
run=output) -> None:
def edit_draft_release(repository: str, release_id: int, tag: str, commit: str, *,
run=output) -> None:
"""Retarget the draft onto the receipt tag and strip the warning blocks.
Immutable releases take no edits after publication, so every edit happens
here while the release is still a draft, and the tag name, draft flag, and
body are read back before anything else touches the release. A release that
is already public is left alone: nothing can repair it.
"""
endpoint = f"repos/{repository}/releases/{release_id}"
current = json.loads(run(["gh", "api", endpoint]))
if (current.get("id") == release_id and current.get("tag_name") == tag
and current.get("prerelease") is False and current.get("draft") is False):
if current.get("id") != release_id:
raise ValueError("Stable draft release id changed")
if (current.get("tag_name") == tag and current.get("draft") is False
and current.get("prerelease") is False):
return
if current.get("draft") is not True:
raise ValueError("Stable release is no longer a draft and cannot be repaired")
body = strip_draft_warning(current.get("body") or "")
run([
"gh", "api", "--method", "PATCH", endpoint,
"--raw-field", f"tag_name={tag}", "--raw-field", f"target_commitish={commit}",
"--field", "prerelease=false", "--raw-field", "make_latest=true",
"--field", f"draft={str(not publish).lower()}",
"--raw-field", "make_latest=true",
"--field", "prerelease=false", "--field", "draft=true",
"--field", f"body={body}",
])
release = json.loads(run(["gh", "api", endpoint]))
if (release.get("id") != release_id or release.get("tag_name") != tag
or release.get("prerelease") is not False or release.get("draft") is not (not publish)):
raise ValueError("Stable release retarget did not persist")
or release.get("prerelease") is not False or release.get("draft") is not True):
raise ValueError("Stable draft retarget did not persist")
# A fence that survives the edit — balanced or not — means the body was
# changed underneath this call, and the release must not go public.
if strip_draft_warning(release.get("body") or "") != body:
raise ValueError("Stable draft body edit did not persist")
def publish_release_draft(repository: str, release_id: int, tag: str, *, run=output) -> None:
"""Make the release public as its own final call.
Under immutable releases this is the last edit the release ever takes, so
it runs only after the retarget and the strip have both been read back.
"""
endpoint = f"repos/{repository}/releases/{release_id}"
run(["gh", "api", "--method", "PATCH", endpoint, "--field", "draft=false"])
release = json.loads(run(["gh", "api", endpoint]))
if (release.get("id") != release_id or release.get("tag_name") != tag
or release.get("prerelease") is not False or release.get("draft") is not False
or not release.get("published_at")):
raise ValueError("Stable release publication did not persist")
def publish_attempt(record: dict, *, repository: str, run=output, read_archive) -> str:
"""The one ordered publication pass, steps 1-4, each read back before the next.
Explicit publish and autopublish converge here. The manifest digest is
hashed from the attempt archive (nothing records it earlier), the receipt
tag is written, the draft is retargeted and stripped while still a draft,
and only then does the final call make it public. Returns the Docker
manifest digest the receipt binds, for the alias move that follows.
"""
from scripts.releases import docker
claim = {"claim_tag": record["claim_tag"], "claim_object": record["claim_object"],
"autopublish": record["autopublish"], "claim_epoch": record["claim_epoch"]}
manifest = read_archive(f"releases/tag/{record['claim_tag']}/release-candidates.json")
docker_digest = docker.published_digest(record["claim_tag"], run)
ensure_final_tag(record["tag"], record["commit"], claim,
candidate_manifest_sha256=hashlib.sha256(manifest).hexdigest(),
docker_manifest_digest=docker_digest,
release_id=record["release_id"], run=run)
edit_draft_release(repository, record["release_id"], record["tag"], record["commit"], run=run)
publish_release_draft(repository, record["release_id"], record["tag"], run=run)
return docker_digest
def complete(env: dict) -> None:

View File

@@ -285,10 +285,10 @@ def test_publish_dispatches_the_sequencer():
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",
]]
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(
@@ -299,6 +299,37 @@ def test_publish_dispatches_the_sequencer():
)
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

View File

@@ -4,6 +4,7 @@ A green draft publishes when it opted into autopublish, or when a later green
release needs it resolved. A running older claim blocks only the versions above
it; already-resolvable older green claims still make progress.
"""
import hashlib
import json
import pytest
@@ -192,10 +193,11 @@ def _claim_message(version, attempt, commit, *, autopublish=False,
def _final_message(version, attempt, commit, *, release_id, epoch=1_790_000_000):
claim_tag = f"rc.{attempt}-v{version}"
return {
"schema": 1, "version": version, "attempt": attempt, "commit": commit,
"schema": 1, "version": version, "commit": commit,
"autopublish": False, "claimEpoch": epoch, "claimTag": claim_tag,
"claimTagObject": "1" * 40, "releaseId": release_id,
"candidateManifestSha256": "a" * 64, "dockerManifestDigest": "sha256:" + "b" * 64,
"archive": f"releases/tag/{claim_tag}/",
}
@@ -243,6 +245,7 @@ def test_discover_reads_an_attempt_ref_and_keeps_the_version():
assert records[0]["version"] == "0.21.5"
assert records[0]["claim_tag"] == "rc.2-v0.21.5"
assert records[0]["state"] == "published"
assert records[0]["claim_epoch"] == 1_790_000_000
def test_a_marker_ref_clears_the_attempt():
@@ -275,13 +278,13 @@ def test_two_outstanding_attempts_are_refused_across_versions():
discover("example/project", _discover_run(tags))
def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
from scripts.releases.sequencer import reconcile
def _sequencer_fixture(*versions, manifest_digest, docker_digest="sha256:" + "b" * 64,
drafts_on_claim_tag=False):
"""Two green claims with their final tags; releases start as drafts."""
commit = "a" * 40
tags = {}
releases = []
for index, version in enumerate(("0.21.5", "0.21.6"), start=1):
for index, version in enumerate(versions, start=1):
claim_tag, tag = f"rc.1-v{version}", f"v{version}"
claim_object, final_object = str(index) * 40, str(index + 2) * 40
claim = {
@@ -289,20 +292,22 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
"autopublish": False, "claimEpoch": 1_790_000_000 + index,
}
final = {
**claim, "claimTag": claim_tag, "claimTagObject": claim_object,
"schema": 1, "version": version, "commit": commit,
"autopublish": False, "claimEpoch": claim["claimEpoch"],
"claimTag": claim_tag, "claimTagObject": claim_object,
"releaseId": index,
"candidateManifestSha256": "a" * 64,
"dockerManifestDigest": "sha256:" + "b" * 64,
"candidateManifestSha256": manifest_digest,
"dockerManifestDigest": docker_digest,
"archive": f"releases/tag/{claim_tag}/",
}
tags[claim_tag] = (claim_object, commit, claim)
tags[tag] = (final_object, commit, final)
releases.append({
"id": index, "tag_name": tag, "draft": True, "prerelease": False,
"published_at": None,
"id": index, "tag_name": claim_tag if drafts_on_claim_tag else tag,
"draft": True, "prerelease": False, "published_at": None,
"body": "notes",
})
events = []
def run(argv):
if argv[:2] == ["git", "fetch"]:
return ""
@@ -311,6 +316,17 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
f"{sha}\trefs/tags/{tag}\n{target}\trefs/tags/{tag}^{{}}"
for tag, (sha, target, _message) in tags.items()
)
if argv[:2] == ["git", "ls-remote"]:
lines = []
for ref in argv[2:]:
name = ref.removeprefix("refs/tags/").removesuffix("^{}")
entry = tags.get(name)
sha, target = (entry[0], entry[1]) if entry else ("", "")
if sha:
lines.append(f"{sha}\t{ref if ref.endswith('^{}') else ref}")
if ref.endswith("^{}"):
lines[-1] = f"{target}\t{ref}"
return "\n".join(lines)
if argv[:2] == ["git", "rev-parse"]:
return tags[argv[-1].removeprefix("refs/tags/")][0]
if argv[:3] == ["git", "cat-file", "-t"]:
@@ -321,30 +337,53 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
return f"tagger Fixture <fixture@example.test> {epoch} +0000\n"
if argv[:3] == ["git", "tag", "-l"]:
return json.dumps(tags[argv[3]][2])
if "tag" in argv and "-a" in argv:
tag = argv[argv.index("-a") + 1]
tags[tag] = (str(len(tags)) * 40, tags[claim_tag][1], tags[tag][2])
return ""
if argv[:2] == ["git", "push"]:
return ""
if argv[:3] == ["docker", "buildx", "imagetools"]:
return json.dumps(docker_digest)
if argv[:4] == ["gh", "api", "--paginate", "--slurp"]:
if "/releases?" in argv[-1]:
return json.dumps([releases])
return json.dumps([{"workflow_runs": []}])
if argv[:3] == ["gh", "api", "--method"]:
release_id = int(argv[4].rsplit("/", 1)[1])
release = next(row for row in releases if row["id"] == release_id)
release.update(tag_name=f"v0.21.{4 + release_id}", draft=False,
prerelease=False, published_at="2026-09-22T01:00:00Z")
events.append(("flip", release["tag_name"]))
release = next(row for row in releases if row["id"] == int(argv[4].rsplit("/", 1)[1]))
for _flag, value in zip(argv[5::2], argv[6::2]):
name, _, raw = value.partition("=")
if name == "tag_name":
release["tag_name"] = raw
elif name == "draft":
release["draft"] = raw == "true"
if not release["draft"]:
release["published_at"] = "2026-09-22T01:00:00Z"
elif name == "body":
release["body"] = raw
elif name == "prerelease":
release["prerelease"] = raw == "true"
return "{}"
if argv[:2] == ["gh", "api"] and "/releases/" in argv[2]:
release_id = int(argv[2].rsplit("/", 1)[1])
return json.dumps(next(row for row in releases if row["id"] == release_id))
release = next(row for row in releases if row["id"] == int(argv[2].rsplit("/", 1)[1]))
return json.dumps(release)
raise AssertionError(argv)
return tags, releases, run
def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
from scripts.releases.sequencer import reconcile
manifest_digest = hashlib.sha256(b"m").hexdigest()
_tags, releases, run = _sequencer_fixture("0.21.5", "0.21.6", manifest_digest=manifest_digest)
events = []
archive_keys = []
head = ["0.21.4"]
releases[0]["id"] = 99
assert reconcile(
{"GITHUB_REPOSITORY": "example/project"},
run=run, read_head=lambda: head[0], advance_head=lambda _record: None,
) == []
releases[0]["id"] = 1
def read_archive(key):
archive_keys.append(key)
return b"m"
def advance(record):
events.append(("advance", record["tag"]))
@@ -353,6 +392,7 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
steps = reconcile(
{"GITHUB_REPOSITORY": "example/project", "REQUESTED_VERSION": "0.21.6"},
run=run, read_head=lambda: head[0], advance_head=advance,
read_archive=read_archive,
)
assert steps == [
@@ -360,6 +400,38 @@ def test_reconcile_discovers_custody_flips_then_advances_oldest_first():
{"flip": "0.21.6"}, {"advance": "0.21.6"},
]
assert events == [
("flip", "v0.21.5"), ("advance", "v0.21.5"),
("flip", "v0.21.6"), ("advance", "v0.21.6"),
("advance", "v0.21.5"), ("advance", "v0.21.6"),
]
# Each pass hashes the manifest from its own attempt archive path.
assert archive_keys == [
"releases/tag/rc.1-v0.21.5/release-candidates.json",
"releases/tag/rc.1-v0.21.6/release-candidates.json",
]
for release, version in zip(releases, ("0.21.5", "0.21.6")):
assert release["tag_name"] == f"v{version}" and release["draft"] is False
def test_a_publish_that_died_before_the_retarget_is_repaired():
from scripts.releases.sequencer import reconcile
manifest_digest = hashlib.sha256(b"m").hexdigest()
_tags, releases, run = _sequencer_fixture(
"0.21.5", manifest_digest=manifest_digest, drafts_on_claim_tag=True)
events = []
head = ["0.21.4"]
def advance(record):
events.append(("advance", record["tag"]))
head[0] = record["version"]
steps = reconcile(
{"GITHUB_REPOSITORY": "example/project"},
run=run, read_head=lambda: head[0], advance_head=advance,
read_archive=lambda _key: b"m",
)
# The final tag existed and the draft was still on the attempt ref: the
# retarget and the publication rerun, then the head advances.
assert steps == [{"advance": "0.21.5"}]
assert events == [("advance", "v0.21.5")]
assert releases[0]["tag_name"] == "v0.21.5" and releases[0]["draft"] is False

View File

@@ -10,9 +10,12 @@ from pathlib import Path
import pytest
from scripts.releases.draft_warning import (
WARNING_CLOSE, WARNING_OPEN, strip_draft_warning,
)
from scripts.releases.stable import (
check_claim, ensure_final_tag, plan_transitions, read_manifest, require_stable_identity,
require_success, retarget_release, validate_candidates,
require_success, validate_candidates,
)
BASE = "https://releases.example"
@@ -359,27 +362,153 @@ def test_complete_writes_no_final_tag_and_leaves_the_draft_on_the_attempt_ref(tm
assert not [argv for argv in calls if argv[0] == "gh"]
@pytest.mark.parametrize("publish", [False, True])
def test_retarget_release_preserves_the_database_id_and_explicit_draft_policy(publish):
commit = "a" * 40
calls = []
patched = False
def _fenced_body(notes="## What's changed\n- x"):
"""A draft body the way the entrypoint builds it: warning block, notes, warning block."""
block = WARNING_OPEN + "\nDO NOT PUBLISH THIS BY HAND\n" + WARNING_CLOSE
return block + "\n" + notes + "\n" + block
def gh(argv):
nonlocal patched
calls.append(argv)
if argv[1:3] == ["api", "--method"]:
patched = True
def test_strip_removes_both_blocks_and_keeps_the_notes():
assert strip_draft_warning(_fenced_body()).strip() == "## What's changed\n- x"
# A body without fences passes through untouched.
assert strip_draft_warning("just notes") == "just notes"
@pytest.mark.parametrize("body", [
WARNING_OPEN + "\nunbalanced",
"text\n" + WARNING_CLOSE,
WARNING_OPEN + "\n" + WARNING_OPEN + "\n" + WARNING_CLOSE,
WARNING_OPEN + "text",
])
def test_strip_refuses_an_unbalanced_fence(body):
with pytest.raises(ValueError, match="unbalanced"):
strip_draft_warning(body)
def _publish_record(commit, tag_object, *, epoch, release_id=42):
return {"claim_tag": "rc.2-v1.2.3", "claim_object": tag_object, "tag": "v1.2.3",
"commit": commit, "version": "1.2.3", "attempt": 2, "release_id": release_id,
"autopublish": False, "claim_epoch": epoch}
def test_publish_attempt_writes_the_receipt_retargets_and_copies_no_bytes(tmp_path, monkeypatch):
from scripts.releases import stable
commit, tag_object = _claim_fixture(tmp_path, tag="rc.2-v1.2.3", version="1.2.3")
monkeypatch.chdir(tmp_path / "repo")
epoch = json.loads(subprocess.check_output(
["git", "tag", "-l", "rc.2-v1.2.3", "--format=%(contents)"],
text=True, encoding="utf-8"))["claimEpoch"]
manifest_bytes = b'{"schema":2}\n'
manifest_digest = hashlib.sha256(manifest_bytes).hexdigest()
docker_digest = "sha256:" + "d" * 64
release = {"id": 42, "tag_name": "rc.2-v1.2.3", "draft": True, "prerelease": False,
"body": _fenced_body(), "published_at": None}
patches = []
requested_keys = []
def run(argv):
if argv[0] == "git":
return subprocess.check_output(argv, text=True, encoding="utf-8").strip()
if argv[:3] == ["docker", "buildx", "imagetools"]:
assert argv[4].endswith("nousresearch/hermes-agent:rc.2-v1.2.3")
return json.dumps(docker_digest)
if argv[:3] == ["gh", "api", "--method"]:
fields = {}
for _flag, value in zip(argv[5::2], argv[6::2]):
name, _, raw = value.partition("=")
fields[name] = raw
patches.append((fields.get("tag_name"), fields.get("draft")))
release.update({key: (raw == "true") if key in {"draft", "prerelease"} else raw
for key, raw in fields.items()
if key in {"tag_name", "draft", "prerelease", "body"}})
if release["draft"] is False:
release["published_at"] = "2026-09-22T00:00:00Z"
return "{}"
if argv[1:3] == ["api", "repos/example/project/releases/42"]:
return json.dumps({
"id": 42, "tag_name": "v1.2.3" if patched else "v1.2.3-rc",
"target_commitish": commit, "prerelease": False,
"draft": not publish if patched else True,
})
return "{}"
if argv[:2] == ["gh", "api"]:
assert argv[2].endswith("/releases/42")
return json.dumps(release)
raise AssertionError(argv)
retarget_release("example/project", 42, "v1.2.3", commit, publish=publish, run=gh)
def read_archive(key):
requested_keys.append(key)
return manifest_bytes
assert ["--field", f"draft={str(not publish).lower()}"] == calls[1][-2:]
assert calls[2] == ["gh", "api", "repos/example/project/releases/42"]
digest = stable.publish_attempt(
_publish_record(commit, tag_object, epoch=epoch),
repository="example/project", run=run, read_archive=read_archive,
)
assert digest == docker_digest
receipt = json.loads(subprocess.check_output(
["git", "tag", "-l", "v1.2.3", "--format=%(contents)"],
text=True, encoding="utf-8"))
assert receipt["claimTag"] == "rc.2-v1.2.3"
assert receipt["archive"] == "releases/tag/rc.2-v1.2.3/"
assert receipt["candidateManifestSha256"] == manifest_digest
assert receipt["dockerManifestDigest"] == docker_digest
assert receipt["releaseId"] == 42
remote = subprocess.check_output(
["git", "ls-remote", "origin", "refs/tags/v1.2.3", "refs/tags/v1.2.3^{}"],
text=True, encoding="utf-8")
assert commit in remote
# The digest is hashed from the attempt archive, and no v-tag path is read.
assert requested_keys == ["releases/tag/rc.2-v1.2.3/release-candidates.json"]
# The retarget and the strip happen while the release is still a draft;
# draft=false is its own final call, after both read back.
assert [draft for _tag, draft in patches] == ["true", "false"]
assert [tag for tag, _draft in patches] == ["v1.2.3", None]
assert release["tag_name"] == "v1.2.3" and release["draft"] is False
assert release["body"] == "## What's changed\n- x"
def test_publish_attempt_refuses_a_release_that_is_no_longer_a_draft(tmp_path, monkeypatch):
from scripts.releases import stable
commit, tag_object = _claim_fixture(tmp_path, tag="rc.2-v1.2.3", version="1.2.3")
monkeypatch.chdir(tmp_path / "repo")
epoch = json.loads(subprocess.check_output(
["git", "tag", "-l", "rc.2-v1.2.3", "--format=%(contents)"],
text=True, encoding="utf-8"))["claimEpoch"]
release = {"id": 42, "tag_name": "rc.2-v1.2.3", "draft": False, "prerelease": False,
"body": "notes", "published_at": "2026-09-22T00:00:00Z"}
def run(argv):
if argv[0] == "git":
return subprocess.check_output(argv, text=True, encoding="utf-8").strip()
if argv[:3] == ["docker", "buildx", "imagetools"]:
return json.dumps("sha256:" + "d" * 64)
if argv[:2] == ["gh", "api"]:
return json.dumps(release)
raise AssertionError(argv)
with pytest.raises(ValueError, match="no longer a draft"):
stable.publish_attempt(
_publish_record(commit, tag_object, epoch=epoch),
repository="example/project", run=run, read_archive=lambda _key: b"m",
)
# The custody receipt still exists: a public release cannot be repaired,
# but the tag must not be skipped either.
assert "refs/tags/v1.2.3" in subprocess.check_output(
["git", "ls-remote", "origin", "refs/tags/v1.2.3"], text=True, encoding="utf-8")
def test_edit_draft_release_refuses_a_body_that_still_carries_a_fence(tmp_path, monkeypatch):
from scripts.releases import stable
commit, _tag_object = _claim_fixture(tmp_path, tag="rc.2-v1.2.3", version="1.2.3")
monkeypatch.chdir(tmp_path / "repo")
release = {"id": 42, "tag_name": "rc.2-v1.2.3", "draft": True, "prerelease": False,
"body": _fenced_body(), "published_at": None}
# The PATCH is dropped on the floor: the read-back still shows the fence.
def run(argv):
if argv[0] == "git":
return subprocess.check_output(argv, text=True, encoding="utf-8").strip()
if argv[:3] == ["gh", "api", "--method"]:
return "{}"
if argv[:2] == ["gh", "api"]:
return json.dumps(release)
raise AssertionError(argv)
with pytest.raises(ValueError):
stable.edit_draft_release("example/project", 42, "v1.2.3", commit, run=run)