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.
134 lines
5.6 KiB
Python
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())
|