Files
hermes-agent/scripts/releases/docker.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

217 lines
8.9 KiB
Python

"""Validate staged Docker artifact identities and publish receipts."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
import sys
import time
MANIFEST_SCHEMA = 1
SHA256 = re.compile(r"[a-f0-9]{64}")
GIT_SHA = re.compile(r"[a-f0-9]{40}")
from hermes_cli.update_channel import STABLE_TAG_RE
from scripts.releases.versioning import parse_attempt_ref
ARCHES = ("amd64", "arm64")
IMAGE = "nousresearch/hermes-agent"
class DockerReleaseError(ValueError):
"""Raised when a phase/manifest violates the staged-release contract."""
def require_stable_tag(tag: str) -> str:
# The versioned image is tagged by the attempt ref; stable/latest move only
# at publish. The old v-suffix shape is dead.
if not isinstance(tag, str) or not (STABLE_TAG_RE.fullmatch(tag) or parse_attempt_ref(tag)):
raise DockerReleaseError(f"Not a stable release tag: {tag!r}")
return tag
def build_manifest(tag: str, commit: str, digests: dict[str, str], archive_sha256: dict[str, str] | None = None) -> dict:
"""Digest manifest emitted by the test phase (artifact ``docker-test-manifest``)."""
require_stable_tag(tag)
if not isinstance(commit, str) or not GIT_SHA.fullmatch(commit):
raise DockerReleaseError(f"Invalid release commit: {commit!r}")
if sorted(digests) != sorted(ARCHES):
raise DockerReleaseError(f"Manifest needs per-arch digests for {ARCHES}, got {sorted(digests)}")
for arch, digest in digests.items():
if not SHA256.fullmatch(digest):
raise DockerReleaseError(f"Invalid digest for {arch}: {digest!r}")
manifest = {
"schema": MANIFEST_SCHEMA,
"tag": tag,
"commit": commit,
"digests": {arch: digests[arch] for arch in ARCHES},
}
if archive_sha256 is not None:
if sorted(archive_sha256) != sorted(ARCHES):
raise DockerReleaseError(f"Manifest needs per-arch archive hashes for {ARCHES}")
manifest["archives"] = {arch: archive_sha256[arch] for arch in ARCHES}
return manifest
def parse_manifest(raw: bytes) -> dict:
try:
manifest = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DockerReleaseError(f"Manifest is not valid JSON: {exc}") from exc
if not isinstance(manifest, dict) or manifest.get("schema") != MANIFEST_SCHEMA:
raise DockerReleaseError("Manifest schema mismatch")
require_stable_tag(manifest.get("tag", ""))
if not isinstance(manifest.get("commit"), str) or not GIT_SHA.fullmatch(manifest["commit"]):
raise DockerReleaseError("Manifest commit is not a full git SHA")
digests = manifest.get("digests")
if not isinstance(digests, dict) or sorted(digests) != sorted(ARCHES):
raise DockerReleaseError(f"Manifest needs per-arch digests for {ARCHES}")
for arch, digest in digests.items():
if not isinstance(digest, str) or not SHA256.fullmatch(digest):
raise DockerReleaseError(f"Invalid digest for {arch}")
archives = manifest.get("archives", {})
if archives and (not isinstance(archives, dict) or sorted(archives) != sorted(ARCHES)):
raise DockerReleaseError(f"Manifest archive hashes must cover {ARCHES}")
if "list-digest" in manifest and not re.fullmatch(r"sha256:[a-f0-9]{64}", manifest["list-digest"]):
raise DockerReleaseError("Invalid published manifest-list digest")
return manifest
def verify_manifest(manifest: dict, tag: str, commit: str) -> None:
"""Fail the publish/promote phase unless the manifest matches the release identity."""
if manifest.get("tag") != tag or manifest.get("commit") != commit:
raise DockerReleaseError(
f"Tested manifest identity {manifest.get('tag')}@{manifest.get('commit')} "
f"does not match release {tag}@{commit}"
)
def sha256_file(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def output(argv: list[str]) -> str:
return subprocess.check_output(argv, text=True, encoding="utf-8").strip().strip('"')
def _inspect(reference: str, run) -> str:
return run([
"docker", "buildx", "imagetools", "inspect", reference,
"--format", "{{json .Manifest.Digest}}",
]).strip('"')
def _version_digest(tag: str, suffix: str, run) -> str:
reference = f"{IMAGE}:{tag}{suffix}"
try:
digest = _inspect(reference, run)
except subprocess.CalledProcessError as exc:
raise DockerReleaseError(f"Docker versioned tag {reference} is missing") from exc
if not re.fullmatch(r"sha256:[a-f0-9]{64}", digest):
raise DockerReleaseError(f"Published image manifest digest is invalid: {reference}")
return digest
def promote_stable(tag: str, digest: str, *, run=output, sleep=time.sleep) -> None:
"""Move each variant's aliases from its immutable versioned registry image."""
require_stable_tag(tag)
if not re.fullmatch(r"sha256:[a-f0-9]{64}", digest):
raise DockerReleaseError("Invalid published manifest-list digest")
if _version_digest(tag, "", run) != digest:
raise DockerReleaseError("Docker versioned tag differs from the final release receipt")
# Inspect both before moving either alias; desktop has its own registry digest.
variants = (("", digest), ("-desktop", _version_digest(tag, "-desktop", run)))
for suffix, version_digest in variants:
command = [
"docker", "buildx", "imagetools", "create", "-t", f"{IMAGE}:stable{suffix}",
"-t", f"{IMAGE}:latest{suffix}", f"{IMAGE}@{version_digest}",
]
for attempt in range(3):
try:
run(command)
break
except subprocess.CalledProcessError:
if attempt == 2:
raise
sleep(20)
for alias in ("stable", "latest"):
for attempt in range(3):
if _inspect(f"{IMAGE}:{alias}{suffix}", run) == version_digest:
break
if attempt < 2:
sleep(20)
else:
raise DockerReleaseError(f"Docker {alias}{suffix} alias read-back mismatch")
def stable_alias_digest(run=output) -> str | None:
"""The slim ``stable`` alias digest, or None when the alias does not exist yet."""
try:
digest = _inspect(f"{IMAGE}:stable", run)
except subprocess.CalledProcessError:
return None
if not re.fullmatch(r"sha256:[a-f0-9]{64}", digest):
raise DockerReleaseError(f"Docker stable alias digest is invalid: {digest!r}")
return digest
def published_digest(tag: str, run=output) -> str:
"""Read both attempt images; return the slim digest bound to the release receipt.
Desktop has a separate digest. Its immutable tag must be published before
the release can finalize; promotion reads it again when moving aliases.
"""
require_stable_tag(tag)
digest = _version_digest(tag, "", run)
_version_digest(tag, "-desktop", run)
return digest
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
p_manifest = sub.add_parser("manifest", help="Emit the tested-image digest manifest JSON")
p_manifest.add_argument("--tag", required=True)
p_manifest.add_argument("--commit", required=True)
p_manifest.add_argument("--digest-amd64", required=True)
p_manifest.add_argument("--digest-arm64", required=True)
p_manifest.add_argument("--archive-amd64", default="", help="Optional sha256 file of the amd64 image archive")
p_manifest.add_argument("--archive-arm64", default="")
p_verify = sub.add_parser("verify", help="Verify a downloaded manifest against the release identity")
p_verify.add_argument("--tag", required=True)
p_verify.add_argument("--commit", required=True)
p_verify.add_argument("manifest", help="Path to the downloaded manifest JSON")
args = parser.parse_args(argv)
try:
if args.command == "manifest":
archive_hashes = {}
for arch, path in (("amd64", args.archive_amd64), ("arm64", args.archive_arm64)):
if path:
archive_hashes[arch] = sha256_file(path)
manifest = build_manifest(
args.tag,
args.commit,
{"amd64": args.digest_amd64, "arm64": args.digest_arm64},
archive_hashes or None,
)
print(json.dumps(manifest, indent=2))
else:
with open(args.manifest, "rb") as handle:
manifest = parse_manifest(handle.read())
verify_manifest(manifest, args.tag, args.commit)
print(json.dumps(manifest))
except DockerReleaseError as exc:
print(f"::error::{exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())