Files
shacraft-launcher/scripts/release_github.py
T

229 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Explicit workflow-only GitHub release operations, with fail-closed gates."""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import release
ENVIRONMENTS = {"launcher-release", "launcher-release-publish"}
def gh(*args, missing=False):
result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=900, check=False)
if result.returncode:
try:
body = json.loads(result.stdout)
except ValueError:
body = {}
if missing and str(body.get("status")) == "404":
return None
raise ValueError("GitHub operation failed; no permission or validation bypass is allowed")
return result.stdout
def api(path, missing=False):
value = gh("api", f"repos/{release.REPOSITORY}/{path}", missing=missing)
return None if value is None else json.loads(value)
def validate_environment(data):
release.require(isinstance(data, dict), "protected environment is absent")
policy = data.get("deployment_branch_policy") or {}
release.require(
policy.get("protected_branches") is True and policy.get("custom_branch_policies") is False,
"release environment must allow protected branches only",
)
rules = data.get("protection_rules") or []
reviewers = next((rule for rule in rules if rule.get("type") == "required_reviewers"), {})
release.require(
reviewers.get("prevent_self_review") is True, "environment must prevent self review"
)
allowed = reviewers.get("reviewers") or []
release.require(
any(
item.get("type") in {"User", "Team"}
and type((item.get("reviewer") or {}).get("id")) is int
and item["reviewer"]["id"] > 0
for item in allowed
),
"release environment requires an independent reviewer",
)
def workflow_guard():
release.require(
os.environ.get("GITHUB_REPOSITORY") == release.REPOSITORY, "wrong release repository"
)
release.require(
os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch",
"release must be dispatched manually",
)
release.require(
os.environ.get("GITHUB_REF") == "refs/heads/main", "release workflow must run from main"
)
def gate(environment):
workflow_guard()
release.require(environment in ENVIRONMENTS, "unexpected release environment")
release.require(
api("branches/main").get("protected") is True, "main must be a protected branch"
)
validate_environment(api(f"environments/{environment}", missing=True))
# Jobs consume this output only after validation. Never reference a missing
# environment directly: GitHub would create it without protection rules.
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream:
stream.write(f"environment={environment}\n")
def asset_snapshot(data):
release.require(
data.get("draft") is True and data.get("prerelease") is False,
"expected a stable DRAFT release",
)
assets = data.get("assets") or []
result = {}
for asset in assets:
name = asset["name"]
release.require(
name not in result and asset.get("state") == "uploaded",
"duplicate or incomplete release asset",
)
result[name] = (asset["id"], asset["size"], asset.get("digest"))
return result
def verify_draft(root, version, tag):
release.version_tag(version, tag)
before = api(f"releases/tags/{tag}")
release.require(before.get("tag_name") == tag, "draft tag mismatch")
snapshot = asset_snapshot(before)
packages = release.expected_names(version)
expected = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
release.require(set(snapshot) == expected, "draft asset set is incomplete or unexpected")
# Reject remote names and declared sizes before gh writes any asset locally.
# This is only a pre-download bound; signatures and actual bytes are still
# checked below, followed by the remote identity/race check.
for name, (_, size, _) in snapshot.items():
limit = (
32 * 1024 if name == "latest.json"
else 8 * 1024 if name.endswith(".sig")
else release.MAX_SIZE
)
release.require(type(size) is int and 0 < size <= limit, "invalid remote draft asset size")
with tempfile.TemporaryDirectory(prefix="shacraft-draft-check-") as temporary:
directory = Path(temporary)
gh("release", "download", tag, "--repo", release.REPOSITORY, "--dir", str(directory))
release.verify_release(root, directory, version, tag)
release.require(
set(snapshot) == {p.name for p in directory.iterdir()},
"draft assets changed during download",
)
for path in directory.iterdir():
release.require(snapshot[path.name][1] == path.stat().st_size, "draft size mismatch")
after = api(f"releases/tags/{tag}")
release.require(
after["id"] == before["id"] and asset_snapshot(after) == snapshot,
"draft changed during verification",
)
return after
def draft(root, directory, version, tag):
workflow_guard()
release.preflight(root, version, tag, check_git=True, signing=True)
release.verify_release(root, directory, version, tag)
with tempfile.TemporaryDirectory(prefix="shacraft-release-notes-") as temporary:
notes = Path(temporary) / "notes.md"
release_notes = release.read_json(directory / "latest.json")["notes"]
instructions = (
f"https://github.com/{release.REPOSITORY}/blob/{tag}/docs/updater-release.md"
"#installed-package-migration-and-recovery"
)
notes.write_text(
release_notes + "\n\n" +
"[Установка, переход с 0.1.1 и восстановление](" + instructions + ").\n" +
"Используйте прежний тип пакета. Обновление .deb выполняется через менеджер пакетов.\n",
encoding="utf-8",
)
gh(
"release",
"create",
tag,
*[str(path) for path in sorted(directory.iterdir())],
"--repo",
release.REPOSITORY,
"--draft",
"--verify-tag",
"--title",
f"ShaCraft Launcher {version}",
"--notes-file",
str(notes),
)
verify_draft(root, version, tag)
print(
"Complete draft uploaded and re-verified. Publication requires the separate protected workflow."
)
def publish(root, version, tag, confirmation):
workflow_guard()
release.require(
confirmation == f"publish {tag}", "explicit publication confirmation does not match tag"
)
release.preflight(root, version, tag, check_git=True)
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
release.require(
release.public_key() == pinned, "publication key differs from committed updater key"
)
gate("launcher-release-publish") # Re-check protection immediately before publication.
current = api("releases/latest", missing=True)
if current is not None:
old_tag = current.get("tag_name", "")
release.version_tag(old_tag.removeprefix("v"), old_tag)
release.require(
tuple(map(int, version.split("."))) > tuple(map(int, old_tag[1:].split("."))),
"publication must advance the stable release version",
)
verify_draft(root, version, tag)
gh("release", "edit", tag, "--repo", release.REPOSITORY, "--draft=false", "--latest")
print("Verified release published by explicit protected operator workflow.")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
sub = parser.add_subparsers(dest="command", required=True)
item = sub.add_parser("gate")
item.add_argument("environment", choices=sorted(ENVIRONMENTS))
for name in ("draft", "publish"):
item = sub.add_parser(name)
item.add_argument("--version", required=True)
item.add_argument("--tag", required=True)
if name == "draft":
item.add_argument("--directory", required=True, type=Path)
else:
item.add_argument("--confirmation", required=True)
args = parser.parse_args()
if args.command == "gate":
gate(args.environment)
elif args.command == "draft":
draft(args.root.resolve(), args.directory, args.version, args.tag)
else:
publish(args.root.resolve(), args.version, args.tag, args.confirmation)
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
print(f"Release stopped: {error}", file=sys.stderr)
sys.exit(1)