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:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
58
scripts/releases/draft_warning.py
Normal file
58
scripts/releases/draft_warning.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user