Files
hermes-agent/scripts/releases/docker.py
ethernet d7466aa3ba release: one strict stable-tag grammar shared by every selector
scripts/releases/semver.STABLE_TAG accepted any-width majors, so
is_valid_version('2026.9.21') was True and docker.require_stable_tag /
stable.py / release.py admitted the legacy CalVer tags that
hermes_cli.source_releases and get_last_tag() already refused. A
workflow_call carrying GitHub's current 'latest' (v2026.9.21) would have
passed the docker publish gate.

hermes_cli.update_channel already owns the canary tag shape; it now owns
STABLE_TAG_RE too (three-digit major cap, no leading zeros, no suffix)
and every stable selector imports it. release.py drops its private
_SEMVER_TAG_RE + CalVer exclusion pair, which the capped major makes
redundant.
2026-09-21 18:42:01 -04:00

134 lines
5.6 KiB
Python

"""Validate staged Docker artifact identities and publish receipts."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
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
ARCHES = ("amd64", "arm64")
class DockerReleaseError(ValueError):
"""Raised when a phase/manifest violates the staged-release contract."""
def require_stable_tag(tag: str) -> str:
if not isinstance(tag, str) or not STABLE_TAG_RE.fullmatch(tag or ""):
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 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())