Files
hermes-agent/scripts/releases/stable.py
ethernet bf75bc2516 feat(ci): require desktop chat after bundle and install builds
Share the real composer, provider-witness and completed-reply check across
post-build bundle smoke and desktop-bearing install/update checkpoints.
Keep native automatic-relaunch proof separate from post-update chat.

Download receipt-bound artifacts without release credentials and install
DMG, ZIP, MSIX and universal MSIXBUNDLE on each native architecture.
Split Windows assembly from feed publication; publish tested bytes only.
Bind candidate smoke results into the manifest used by stable promotion.

Verify historical/source provenance without assuming a version IPC commit,
strip CI identity from source build children, and use the actual Electron
PID rather than Playwright's Windows launcher wrapper.

Validation: real Linux Electron chat and sequential OLD/NEW source smoke
with preserved history; 145 targeted Python tests and 14 JS tests passed;
TypeScript, shell/PowerShell parsing and workflow checks passed.
Native macOS/Windows deployment and historical upgrades need Actions proof.
2026-09-13 19:57:38 -04:00

275 lines
14 KiB
Python

"""Stable release admission, signed-package transitions and final release receipt."""
from __future__ import annotations
import hashlib
import json
import os
import re
import subprocess
import sys
import tomllib
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import unquote, urlsplit
from scripts.releases.semver import STABLE_TAG
SHA = re.compile(r"[a-f0-9]{40}")
DIGEST = re.compile(r"[a-f0-9]{64}")
DESKTOP_TARGETS = ("windows/x64", "windows/arm64", "macos/x64", "macos/arm64")
SMOKE_JOBS = {
"smoke-darwin": "macOS DMG + ZIP (arm64 and x64)",
"smoke-win32": "Windows MSIX (arm64 and x64)",
"smoke-win32-universal": "Windows MSIXBUNDLE (arm64 and x64)",
}
def require_stable_identity(tag: str, commit: str, ref: str) -> None:
if not isinstance(tag, str) or not STABLE_TAG.fullmatch(tag) or not SHA.fullmatch(commit or "") or ref != f"refs/tags/{tag}":
raise ValueError("Stable release must run on its exact stable tag and commit")
def require_success(needs: dict, required: list[str]) -> None:
if not required or len(set(required)) != len(required):
raise ValueError("Invalid required-job list")
failures = [f"{name}={needs.get(name, {}).get('result', 'missing')}"
for name in required if needs.get(name, {}).get("result") != "success"]
if failures:
raise ValueError("Release blocked: " + ", ".join(failures))
def successful_smoke_results(needs: object) -> dict:
"""Persist only observed successful native groups, never infer them from artifacts."""
if not isinstance(needs, dict):
raise ValueError("Candidate smoke results must be a job-result object")
results = {}
for job in SMOKE_JOBS:
row = needs.get(job)
results[job] = {"result": row.get("result") if isinstance(row, dict) else None}
require_success(results, list(SMOKE_JOBS))
return results
def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str,
*, allow_legacy: bool = False) -> dict:
require_stable_identity(tag, commit, f"refs/tags/{tag}")
if manifest.get("schema") not in (1, 2) or manifest.get("tag") != tag or manifest.get("commit") != commit or not isinstance(manifest.get("packages"), list):
raise ValueError("Candidate manifest does not match release identity")
if manifest["schema"] == 1:
# A published baseline supplies old package identities, not new smoke evidence.
if not allow_legacy:
raise ValueError("Legacy candidate has no bound smoke admission; build a new candidate tag")
else:
successful_smoke_results(manifest.get("smoke_results"))
prefix = urlsplit(f"{public_base.rstrip('/')}/releases/tag/{tag}/")
if prefix.scheme != "https" or prefix.username or prefix.password or not prefix.netloc:
raise ValueError("Public release origin must use HTTPS")
rows = {}
for item in manifest["packages"]:
target = f"{item.get('platform')}/{item.get('arch')}"
if target not in (*DESKTOP_TARGETS, "termux/aarch64") or target in rows or item.get("tag") != tag or item.get("commit") != commit:
raise ValueError(f"Invalid or duplicate candidate target: {target}")
artifact = item.get("artifact", {})
url = urlsplit(artifact.get("url", ""))
decoded = unquote(url.path)
if any(part in (".", "..") for part in decoded.split("/")) or "\\" in decoded or "%" in decoded:
raise ValueError("Invalid artifact path encoding")
if (url.scheme, url.netloc) != (prefix.scheme, prefix.netloc) or not url.path.startswith(prefix.path) or url.query or url.fragment or url.username or url.password:
raise ValueError(f"Candidate package is outside its immutable tag archive: {target}")
if not DIGEST.fullmatch(artifact.get("sha256", "")) or not item.get("identity"):
raise ValueError(f"Invalid candidate digest or identity: {target}")
if item["platform"] == "windows":
windows_version(item.get("version", ""))
if item["version"] != f"{tag[1:]}.0":
raise ValueError("Stable Windows package version must match its release tag")
if not item.get("publisher") or not item.get("applicationId") or not url.path.endswith(".msixbundle"):
raise ValueError("Windows candidate needs publisher, applicationId and MSIX bundle")
elif item["platform"] == "macos":
if item.get("version") != tag[1:] or not re.fullmatch(r"[A-Z0-9]{10}", item.get("teamId", "")) or not url.path.endswith(".zip"):
raise ValueError("macOS candidate needs matching version, signing team and app ZIP")
rows[target] = item
if any(target not in rows for target in DESKTOP_TARGETS):
raise ValueError("Candidate manifest must cover Windows and macOS on both architectures")
return rows
def windows_version(value: str) -> tuple[int, ...]:
if not isinstance(value, str) or not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", value):
raise ValueError("Windows package version must have four numeric components")
result = tuple(map(int, value.split(".")))
if any(n > 65535 for n in result):
raise ValueError("Windows package version exceeds 16 bits")
return result
def plan_transitions(previous: dict, candidate: dict, public_base: str) -> list[dict]:
old = validate_candidates(previous, previous.get("tag"), previous.get("commit"), public_base, allow_legacy=True)
new = validate_candidates(candidate, candidate.get("tag"), candidate.get("commit"), public_base)
result = []
for target in DESKTOP_TARGETS:
left, right = old[target], new[target]
if left["identity"] != right["identity"] or left["commit"] == right["commit"] or left["artifact"]["sha256"] == right["artifact"]["sha256"]:
raise ValueError("Update must preserve package identity and change the build")
if right["platform"] == "windows":
if (left["publisher"], left["applicationId"]) != (right["publisher"], right["applicationId"]):
raise ValueError("Update must preserve publisher and applicationId")
newer = windows_version(right["version"]) > windows_version(left["version"])
else:
if left["teamId"] != right["teamId"]:
raise ValueError("Update must preserve signing team")
newer = tuple(map(int, right["version"].split("."))) > tuple(map(int, left["version"].split(".")))
if not newer:
raise ValueError("New package version must increase")
result.append({"target": target.replace("/", "-"), "transition": {
"schema": 1, "platform": right["platform"], "arch": right["arch"], "old": left, "new": right,
}})
return result
def read_manifest(url: str, expected_hash: str | None = None, *, expected_origin: str | None = None,
opener=urllib.request.urlopen) -> dict:
location = urlsplit(url)
origin = urlsplit(expected_origin or url)
def check_origin(target):
if target.scheme != "https" or not target.hostname or target.username or target.password:
raise ValueError("Manifest origin must use HTTPS without credentials")
if (target.scheme, target.hostname, target.port or 443) != (origin.scheme, origin.hostname, origin.port or 443):
raise ValueError("Manifest is outside the expected release origin")
check_origin(location)
with opener(url, timeout=60) as response:
check_origin(urlsplit(response.geturl()))
data = response.read(1024 * 1024 + 1)
if len(data) > 1024 * 1024:
raise ValueError("Release manifest exceeds size limit")
if expected_hash and hashlib.sha256(data).hexdigest() != expected_hash:
raise ValueError("Candidate manifest digest mismatch")
return json.loads(data)
def output(argv: list[str]) -> str:
return subprocess.check_output(argv, text=True, encoding="utf-8").strip()
def check_tag(env: dict, run=output) -> tuple[str, str]:
tag, commit = env.get("RELEASE_TAG"), env.get("GITHUB_SHA")
require_stable_identity(tag, commit, env.get("GITHUB_REF"))
actual = run(["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"])
remote = dict(line.split()[::-1] for line in run(["git", "ls-remote", "origin", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"] ).splitlines())
remote_commit = remote.get(f"refs/tags/{tag}^{{}}", remote.get(f"refs/tags/{tag}"))
if actual != commit or remote_commit != commit or run(["git", "rev-parse", "HEAD"]) != commit:
raise ValueError("Release tag or checkout moved")
run(["git", "fetch", "origin", "main"])
run(["git", "merge-base", "--is-ancestor", commit, "origin/main"])
return tag, commit
def emit(values: dict, env: dict) -> None:
with Path(env["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as file:
for key, value in values.items():
file.write(f"{key}={value if isinstance(value, str) else json.dumps(value, separators=(',', ':'))}\n")
def read_candidate(env: dict) -> dict:
digest = env.get("CANDIDATE_MANIFEST_SHA256", "")
if not DIGEST.fullmatch(digest):
raise ValueError("Pinned candidate manifest digest is required")
return read_manifest(env["CANDIDATE_MANIFEST_URL"], digest)
def read_admitted_candidate(tag: str, commit: str, public_base: str, digest: str) -> dict:
"""The page and package promoter consume the same pinned admission."""
if not DIGEST.fullmatch(digest or ""):
raise ValueError("Pinned candidate manifest digest is required")
require_stable_identity(tag, commit, f"refs/tags/{tag}")
manifest = read_manifest(f"{public_base.rstrip('/')}/releases/tag/{tag}/release-candidates.json",
digest, expected_origin=public_base)
validate_candidates(manifest, tag, commit, public_base)
return manifest
def summary(text: str, env: dict) -> None:
with Path(env["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as file:
file.write(text + "\n")
def admit(env: dict) -> None:
tag, commit = check_tag(env)
with Path("pyproject.toml").open("rb") as file:
version = tomllib.load(file)["project"]["version"]
if f"v{version}" != tag:
raise ValueError("Stable tag must match the project version")
release = json.loads(output(["gh", "release", "view", tag, "--repo", env["GITHUB_REPOSITORY"], "--json", "tagName,isDraft,isPrerelease"]))
if release["tagName"] != tag or not release["isDraft"] or release["isPrerelease"]:
raise ValueError("Stable candidate must have a non-prerelease draft")
emit({"tag": tag, "commit": commit}, env)
summary(f"## Stable candidate {tag}\nCommit: {commit}\n\nDesktop Playwright E2E: deferred by owner, not passed.\nOSV findings retain the existing advisory policy.", env)
def transitions(env: dict) -> None:
from scripts.releases.r2 import put
tag, commit = check_tag(env)
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
candidate = read_candidate(env)
validate_candidates(candidate, tag, commit, base)
try:
previous = read_manifest(env.get("BASELINE_MANIFEST_URL") or f"{base}/releases/stable/release-candidates.json",
expected_origin=base)
except urllib.error.HTTPError as error:
if error.code == 404:
raise ValueError("No published stable package baseline. Supply baseline-manifest for an actual previous stable release; acceptance cannot be skipped.") from error
raise
published = json.loads(output(["gh", "release", "view", previous["tag"], "--repo", env["GITHUB_REPOSITORY"], "--json", "tagName,isDraft,isPrerelease"]))
if published["tagName"] != previous["tag"] or published["isDraft"] or published["isPrerelease"]:
raise ValueError("Upgrade baseline must be a published stable release")
matrices = {"windows": {"include": []}, "macos": {"include": []}}
for row in plan_transitions(previous, candidate, base):
transition = row["transition"]
name = f"acceptance-{row['target']}.json"
file = Path(env["RUNNER_TEMP"]) / name
file.write_text(json.dumps(transition), encoding="utf-8")
put(tag=tag, key=name, file=file, immutable=True)
url = f"{base}/releases/tag/{tag}/{name}"
if read_manifest(url) != transition:
raise ValueError("Transition manifest read-back mismatch")
matrices[transition["platform"]]["include"].append({"arch": transition["arch"], "manifest": url, "old": transition["old"]["tag"], "id": row["target"], "manifest_sha256": hashlib.sha256(file.read_bytes()).hexdigest()})
emit(matrices, env)
def complete(env: dict) -> None:
from scripts.releases.r2 import put
tag, commit = check_tag(env)
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
candidate = read_candidate(env)
validate_candidates(candidate, tag, commit, base)
file = Path(env["RUNNER_TEMP"]) / "release-candidates.json"
file.write_text(json.dumps(candidate), encoding="utf-8")
put(tag=tag, key="releases/stable/release-candidates.json", key_is_full=True, file=file)
if read_manifest(f"{base}/releases/stable/release-candidates.json") != candidate:
raise ValueError("Stable manifest read-back mismatch")
output(["gh", "release", "edit", tag, "--repo", env["GITHUB_REPOSITORY"], "--draft=false"])
release = json.loads(output(["gh", "release", "view", tag, "--repo", env["GITHUB_REPOSITORY"], "--json", "isDraft"]))
if release["isDraft"]:
raise ValueError("Stable release remained a draft")
def main(argv: list[str] | None = None, env: dict | None = None) -> None:
argv = sys.argv[1:] if argv is None else argv
env = os.environ if env is None else env
if argv and argv[0] == "gate":
needs = json.loads(env["RELEASE_NEEDS"])
summary("\n".join(f"- {name}: {needs.get(name, {}).get('result', 'missing')}" for name in argv[1:]), env)
require_success(needs, argv[1:])
return
commands = {"admit": admit, "transitions": transitions, "complete": complete}
if len(argv) != 1 or argv[0] not in commands:
raise ValueError("Expected admit, gate, transitions or complete")
commands[argv[0]](env)
if __name__ == "__main__":
main()