229 lines
8.9 KiB
Python
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)
|