Files
hermes-agent/scripts/releases/stable.py
2026-09-22 02:19:38 -04:00

538 lines
27 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 datetime import datetime, timezone
from pathlib import Path
from urllib.parse import unquote, urlsplit
from hermes_cli.update_channel import STABLE_TAG_RE
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 admit_claim(tag: str, commit: str, *, on_main) -> dict:
"""Admit a release from its claim tag. The checkout version is not read.
``main`` carries ``0.0.0`` on purpose, so the version comes from the
``-rc`` tag and the only question about the commit is whether it is on
``main``.
"""
from scripts.releases.versioning import version_from_tag
if not isinstance(tag, str) or not tag.endswith("-rc"):
raise ValueError(f"{tag} is not a claim tag")
version = version_from_tag(tag[:-3])
if version is None:
raise ValueError(f"{tag} is not a claim tag")
if not on_main(commit):
raise ValueError(f"{commit} is not on main")
return {"claim_tag": tag, "tag": f"v{version}", "version": version, "commit": commit}
def require_stable_identity(tag: str, commit: str) -> None:
"""Validate the final payload identity without requiring its future ref."""
if not isinstance(tag, str) or not STABLE_TAG_RE.fullmatch(tag) or not SHA.fullmatch(commit or ""):
raise ValueError("Invalid stable payload identity")
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 stable_windows_version(epoch: object) -> str:
if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 0:
raise ValueError("Stable release epoch must be a non-negative integer")
instant = datetime.fromtimestamp(epoch, tz=timezone.utc)
start = datetime(instant.year, 1, 1, tzinfo=timezone.utc)
hour_of_year = (instant - start).days * 24 + instant.hour
second_of_hour = instant.minute * 60 + instant.second
return f"{instant.year}.{hour_of_year}.{second_of_hour}.0"
def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str,
release_epoch: int | None = None) -> dict:
require_stable_identity(tag, commit)
if manifest.get("schema") != 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")
successful_smoke_results(manifest.get("smoke_results"))
admitted_epoch = manifest.get("releaseEpoch")
expected_windows_version = stable_windows_version(admitted_epoch)
if release_epoch is not None and admitted_epoch != release_epoch:
raise ValueError("Candidate release epoch differs from the admitted claim")
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":
if (item.get("version") != expected_windows_version
or item.get("executableVersion") != expected_windows_version):
raise ValueError("Windows candidate version differs from the admitted release epoch")
windows_version(item["version"])
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")
elif item.get("version") != f"{tag[1:]}-1":
raise ValueError("Termux candidate version differs from the admitted release")
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)
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 _claim_metadata(raw: str, *, version: str, commit: str) -> dict:
try:
metadata = json.loads(raw)
except (TypeError, json.JSONDecodeError) as error:
raise ValueError("Stable claim metadata is invalid") from error
expected = {"schema": 1, "version": version, "commit": commit}
if (not isinstance(metadata, dict)
or any(metadata.get(key) != value for key, value in expected.items())
or not isinstance(metadata.get("autopublish"), bool)
or not isinstance(metadata.get("claimEpoch"), int)
or metadata["claimEpoch"] <= 0
or set(metadata) != {*expected, "autopublish", "claimEpoch"}):
raise ValueError("Stable claim metadata is invalid")
return metadata
def tagger_epoch(tag_object: str, run=output) -> int:
body = run(["git", "cat-file", "-p", tag_object])
tagger = next((line for line in body.splitlines() if line.startswith("tagger ")), None)
if tagger is None:
raise ValueError("Stable claim has no tagger timestamp")
try:
epoch = int(tagger.rsplit(" ", 2)[1])
except (IndexError, ValueError) as error:
raise ValueError("Stable claim tagger timestamp is invalid") from error
if epoch <= 0:
raise ValueError("Stable claim tagger timestamp is invalid")
return epoch
def check_claim(env: dict, run=output) -> dict:
"""Bind the run to one remote annotated claim object and its commit."""
claim_tag, commit = env.get("RELEASE_CLAIM_TAG"), env.get("GITHUB_SHA")
if not isinstance(claim_tag, str) or env.get("GITHUB_REF") != f"refs/tags/{claim_tag}":
raise ValueError("Stable release must run on its exact claim ref")
if not isinstance(commit, str) or not SHA.fullmatch(commit):
raise ValueError("Stable claim needs an exact commit")
claim_ref = f"refs/tags/{claim_tag}"
local_object = run(["git", "rev-parse", claim_ref])
local_commit = run(["git", "rev-parse", f"{claim_ref}^{{commit}}"])
if run(["git", "cat-file", "-t", local_object]) != "tag":
raise ValueError("Stable claim must be an annotated tag")
remote = dict(line.split()[::-1] for line in run(
["git", "ls-remote", "origin", claim_ref, f"{claim_ref}^{{}}"]
).splitlines())
remote_object = remote.get(claim_ref)
remote_commit = remote.get(f"{claim_ref}^{{}}")
expected_object = env.get("RELEASE_CLAIM_OBJECT")
if (local_commit != commit or remote_commit != commit or remote_object != local_object
or (expected_object and remote_object != expected_object)
or run(["git", "rev-parse", "HEAD"]) != commit):
raise ValueError("Stable claim tag or checkout moved")
run(["git", "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main"])
def on_main(sha: str) -> bool:
try:
run(["git", "merge-base", "--is-ancestor", sha, "origin/main"])
except subprocess.CalledProcessError:
return False
return True
admitted = admit_claim(claim_tag, commit, on_main=on_main)
raw_metadata = run(["git", "tag", "-l", claim_tag, "--format=%(contents)"])
metadata = _claim_metadata(raw_metadata, version=admitted["version"], commit=commit)
claim_epoch = tagger_epoch(local_object, run)
if metadata["claimEpoch"] != claim_epoch:
raise ValueError("Stable claim epoch differs from its annotated tagger timestamp")
return {**admitted, "claim_object": local_object,
"autopublish": metadata["autopublish"], "claim_epoch": claim_epoch}
def stable_context(env: dict, run=output) -> tuple[str, str, dict]:
claim = check_claim(env, run=run)
tag = env.get("RELEASE_TAG")
if not isinstance(tag, str) or tag != claim["tag"]:
raise ValueError("Stable payload tag differs from the admitted claim")
return tag, claim["commit"], claim
def final_context(env: dict, run=output) -> tuple[str, str, dict]:
"""Verify the final annotated receipt, its claim, and published release."""
repository = env.get("GITHUB_REPOSITORY", "")
tag = env.get("RELEASE_TAG", "")
commit = env.get("RELEASE_COMMIT", "")
claim_tag = env.get("RELEASE_CLAIM_TAG", "")
claim_object = env.get("RELEASE_CLAIM_OBJECT", "")
require_stable_identity(tag, commit)
admitted = admit_claim(claim_tag, commit, on_main=lambda _commit: True)
if admitted["tag"] != tag or not SHA.fullmatch(claim_object):
raise ValueError("Final release differs from its claim")
refs = {}
for line in run(["git", "ls-remote", "origin",
f"refs/tags/{claim_tag}", f"refs/tags/{claim_tag}^{{}}",
f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"]).splitlines():
sha, ref = line.split()
refs[ref] = sha
if (refs.get(f"refs/tags/{claim_tag}") != claim_object
or refs.get(f"refs/tags/{claim_tag}^{{}}") != commit
or refs.get(f"refs/tags/{tag}^{{}}") != commit
or not refs.get(f"refs/tags/{tag}")):
raise ValueError("Final release tag custody changed")
run(["git", "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main",
f"+refs/tags/{claim_tag}:refs/tags/{claim_tag}",
f"+refs/tags/{tag}:refs/tags/{tag}"])
for receipt, expected_object in ((claim_tag, claim_object),
(tag, refs[f"refs/tags/{tag}"])):
local_object = run(["git", "rev-parse", f"refs/tags/{receipt}"])
if local_object != expected_object or run(["git", "cat-file", "-t", local_object]) != "tag":
raise ValueError("Final release local tag differs from the remote")
run(["git", "merge-base", "--is-ancestor", commit, "origin/main"])
claim = _claim_metadata(
run(["git", "tag", "-l", claim_tag, "--format=%(contents)"]),
version=admitted["version"], commit=commit,
)
final = json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"]))
expected = {
"schema": 1, "version": admitted["version"], "commit": commit,
"claimTag": claim_tag, "claimTagObject": claim_object,
"autopublish": claim["autopublish"],
"claimEpoch": claim["claimEpoch"],
"releaseId": final.get("releaseId"),
"candidateManifestSha256": final.get("candidateManifestSha256"),
"dockerManifestDigest": final.get("dockerManifestDigest"),
}
if (final != expected
or not isinstance(final["releaseId"], int) or final["releaseId"] <= 0
or not DIGEST.fullmatch(final["candidateManifestSha256"] or "")
or not re.fullmatch(r"sha256:[a-f0-9]{64}", final["dockerManifestDigest"] or "")):
raise ValueError("Final tag metadata differs from its claim")
release = json.loads(run([
"gh", "api", f"repos/{repository}/releases/tags/{tag}",
]))
if (release.get("id") != final["releaseId"] or release.get("tag_name") != tag
or release.get("draft") is not False
or release.get("prerelease") is not False or not release.get("published_at")):
raise ValueError("Stable channel requires the published final release")
return tag, commit, {**admitted, "claim_object": claim_object,
"autopublish": claim["autopublish"],
"claim_epoch": claim["claimEpoch"],
"release_id": final["releaseId"],
"candidate_manifest_sha256": final["candidateManifestSha256"],
"docker_manifest_digest": final["dockerManifestDigest"]}
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)
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:
"""Admit the claim. The checkout carries 0.0.0, so the tag is the version."""
admitted = check_claim(env)
repository = env["GITHUB_REPOSITORY"]
release = json.loads(output([
"gh", "release", "view", admitted["claim_tag"], "--repo", repository,
"--json", "databaseId,tagName,isDraft,isPrerelease",
]))
if (release.get("tagName") != admitted["claim_tag"] or release.get("isDraft") is not True
or release.get("isPrerelease") is not False or not isinstance(release.get("databaseId"), int)):
raise ValueError("Stable claim must already own one non-prerelease draft")
emit({
"claim-tag": admitted["claim_tag"], "claim-object": admitted["claim_object"],
"tag": admitted["tag"], "commit": admitted["commit"], "version": admitted["version"],
"release-id": release["databaseId"], "release-epoch": admitted["claim_epoch"],
}, env)
summary(
f"## Stable candidate {admitted['claim_tag']}\nCommit: {admitted['commit']}\n"
f"Version: {admitted['version']}\nPayload tag: {admitted['tag']}\n",
env,
)
def verify(env: dict) -> None:
"""Revalidate claim custody in a reusable privileged workflow."""
tag, commit, claim = stable_context(env)
emit({"tag": tag, "sha": commit, "channel": "stable", "payload-version": tag[1:],
"release-epoch": claim["claim_epoch"]}, env)
def transitions(env: dict) -> None:
from scripts.releases.r2 import put
tag, commit, claim = stable_context(env)
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
candidate = read_candidate(env)
validate_candidates(candidate, tag, commit, base, claim["claim_epoch"])
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 _final_metadata(tag: str, commit: str, claim: dict, candidate_manifest_sha256: str,
docker_manifest_digest: str, release_id: int) -> dict:
if not DIGEST.fullmatch(candidate_manifest_sha256):
raise ValueError("Final tag candidate manifest digest is invalid")
if not re.fullmatch(r"sha256:[a-f0-9]{64}", docker_manifest_digest):
raise ValueError("Final tag Docker manifest digest is invalid")
if not isinstance(release_id, int) or release_id <= 0:
raise ValueError("Final tag release database ID is invalid")
return {
"schema": 1, "version": tag[1:], "commit": commit,
"claimTag": claim["claim_tag"], "claimTagObject": claim["claim_object"],
"autopublish": claim["autopublish"],
"claimEpoch": claim["claim_epoch"],
"releaseId": release_id,
"candidateManifestSha256": candidate_manifest_sha256,
"dockerManifestDigest": docker_manifest_digest,
}
def ensure_final_tag(tag: str, commit: str, claim: dict, *, candidate_manifest_sha256: str,
docker_manifest_digest: str, release_id: int, run=output) -> str:
"""Create or verify the immutable annotated final tag."""
require_stable_identity(tag, commit)
expected = _final_metadata(
tag, commit, claim, candidate_manifest_sha256, docker_manifest_digest, release_id,
)
ref = f"refs/tags/{tag}"
remote_raw = run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"])
if not remote_raw:
try:
local_object = run(["git", "rev-parse", "--verify", ref])
except subprocess.CalledProcessError:
message = json.dumps(expected, sort_keys=True, separators=(",", ":"))
run([
"git", "-c", "user.name=Hermes Release Automation",
"-c", "user.email=release-bot@users.noreply.github.com",
"tag", "-a", tag, commit, "-m", message,
])
else:
if (run(["git", "cat-file", "-t", local_object]) != "tag"
or run(["git", "rev-parse", f"{ref}^{{commit}}"]) != commit
or json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"])) != expected):
raise ValueError("Local final tag collision")
run(["git", "push", "origin", ref])
remote_raw = run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"])
remote = dict(line.split()[::-1] for line in remote_raw.splitlines())
tag_object, peeled = remote.get(ref), remote.get(f"{ref}^{{}}")
if not tag_object or peeled != commit:
raise ValueError("Final stable tag points at the wrong commit or is lightweight")
try:
local_object = run(["git", "rev-parse", ref])
except subprocess.CalledProcessError:
run(["git", "fetch", "origin", f"{ref}:{ref}"])
local_object = run(["git", "rev-parse", ref])
if local_object != tag_object or run(["git", "cat-file", "-t", local_object]) != "tag":
raise ValueError("Final stable tag object differs from the verified remote")
metadata = json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"]))
if metadata != expected:
raise ValueError("Final stable tag metadata differs from the accepted artifacts")
return tag_object
def retarget_release(repository: str, release_id: int, tag: str, commit: str, *, publish: bool,
run=output) -> None:
endpoint = f"repos/{repository}/releases/{release_id}"
current = json.loads(run(["gh", "api", endpoint]))
if (current.get("id") == release_id and current.get("tag_name") == tag
and current.get("prerelease") is False and current.get("draft") is False):
return
run([
"gh", "api", "--method", "PATCH", endpoint,
"--raw-field", f"tag_name={tag}", "--raw-field", f"target_commitish={commit}",
"--field", "prerelease=false", "--raw-field", "make_latest=true",
"--field", f"draft={str(not publish).lower()}",
])
release = json.loads(run(["gh", "api", endpoint]))
if (release.get("id") != release_id or release.get("tag_name") != tag
or release.get("prerelease") is not False or release.get("draft") is not (not publish)):
raise ValueError("Stable release retarget did not persist")
def complete(env: dict) -> None:
tag, commit, claim = stable_context(env)
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
candidate = read_candidate(env)
validate_candidates(candidate, tag, commit, base, claim["claim_epoch"])
release_id = env.get("RELEASE_ID", "")
if not str(release_id).isdigit():
raise ValueError("Stable release database ID is required")
ensure_final_tag(
tag, commit, claim,
candidate_manifest_sha256=env["CANDIDATE_MANIFEST_SHA256"],
docker_manifest_digest=env.get("DOCKER_MANIFEST_DIGEST", ""),
release_id=int(release_id),
)
retarget_release(env["GITHUB_REPOSITORY"], int(release_id), tag, commit, publish=False)
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, "verify": verify, "transitions": transitions, "complete": complete}
if len(argv) != 1 or argv[0] not in commands:
raise ValueError("Expected admit, verify, gate, transitions or complete")
commands[argv[0]](env)
if __name__ == "__main__":
main()