Files
hermes-agent/scripts/releases/docker.py
ethernet b0ab0162b0 feat(release): gate stable promotion through the full release pipeline
Run the entire CI workflow before Docker build and tests. Require Nix,
native payload smoke tests, install/update E2E and signed-package upgrade
acceptance before publishing. Keep Desktop Playwright E2E deferred.

Archive tested Docker images and signed bundle candidates with provenance
and hashes. Publishers consume those exact artifacts without rebuilding.
Advance stable channels only after all required publications succeed.
Keep canaries on their separate path and reject direct stable-builder
publication that bypasses the gate.

Move shared release transport, manifests and gates to Python. Keep native
Electron adapters in JS and share feed/MIME facts as JSON. Replace the
R2/feed JS implementation and move its protocol tests to Python.

Verified targeted Python and JS tests, real loopback transport and CLI
execution, temporary Git admission, workflow graph lint, and typechecks.
No live stable release was run. Native signing, package upgrades and real
registry/Store promotion still need their release-run receipts. Separate
services cannot promote atomically. A promotion failure keeps the run red.
2026-09-07 14:40:10 -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 scripts.releases.semver import STABLE_TAG
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.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())