Files
hermes-agent/scripts/releases/sequencer.py
ethernet dc11e3b3bc feat(release): add --skip-bundles and --skip-tests to stable releases
`release.py release` gains two flags. They can be used together.

--skip-bundles ships only the claim, the GitHub release, the final tag
and the Docker image. No desktop, Termux or PM bundle job runs. The
final tag records candidateManifestSha256: null. Publication moves only
the Docker stable/latest aliases. The R2 stable head, feeds, APT, the
downloads page, the signed-package baseline and the Store stay on the
previous bundle release.

--skip-tests builds, signs and publishes every artifact and runs no
test job: source CI, Nix, PM bundle check, Termux, Windows live,
install/update E2E, bootstrap identity, native smokes, upgrade
acceptance, tests/docker and the in-build vitest step. The candidate
manifest records each smoke as skipped, never as passed.

The flags live in the claim message (skipBundles, skipTests), next to
autopublish. They are not workflow inputs, so a rerun cannot change
them. admit emits them, and every job condition and gate reads them.
stable.validate_claim and stable.validate_final are now the one shape
check for stable.py and the sequencer.

The gates stay strict. SKIPPED_BY in stable.py maps each job to the
flags that remove it. `gate` requires those jobs to report skipped and
every other gated job to report success. A job that ran although a flag
removes it blocks the release.

A release that skipped bundles never moves the R2 stable head. Two
readers depended on that head:

- The next version was derived from it, so the next cut would reuse the
  version. It now takes the newer of the R2 head and the newest
  published non-prerelease GitHub release with a vX.Y.Z tag. Bare v*
  tags do not count, because those refs are not protected yet.
- The sequencer used it to decide which published releases still need
  their publication pass, so a bundle-less release would re-advance
  every 15 minutes. The head is now the newer of the R2 head and the
  published release whose final tag binds the Docker stable alias
  digest.

`release` also refuses a cut when its next version already has a final
tag. That closes the window between the final tag and the public
release, where the published identity still names the old version.

Tests: 42 release test files, 546 passed. Three tests fail on this
Windows host, and they fail the same way on a clean HEAD worktree:

- test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag
- test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected
- test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True]

Not verified: no real Stable Release dispatch ran with either flag, and
actionlint is not installed on this host. The workflow changes are
checked by the graph tests and by running the phase-result step script.
2026-09-24 13:31:33 -04:00

423 lines
18 KiB
Python

"""Discover and reconcile stable releases in version order.
The planner is pure. Production discovery reads only remote annotated tags,
GitHub releases/runs, and the protected R2 head; execution flips exact release
IDs before advancing each eligible head oldest-first.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from scripts.releases.versioning import (
marker_ref, outstanding_attempts, parse_attempt_ref, parse_marker_ref,
version_from_tag,
)
MAX_ATTEMPTS = 3
CLAIM_GRACE = timedelta(hours=1)
def _key(version: str) -> tuple[int, int, int]:
major, minor, patch = version.split(".")
return int(major), int(minor), int(patch)
def plan(claims: list[dict], *, head: str | None,
requested_version: str | None = None) -> list[dict]:
"""Return each eligible draft flip immediately followed by its head advance."""
ordered = sorted(claims, key=lambda claim: _key(claim["version"]))
head_key = _key(head) if head is not None else None
steps: list[dict] = []
flush_green_chain = False
for index, claim in enumerate(ordered):
version = claim["version"]
version_key = _key(version)
state = claim["state"]
if head_key is not None and version_key <= head_key:
if state in {"published", "burned"}:
continue
raise ValueError(f"refusing to move the head backwards from {head}")
if state == "published":
steps.append({"advance": version})
continue
if state == "burned":
continue
if state == "running":
break
if state != "green":
raise ValueError(f"unknown claim state {state!r}")
has_later_green_claim = False
for later in ordered[index + 1:]:
if later["state"] == "running":
break
if later["state"] == "green":
has_later_green_claim = True
break
eligible = (
claim.get("autopublish", False)
or version == requested_version
or has_later_green_claim
or flush_green_chain
)
if not eligible:
break
steps.extend(({"flip": version}, {"advance": version}))
flush_green_chain = flush_green_chain or has_later_green_claim
return steps
def output(argv: list[str]) -> str:
return subprocess.check_output(argv, text=True, encoding="utf-8").strip()
def _pages(argv: list[str], run=output) -> list:
pages = json.loads(run(["gh", "api", "--paginate", "--slurp", *argv]))
if not isinstance(pages, list):
raise ValueError("GitHub pagination returned an invalid response")
return pages
def _release_rows(repository: str, run=output) -> list[dict]:
rows = []
for page in _pages([f"repos/{repository}/releases?per_page=100"], run):
if not isinstance(page, list):
raise ValueError("GitHub releases response is invalid")
rows.extend(page)
return rows
def _workflow_runs(repository: str, run=output) -> list[dict]:
rows = []
endpoint = f"repos/{repository}/actions/workflows/stable-release.yml/runs?event=workflow_dispatch&per_page=100"
for page in _pages([endpoint], run):
if not isinstance(page, dict) or not isinstance(page.get("workflow_runs"), list):
raise ValueError("GitHub workflow runs response is invalid")
rows.extend(page["workflow_runs"])
return rows
def classify_runs(runs: list[dict], *, claimed_at: datetime | None = None,
now: datetime | None = None, has_draft: bool = False) -> tuple[str, dict | None]:
"""Keep failed claims live until two failed-job retries are exhausted.
Success with a draft on the attempt ref is green: the final tag moves to
publish. A missing draft after success is an error, because the tool is
the only thing that deletes drafts; abandonment is the marker ref.
"""
if not runs:
if claimed_at is None:
raise ValueError("A claim without a workflow needs its immutable claim time")
now = now or datetime.now(timezone.utc)
return ("running", None) if now < claimed_at + CLAIM_GRACE else ("burned", None)
run_ids = {row.get("id") for row in runs}
if len(run_ids) != 1 or None in run_ids:
raise ValueError("Stable claim owns multiple workflow runs")
latest = max(runs, key=lambda row: row.get("run_attempt", 0))
attempt = latest.get("run_attempt")
if not isinstance(attempt, int) or attempt < 1:
raise ValueError("Stable workflow run attempt is invalid")
if latest.get("status") != "completed":
return "running", None
if latest.get("conclusion") == "success":
if not has_draft:
raise ValueError("Stable workflow succeeded without a draft release")
return "green", None
if attempt >= MAX_ATTEMPTS:
return "burned", None
return "running", {"run_id": latest["id"], "attempt": attempt}
def retry_due(records: list[dict]) -> list[dict]:
"""Return the oldest unresolved claim's retry, if it is waiting on one.
No backoff: the failure event's own reconcile pass reruns the failed jobs,
because nothing else wakes the reconciler to apply a delayed retry.
"""
for record in records:
if record.get("state") != "running":
continue
retry = record.get("retry")
if retry is None:
return []
return [{
"version": record["version"], "run_id": retry["run_id"],
"attempt": retry["attempt"] + 1,
}]
return []
def classify_final_release(tag: str, claim_tag: str, release: dict | None) -> tuple[str, bool]:
"""Derive green/published/abandoned state from the exact GitHub release."""
if release is None:
return "burned", False
if release.get("prerelease") is not False:
raise ValueError(f"{tag} has no valid GitHub release")
needs_retarget = release.get("tag_name") == claim_tag
if release.get("tag_name") not in {claim_tag, tag}:
raise ValueError(f"{tag} release identity changed")
if release.get("draft") is True:
return "green", needs_retarget
if release.get("draft") is False and release.get("published_at"):
if needs_retarget:
raise ValueError(f"{claim_tag} was published before final retargeting")
return "published", False
raise ValueError(f"{tag} release state is invalid")
def _remote_tags(run=output) -> dict[str, dict[str, str]]:
"""Every remote receipt tag, attempt ref, and abandon marker ref.
Three listings, because a fetch or ls-remote glob is filtered here by the
ref parsers, not widened.
"""
refs: dict[str, dict[str, str]] = {}
for line in run([
"git", "ls-remote", "--tags", "origin",
"refs/tags/v*", "refs/tags/rc.*", "refs/tags/abandoned-rc.*",
]).splitlines():
sha, ref = line.split()
peeled = ref.endswith("^{}")
tag = ref.removeprefix("refs/tags/").removesuffix("^{}")
if (version_from_tag(tag) or parse_attempt_ref(tag)
or parse_marker_ref(tag)):
refs.setdefault(tag, {})["commit" if peeled else "object"] = sha
return refs
def _tag_message(tag: str, expected_object: str, run=output) -> dict:
local_object = run(["git", "rev-parse", f"refs/tags/{tag}"])
if local_object != expected_object or run(["git", "cat-file", "-t", local_object]) != "tag":
raise ValueError(f"{tag} differs from its remote annotated object")
try:
message = json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"]))
except json.JSONDecodeError as error:
raise ValueError(f"{tag} metadata is invalid") from error
if not isinstance(message, dict):
raise ValueError(f"{tag} metadata is invalid")
return message
def discover(repository: str, run=output) -> list[dict]:
"""Derive every stable claim state from remote refs and GitHub objects."""
from scripts.releases.stable import tagger_epoch, validate_claim, validate_final
run([
"git", "fetch", "origin", "+refs/tags/v*:refs/tags/v*",
"+refs/tags/rc.*:refs/tags/rc.*",
"+refs/tags/abandoned-rc.*:refs/tags/abandoned-rc.*",
])
refs = _remote_tags(run)
outstanding = outstanding_attempts(list(refs), lambda v: f"v{v}" in refs)
if len(outstanding) > 1:
named = ", ".join(ref for *_rest, ref in outstanding)
raise ValueError(f"more than one outstanding attempt ({named})")
releases = _release_rows(repository, run)
workflow_runs = _workflow_runs(repository, run)
records = []
for claim_tag, claim_ref in refs.items():
parsed = parse_attempt_ref(claim_tag)
if parsed is None:
continue
version, attempt = parsed
if set(claim_ref) != {"object", "commit"}:
raise ValueError(f"{claim_tag} must be an annotated remote tag")
tag = f"v{version}"
commit = claim_ref["commit"]
claim = _tag_message(claim_tag, claim_ref["object"], run)
try:
validate_claim(claim, version=version, attempt=attempt, commit=commit)
except ValueError as error:
raise ValueError(f"{claim_tag} metadata is invalid") from error
claim_epoch = claim["claimEpoch"]
if tagger_epoch(claim_ref["object"], run) != claim_epoch:
raise ValueError(f"{claim_tag} epoch differs from its annotated tagger timestamp")
family_releases = [row for row in releases if row.get("tag_name") in {claim_tag, tag}]
if len(family_releases) > 1:
raise ValueError(f"{claim_tag} owns multiple GitHub releases")
release = family_releases[0] if family_releases else None
final_ref = refs.get(tag)
needs_retarget = False
final = None
retry = None
if final_ref is not None:
if set(final_ref) != {"object", "commit"}:
raise ValueError(f"{tag} must be an annotated remote tag")
if final_ref["commit"] != commit:
raise ValueError(f"{tag} points at a different commit than {claim_tag}")
final = _tag_message(tag, final_ref["object"], run)
try:
validate_final(final, version=version, commit=commit, claim_tag=claim_tag,
claim_object=claim_ref["object"], claim=claim)
except ValueError as error:
raise ValueError(f"{tag} metadata differs from {claim_tag}") from error
if release is None or release.get("id") != final["releaseId"]:
state, needs_retarget = "burned", False
else:
state, needs_retarget = classify_final_release(tag, claim_tag, release)
elif marker_ref(version, attempt) in refs:
# The attempt was abandoned and its version has no final tag.
state, retry = "burned", None
else:
if release is not None and (release.get("tag_name") != claim_tag
or release.get("draft") is not True
or release.get("prerelease") is not False):
raise ValueError(f"{claim_tag} draft state is invalid")
matching_runs = [row for row in workflow_runs
if row.get("head_branch") == claim_tag and row.get("head_sha") == commit]
state, retry = classify_runs(
matching_runs,
claimed_at=datetime.fromtimestamp(claim_epoch, tz=timezone.utc),
has_draft=release is not None,
)
records.append({
"version": version,
"attempt": attempt,
"state": state,
"autopublish": claim["autopublish"],
"skip_bundles": claim["skipBundles"],
"skip_tests": claim["skipTests"],
"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,
"needs_retarget": needs_retarget,
"candidate_manifest_sha256": final["candidateManifestSha256"] if final else None,
"docker_manifest_digest": final["dockerManifestDigest"] if final else None,
"retry": retry if final_ref is None else None,
})
return sorted(records, key=lambda record: _key(record["version"]))
def channel_head(desktop_head: str | None, records: list[dict],
stable_alias_digest: str | None) -> str | None:
"""The newest version whose publication pass finished.
A bundle release finishes when the protected R2 head names it. A release
that skipped bundles never moves that head. It finishes when the Docker
``stable`` alias carries the digest its final receipt binds.
"""
versions = [desktop_head] if desktop_head is not None else []
if stable_alias_digest is not None:
versions += [record["version"] for record in records
if record["state"] == "published"
and record["docker_manifest_digest"] == stable_alias_digest]
return max(versions, key=_key, default=None)
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, store
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:
for retry in retries:
endpoint = f"repos/{repository}/actions/runs/{retry['run_id']}"
run([
"gh", "api", "--method", "POST",
f"{endpoint}/rerun-failed-jobs",
])
for attempt in range(6):
current = json.loads(run(["gh", "api", endpoint]))
if (current.get("id") == retry["run_id"]
and current.get("run_attempt") == retry["attempt"]
and current.get("status") != "completed"):
break
if attempt < 5:
time.sleep(5)
else:
raise ValueError(f"Stable retry {retry['run_id']} did not enter attempt {retry['attempt']}")
return [{"retry": retry["version"], "attempt": retry["attempt"]} for retry in retries]
for record in records:
if record["needs_retarget"]:
# 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)
read_head = read_head or (lambda: channel_head(
channel_releases.stable_head_version(env), discover(repository, run),
docker.stable_alias_digest()))
head = read_head()
requested = env.get("REQUESTED_VERSION") or None
steps = plan(records, head=head, requested_version=requested)
by_version = {record["version"]: record for record in records}
if advance_head is None:
def production_advance(record: dict) -> None:
if not record["skip_bundles"]:
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. A release
# that skipped bundles moves only these aliases.
docker.promote_stable(record["claim_tag"], record["docker_manifest_digest"])
if not record["skip_bundles"]:
# The Store check joins the pass here (after the feeds and aliases
# move). It never releases the held submission: the API cannot,
# so it prints the Publish now step. A failed submission is red.
store.check_from_env(env)
advance_head = production_advance
for step in steps:
if "flip" in step:
record = by_version[step["flip"]]
record["docker_manifest_digest"] = stable.publish_attempt(
record, repository=repository, run=run, read_archive=read_archive)
else:
advance_head(by_version[step["advance"]])
final = {record["version"]: record for record in discover(repository, run)}
for step in steps:
if "flip" in step and final[step["flip"]]["state"] != "published":
raise ValueError(f"Stable release {step['flip']} did not publish")
if requested and (requested not in final or final[requested]["state"] != "published"):
raise ValueError(f"Requested stable release {requested} did not publish")
if any("advance" in step for step in steps):
expected = next(step["advance"] for step in reversed(steps) if "advance" in step)
if read_head() != expected:
raise ValueError("Stable protected head did not reach the planned version")
return steps
def main(argv: list[str] | None = None, env: dict | None = None) -> None:
if argv is None:
argv = sys.argv[1:]
if argv:
raise ValueError("The stable sequencer takes no positional arguments")
print(json.dumps(reconcile(dict(os.environ if env is None else env)), sort_keys=True))
if __name__ == "__main__":
main()