#!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright 2026 suyu Emulator Project # SPDX-License-Identifier: GPL-2.0-or-later from __future__ import annotations import copy import os import re import shlex import shutil import stat import subprocess import sys import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml REPO_ROOT = Path(__file__).resolve().parents[2] RELEASE_YML = REPO_ROOT / ".github" / "workflows" / "release.yml" PR_CI_YML = REPO_ROOT / ".github" / "workflows" / "exporter-smoke.yml" LIBRETRO_SOURCE = REPO_ROOT / "src" / "libretro_core" / "retro_core.cpp" ANDROID_BUILD_GRADLE = REPO_ROOT / "src" / "android" / "app" / "build.gradle.kts" PUBLISH_JOB_ID = "release" ROLLING_TAG = "v0.04-latest" STATUS_FUNCS = ("always", "success", "failure", "cancelled") @dataclass class JobSpec: job_id: str name: str needs: list[str] raw_if: str | None continue_on_error: bool steps: list[dict[str, Any]] uses: str | None @dataclass class GhEvent: kind: str args: list[str] @dataclass class PublishTrace: job_ran: bool events: list[GhEvent] = field(default_factory=list) stdout: str = "" stderr: str = "" exit_code: int | None = None created_tag: str | None = None notes: str = "" uploaded: list[str] = field(default_factory=list) checksum_text: str = "" def load_workflow(path: Path) -> dict[str, Any]: return yaml.safe_load(path.read_text(encoding="utf-8")) def parse_jobs(doc: dict[str, Any]) -> dict[str, JobSpec]: jobs: dict[str, JobSpec] = {} for job_id, raw in (doc.get("jobs") or {}).items(): needs = raw.get("needs") or [] if isinstance(needs, str): needs = [needs] jobs[job_id] = JobSpec( job_id=job_id, name=str(raw.get("name") or job_id), needs=list(needs), raw_if=raw.get("if"), continue_on_error=bool(raw.get("continue-on-error", False)), steps=list(raw.get("steps") or []), uses=raw.get("uses"), ) return jobs def unwrap_expression(expr: str) -> str: expr = expr.strip() if expr.startswith("${{") and expr.endswith("}}"): return expr[3:-2].strip() return expr def uses_status_check(expr: str) -> bool: return any(re.search(rf"\b{name}\s*\(", expr) for name in STATUS_FUNCS) def apply_github_default_success(expr: str) -> str: if uses_status_check(expr): return expr return f"success() && ({expr})" def tokenize(expr: str) -> list[str]: spec = r""" \s+ | '([^']*)' | "([^"]*)" | [A-Za-z_][A-Za-z0-9_-]* | && | \|\| | == | != | [()!.] """ tokens: list[str] = [] for match in re.finditer(spec, expr, re.VERBOSE): tok = match.group(0) if tok.isspace(): continue tokens.append(tok) return tokens class ExprEval: def __init__(self, tokens: list[str], ctx: dict[str, Any]): self.tokens = tokens self.i = 0 self.ctx = ctx def peek(self) -> str | None: return self.tokens[self.i] if self.i < len(self.tokens) else None def eat(self, expected: str | None = None) -> str: if self.i >= len(self.tokens): raise ValueError("unexpected end of expression") tok = self.tokens[self.i] if expected is not None and tok != expected: raise ValueError(f"expected {expected!r}, got {tok!r}") self.i += 1 return tok def parse(self) -> Any: value = self.parse_or() if self.peek() is not None: raise ValueError(f"trailing token {self.peek()!r}") return value def parse_or(self) -> bool: left = self.parse_and() while self.peek() == "||": self.eat("||") right = self.parse_and() left = bool(left) or bool(right) return bool(left) def parse_and(self) -> bool: left = self.parse_eq() while self.peek() == "&&": self.eat("&&") right = self.parse_eq() left = bool(left) and bool(right) return bool(left) def parse_eq(self) -> Any: left = self.parse_unary() while self.peek() in ("==", "!="): op = self.eat() right = self.parse_unary() if op == "==": left = _gha_equal(left, right) else: left = not _gha_equal(left, right) return left def parse_unary(self) -> Any: if self.peek() == "!": self.eat("!") return not bool(self.parse_unary()) return self.parse_primary() def parse_primary(self) -> Any: tok = self.peek() if tok is None: raise ValueError("expected value") if tok == "(": self.eat("(") value = self.parse_or() self.eat(")") return value if tok.startswith("'") or tok.startswith('"'): self.eat() return tok[1:-1] if tok in STATUS_FUNCS: name = self.eat() self.eat("(") self.eat(")") return self.ctx["status"][name]() if re.match(r"^[A-Za-z_]", tok): name = self.eat() if name == "needs": while self.peek() == ".": self.eat(".") name += "." + self.eat() return self.lookup_needs(name) return name raise ValueError(f"bad token {tok!r}") def lookup_needs(self, path: str) -> str: parts = path.split(".") if len(parts) != 3 or parts[0] != "needs" or parts[2] != "result": raise ValueError(f"unsupported needs path {path}") job_id = parts[1] results = self.ctx["needs_results"] if job_id not in results: raise ValueError(f"unknown needed job {job_id}") return results[job_id] def _gha_equal(left: Any, right: Any) -> bool: if isinstance(left, str) and isinstance(right, str): return left.lower() == right.lower() return left == right def evaluate_if( raw_if: str | None, needs: list[str], needs_results: dict[str, str], cancelled: bool = False, ) -> bool: needed = {job: needs_results[job] for job in needs} def success() -> bool: return all(result == "success" for result in needed.values()) and not cancelled def failure() -> bool: return any(result == "failure" for result in needed.values()) ctx = { "needs_results": needs_results, "status": { "always": lambda: True, "success": success, "failure": failure, "cancelled": lambda: cancelled, }, } if raw_if is None: return success() expr = apply_github_default_success(unwrap_expression(str(raw_if))) value = ExprEval(tokenize(expr), ctx).parse() return bool(value) def required_job_ids(jobs: dict[str, JobSpec]) -> list[str]: return [ job_id for job_id, spec in jobs.items() if job_id != PUBLISH_JOB_ID and not spec.continue_on_error ] def optional_job_ids(jobs: dict[str, JobSpec]) -> list[str]: return [ job_id for job_id, spec in jobs.items() if job_id != PUBLISH_JOB_ID and spec.continue_on_error ] def step_run_script(step: dict[str, Any]) -> str | None: run = step.get("run") if isinstance(run, str): return run action_inputs = step.get("with") if isinstance(action_inputs, dict) and isinstance(action_inputs.get("run"), str): return action_inputs["run"] return None def publish_scripts(spec: JobSpec) -> list[tuple[str, str]]: scripts: list[tuple[str, str]] = [] for step in spec.steps: run = step_run_script(step) if run is None: continue scripts.append((str(step.get("name") or "unnamed"), run)) return scripts def write_executable(path: Path, body: str) -> None: path.write_text(body, encoding="utf-8") path.chmod(path.stat().st_mode | stat.S_IEXEC) def shell_path(path: Path) -> str: """Render a temporary Windows path in the POSIX form Git Bash accepts.""" if os.name == "nt": rendered = "/" + path.drive[0].lower() + path.as_posix()[2:] else: rendered = str(path) return shlex.quote(rendered) def run_publish_shell( spec: JobSpec, artifacts: dict[str, bytes], repo: str = "SourCreamCulture/suyu-v0.0.4", commit: str = "deadbeefcafebabe0123456789abcdef01234567", ) -> PublishTrace: scripts = publish_scripts(spec) # Keep a space in the real execution path so every publish simulation # exercises the quoting required by Windows Git Bash and POSIX shells. with tempfile.TemporaryDirectory(prefix="suyu release gate-") as tmp: tmp_path = Path(tmp) artifacts_dir = tmp_path / "artifacts" bin_dir = tmp_path / "bin" log_path = tmp_path / "stub.log" notes_path = tmp_path / "created-notes.md" checksum_path = tmp_path / "created-checksums" uploaded_dir = tmp_path / "uploaded" artifacts_dir.mkdir() bin_dir.mkdir() uploaded_dir.mkdir() log_path.write_text("", encoding="utf-8") scripts_src = REPO_ROOT / ".github" / "scripts" if scripts_src.is_dir(): shutil.copytree(scripts_src, tmp_path / ".github" / "scripts") for relative, data in artifacts.items(): dest = artifacts_dir / relative dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(data) gh_stub = """#!/bin/bash set -euo pipefail LOG=__LOG__ echo "GH $*" >> "$LOG" if [ "${1:-}" = "release" ] && [ "${2:-}" = "delete" ]; then echo "DELETED ${3:-}" >> "$LOG" exit 0 fi if [ "${1:-}" = "release" ] && [ "${2:-}" = "create" ]; then tag="$3" notes="" files=() prerelease=0 latest=0 shift 3 while [ "$#" -gt 0 ]; do case "$1" in --repo|--target|--title|--notes|--notes-file) key="$1" val="$2" shift 2 if [ "$key" = "--notes" ]; then notes="$val"; fi if [ "$key" = "--notes-file" ]; then notes="$(cat "$val")"; fi ;; --repo=*|--title=*) ;; --notes=*) notes="${1#--notes=}" ;; --notes-file=*) notes="$(cat "${1#--notes-file=}")" ;; --prerelease|--prerelease=true) prerelease=1; shift ;; --prerelease=false) prerelease=0; shift ;; --latest|--latest=true) latest=1; shift ;; --latest=false) latest=0; shift ;; --draft) shift ;; --*) shift ;; *) files+=("$1") shift ;; esac done if [ "$prerelease" -eq 1 ] && [ "$latest" -eq 1 ]; then echo "HTTP 422: Validation Failed (https://api.github.com/repos/${GITHUB_REPOSITORY:-}/releases)" >&2 echo "Latest release cannot be draft or prerelease." >&2 echo "CLEANUP_DRAFT" >> "$LOG" exit 1 fi echo "CREATED $tag" >> "$LOG" printf '%s\\n' "$notes" > __NOTES__ : > __CHECKSUMS__ for f in "${files[@]+"${files[@]}"}"; do echo "UPLOAD $f" >> "$LOG" cp -a "$f" __UPLOADED__/ 2>/dev/null || true base="$(basename "$f")" case "$base" in *SHA256*|*sha256*|*CHECKSUM*|*checksum*) cat "$f" >> __CHECKSUMS__ || true ;; esac done echo "$notes" >> __CHECKSUMS__ exit 0 fi exit 0 """ write_executable( bin_dir / "gh", gh_stub.replace("__LOG__", shell_path(log_path)) .replace("__NOTES__", shell_path(notes_path)) .replace("__CHECKSUMS__", shell_path(checksum_path)) .replace("__UPLOADED__", shell_path(uploaded_dir)), ) git_stub = """#!/bin/bash LOG=__LOG__ if [ "${1:-}" = "push" ] && [ "${2:-}" = "--delete" ]; then echo "GIT_PUSH_DELETE $*" >> "$LOG" exit 0 fi exec /usr/bin/git "$@" """ write_executable(bin_dir / "git", git_stub.replace("__LOG__", shell_path(log_path))) env = os.environ.copy() env["PATH"] = str(bin_dir) + os.pathsep + env.get("PATH", "") env["GH_TOKEN"] = "stub" env["GITHUB_REPOSITORY"] = repo env["GITHUB_SHA"] = commit env["GITHUB_RUN_ID"] = "1" env["GITHUB_SERVER_URL"] = "https://github.com" env["GITHUB_WORKSPACE"] = str(tmp_path) stdout_parts: list[str] = [] stderr_parts: list[str] = [] exit_code = 0 for name, script in scripts: rendered = ( script.replace("${{ github.repository }}", repo) .replace("${{ github.sha }}", commit) .replace("${{ github.run_id }}", "1") .replace("${{ github.server_url }}", "https://github.com") ) # Git Bash login profiles rebuild PATH from the Windows process # environment, which can hide the temporary gh/git stubs above. # Re-export the POSIX form explicitly so the publish simulation is # deterministic on Windows developer machines as well as CI. if os.name == "nt": bin_posix = "/" + bin_dir.drive[0].lower() + bin_dir.as_posix()[2:] rendered = f"export PATH={shlex.quote(bin_posix)}:$PATH\n{rendered}" rendered = rendered.replace( "gh release", f"{shlex.quote(bin_posix + '/gh')} release" ) proc = subprocess.run( ["bash", "-lc", rendered], cwd=tmp_path, env=env, capture_output=True, text=True, ) stdout_parts.append(f"$ {name}\n{proc.stdout}") if proc.stderr: stderr_parts.append(f"$ {name}\n{proc.stderr}") if proc.returncode != 0: exit_code = proc.returncode break events: list[GhEvent] = [] created_tag = None for line in log_path.read_text(encoding="utf-8").splitlines(): parts = line.split() if not parts: continue if parts[0] == "DELETED": events.append(GhEvent("delete", parts[1:])) elif parts[0] == "CREATED": created_tag = parts[1] events.append(GhEvent("create", parts[1:])) elif parts[0] == "GIT_PUSH_DELETE": events.append(GhEvent("git-delete", parts[1:])) elif parts[0] == "UPLOAD": events.append(GhEvent("upload", parts[1:])) elif parts[0] == "CLEANUP_DRAFT": events.append(GhEvent("cleanup-draft", [])) elif parts[0] == "GH": events.append(GhEvent("gh", parts[1:])) notes = notes_path.read_text(encoding="utf-8") if notes_path.exists() else "" checksum_text = ( checksum_path.read_text(encoding="utf-8") if checksum_path.exists() else "" ) uploaded = sorted(p.name for p in uploaded_dir.iterdir()) if uploaded_dir.exists() else [] return PublishTrace( job_ran=True, events=events, stdout="\n".join(stdout_parts), stderr="\n".join(stderr_parts), exit_code=exit_code, created_tag=created_tag, notes=notes, uploaded=uploaded, checksum_text=checksum_text, ) def default_results(jobs: dict[str, JobSpec], **overrides: str) -> dict[str, str]: results = {job_id: "success" for job_id in jobs if job_id != PUBLISH_JOB_ID} results.update(overrides) return results def would_publish(jobs: dict[str, JobSpec], results: dict[str, str], cancelled: bool = False) -> bool: spec = jobs[PUBLISH_JOB_ID] return evaluate_if(spec.raw_if, spec.needs, results, cancelled=cancelled) def yaml_text() -> str: return RELEASE_YML.read_text(encoding="utf-8") def pr_ci_text() -> str: return PR_CI_YML.read_text(encoding="utf-8") if PR_CI_YML.exists() else "" def workflow_has_aot_job(doc: dict[str, Any], text: str) -> bool: if "src/tests/recompiler" in text: return True if "exporter-smoke.yml" in text: return True for spec in parse_jobs(doc).values(): if spec.uses and "exporter-smoke.yml" in spec.uses: return True return False def workflow_has_test_suite_job(doc: dict[str, Any], text: str) -> bool: if re.search(r"--target\s+tests\b", text): return True if "YUZU_TESTS=ON" in text or "-DYUZU_TESTS=ON" in text: return True for spec in parse_jobs(doc).values(): if spec.uses and spec.uses.endswith("tests.yml"): return True if spec.job_id == "tests" and spec.job_id != "exporter-smoke": return True return False def enabled_run_lines(spec: JobSpec) -> list[str]: """Return executable lines from enabled run steps, excluding comments.""" lines: list[str] = [] for step in spec.steps: if not isinstance(step, dict): continue raw_if = step.get("if") if isinstance(raw_if, str) and unwrap_expression(raw_if).strip().lower() in {"false", "0"}: continue run = step_run_script(step) if run is None: continue lines.extend( line.strip() for line in run.splitlines() if line.strip() and not line.lstrip().startswith("#") ) return lines def workflow_has_artifact_verification(doc: dict[str, Any]) -> bool: """Require native package checks in their owning jobs before publishing.""" jobs = parse_jobs(doc) expected = { "build-windows": "verify_release_artifacts.py desktop --platform windows", "build-linux": "verify_release_artifacts.py desktop --platform linux", "build-macos": "verify_release_artifacts.py desktop --platform macos", "build-freebsd": "ldd _pkg/drippu", "build-android": "verify_release_artifacts.py apk --platform android", "build-libretro": "verify_release_artifacts.py libretro --platform linux", "build-libretro-windows": "verify_release_artifacts.py libretro --platform windows", "build-libretro-android": "tar -tzf drippu-libretro-core-android-arm64.tar.gz", } for job_id, marker in expected.items(): spec = jobs.get(job_id) if spec is None or not any(marker in line for line in enabled_run_lines(spec)): return False aggregate = jobs.get("verify-release-artifacts") if aggregate is None or not any( "check_release_gate.py" in line for line in enabled_run_lines(aggregate) ): return False publish = jobs.get(PUBLISH_JOB_ID) return publish is not None and "verify-release-artifacts" in publish.needs def workflow_requires_android_release_signing(doc: dict[str, Any]) -> bool: spec = parse_jobs(doc).get("build-android") if spec is None or not ANDROID_BUILD_GRADLE.is_file(): return False signing_configuration = "\n".join(enabled_run_lines(spec)) signing_configuration += "\n" + ANDROID_BUILD_GRADLE.read_text(encoding="utf-8") markers = ( "ANDROID_KEYSTORE_B64", "ANDROID_KEY_ALIAS", "ANDROID_KEYSTORE_PASS", 'storeType = "PKCS12"', "release.p12", "apksigner", "DEBUG_CERT", ) return all(marker in signing_configuration for marker in markers) def libretro_source_handles_directory_creation() -> bool: if not LIBRETRO_SOURCE.is_file(): return False source = LIBRETRO_SOURCE.read_text(encoding="utf-8") ignored_create_dir = re.search( r"^\s*Common::FS::CreateDir\([^;]+\);\s*$", source, re.MULTILINE ) return ignored_create_dir is None and '"common/logging/log.h"' not in source def publish_deletes_a_release(spec: JobSpec) -> bool: for _, script in publish_scripts(spec): if "gh release delete" in script or "git push --delete" in script: return True return False def immutable_tag(tag: str | None) -> bool: if not tag: return False if tag == ROLLING_TAG or tag.endswith("-latest"): return False return bool(re.search(r"[0-9a-f]{7,}", tag)) or bool(re.search(r"\d{8}", tag)) def has_commit_metadata(trace: PublishTrace, commit: str) -> bool: blob = trace.notes + "\n" + trace.checksum_text + "\n" + "\n".join(trace.uploaded) return commit in blob or commit[:12] in blob or commit[:7] in blob def has_checksum_metadata(trace: PublishTrace) -> bool: blob = (trace.notes + "\n" + trace.checksum_text).lower() if "sha256" in blob or "checksum" in blob: return True return any("sha256" in name.lower() or "checksum" in name.lower() for name in trace.uploaded) def has_toolchain_metadata(trace: PublishTrace) -> bool: blob = (trace.notes + "\n" + trace.checksum_text + "\n" + "\n".join(trace.uploaded)).lower() keys = ("cmake", "compiler", "toolchain", "clang", "gcc", "msvc", "appleclang") return any(key in blob for key in keys) def has_portable_checksums(trace: PublishTrace) -> bool: entries = re.findall(r"^[0-9a-f]{64}\s+\*?(.+)$", trace.checksum_text, re.MULTILINE) return bool(entries) and all("/" not in entry and "\\" not in entry for entry in entries) def has_formatted_release_notes(trace: PublishTrace) -> bool: required_sections = ("## Downloads", "## What's changed", "## Verification") return all(section in trace.notes for section in required_sections) and "```text" in trace.notes def release_targets_commit(trace: PublishTrace, commit: str) -> bool: return any( event.kind == "gh" and "release" in event.args and "create" in event.args and "--target" in event.args and commit in event.args for event in trace.events ) class Check: def __init__(self) -> None: self.fails = 0 def expect(self, cond: bool, msg: str) -> None: if cond: print(f"PASS: {msg}") else: print(f"FAIL: {msg}") self.fails += 1 def sample_artifacts(missing: set[str] | None = None) -> dict[str, bytes]: files = { "windows/drippu-windows-x64.zip": b"win", "windows/toolchain.txt": b"compiler=msvc\ncmake=4.1\n", "linux/drippu-linux-x64.tar.gz": b"lin", "linux/toolchain.txt": b"compiler=gcc\ncmake=4.1\n", "macos/drippu-macos-arm64.tar.gz": b"mac", "macos/toolchain.txt": b"compiler=appleclang\ncmake=4.1\n", "android/app-legacy-release.apk": b"apk-legacy", "android/app-mainline-release.apk": b"apk-mainline", "android/app-chromeOS-release.apk": b"apk-chromeos", "android/app-genshinSpoof-release.apk": b"apk-genshin", "libretro/drippu-libretro-core-linux-x64.tar.gz": b"lr", "libretro-windows/drippu-libretro-core-windows-x64.zip": b"lrw", "freebsd/drippu-freebsd-x64.tar.gz": b"fbsd", "freebsd/toolchain.txt": b"compiler=clang\ncmake=4.1\n", "libretro-android/drippu-libretro-core-android-arm64.tar.gz": b"lra", } if missing: for key in list(files): job = key.split("/", 1)[0] if job in missing: del files[key] return files def main() -> int: check = Check() if not RELEASE_YML.is_file(): print(f"FAIL: missing {RELEASE_YML}") return 1 doc = load_workflow(RELEASE_YML) jobs = parse_jobs(doc) text = yaml_text() check.expect(PUBLISH_JOB_ID in jobs, "release workflow defines a publish job") spec = jobs[PUBLISH_JOB_ID] required = required_job_ids(jobs) optional = optional_job_ids(jobs) print(f"publish if: {spec.raw_if!r}") print(f"publish needs: {spec.needs}") print(f"required jobs: {required}") print(f"optional jobs: {optional}") check.expect( PR_CI_YML.is_file() and "pull_request" in pr_ci_text(), "PR CI exists separately from publishing (exporter-smoke.yml on pull_request)", ) linux_fail = default_results(jobs, **{"build-linux": "failure"}) publishes_after_required_fail = would_publish(jobs, linux_fail) print(f"publish after required linux failure: {publishes_after_required_fail}") check.expect( not publishes_after_required_fail, "required platform failure does not run Publish Release", ) check.expect(not optional, "release workflow has no failure-tolerant build jobs") for formerly_optional in ("build-freebsd", "build-libretro-android"): platform_fail = default_results(jobs, **{formerly_optional: "failure"}) publishes_after_platform_fail = would_publish(jobs, platform_fail) print( f"publish after {formerly_optional} failure: " f"{publishes_after_platform_fail}" ) check.expect( formerly_optional in required and not publishes_after_platform_fail, f"{formerly_optional} failure blocks Publish Release", ) all_fail = {job_id: "failure" for job_id in jobs if job_id != PUBLISH_JOB_ID} check.expect( not would_publish(jobs, all_fail), "all platform failures do not run Publish Release", ) check.expect( not would_publish(jobs, default_results(jobs), cancelled=True), "cancelled workflow does not run Publish Release", ) check.expect( would_publish(jobs, default_results(jobs)), "all required successes run Publish Release", ) check.expect( not publish_deletes_a_release(spec), "publish job does not delete a release or tag", ) check.expect( workflow_has_aot_job(doc, text), "release workflow generates and compiles an AOT project (exporter smoke)", ) check.expect( workflow_has_test_suite_job(doc, text), "release workflow builds and runs the test suite", ) check.expect( workflow_has_artifact_verification(doc), "release workflow verifies desktop and libretro packages before publishing", ) check.expect( workflow_requires_android_release_signing(doc), "release workflow requires non-debug Android signing", ) check.expect( libretro_source_handles_directory_creation(), "Android libretro source handles directory creation results without logging macro conflicts", ) commented = copy.deepcopy(doc) commented_step = commented["jobs"]["build-linux"]["steps"] for step in commented_step: if isinstance(step, dict) and "verify_release_artifacts.py desktop --platform linux" in str(step.get("run", "")): step["run"] = "# verify_release_artifacts.py desktop --platform linux" break check.expect( not workflow_has_artifact_verification(commented), "commented-out package checks do not satisfy the release gate", ) unrelated = copy.deepcopy(doc) unrelated["jobs"]["build-windows"]["steps"] = [] unrelated["jobs"]["unrelated-marker-job"] = { "steps": [{"run": "verify_release_artifacts.py desktop --platform windows"}], } check.expect( not workflow_has_artifact_verification(unrelated), "package checks in unrelated jobs do not satisfy the release gate", ) missing_publish_dependency = copy.deepcopy(doc) missing_publish_dependency["jobs"][PUBLISH_JOB_ID]["needs"] = [ need for need in missing_publish_dependency["jobs"][PUBLISH_JOB_ID]["needs"] if need != "verify-release-artifacts" ] check.expect( not workflow_has_artifact_verification(missing_publish_dependency), "publishing must depend on the aggregate artifact verification job", ) path_with_spaces = shell_path(Path("C:/Temp/release gate/trace.log")) check.expect( "release gate" in path_with_spaces and path_with_spaces.startswith("'") and path_with_spaces.endswith("'"), "converted temporary paths are shell-quoted when they contain spaces", ) commit = "cafebabedeadbeef0123456789abcdef01234567" print("--- drive: required linux missing, leftover windows zip ---") if publishes_after_required_fail: trace = run_publish_shell(spec, sample_artifacts(missing={"linux"}), commit=commit) kinds = [event.kind for event in trace.events] print(trace.stdout) print(f"events: {kinds}") print(f"created_tag: {trace.created_tag}") print(f"exit: {trace.exit_code}") check.expect( "delete" not in kinds and "git-delete" not in kinds, "required failure does not delete v0.04-latest", ) check.expect( trace.created_tag is None, "required failure does not create a replacement release", ) else: print("publish job skipped (gate held)") print("--- drive: empty artifacts, all jobs reported success ---") empty_trace = run_publish_shell(spec, {}, commit=commit) empty_kinds = [event.kind for event in empty_trace.events] print(empty_trace.stdout) print(f"events: {empty_kinds}") print(f"exit: {empty_trace.exit_code}") check.expect( "delete" not in empty_kinds and "git-delete" not in empty_kinds, "empty artifacts do not delete an existing release", ) check.expect(empty_trace.created_tag is None, "empty artifacts do not create a release") check.expect(empty_trace.exit_code != 0, "empty artifacts fail the publish step") for missing_job in ("linux", "freebsd", "libretro-android", "android"): print(f"--- drive: {missing_job} artifact missing, jobs reported success ---") partial_trace = run_publish_shell( spec, sample_artifacts(missing={missing_job}), commit=commit ) partial_kinds = [event.kind for event in partial_trace.events] print(partial_trace.stdout) print(f"events: {partial_kinds}") print(f"exit: {partial_trace.exit_code}") check.expect( "delete" not in partial_kinds and "create" not in partial_kinds, f"missing required {missing_job} artifact does not delete or publish", ) check.expect( partial_trace.exit_code != 0, f"missing required {missing_job} artifact fails publish", ) print("--- drive: required artifacts present ---") full_trace = run_publish_shell(spec, sample_artifacts(), commit=commit) full_kinds = [event.kind for event in full_trace.events] print(full_trace.stdout) if full_trace.stderr: print(full_trace.stderr) print(f"events: {full_kinds}") print(f"created_tag: {full_trace.created_tag}") print(f"notes: {full_trace.notes!r}") print(f"exit: {full_trace.exit_code}") check.expect(full_trace.exit_code == 0, "successful artifact set publishes") check.expect( "delete" not in full_kinds and "git-delete" not in full_kinds, "successful publish does not delete a prior tag", ) check.expect( immutable_tag(full_trace.created_tag), f"published tag is immutable (not {ROLLING_TAG}): {full_trace.created_tag}", ) check.expect( has_commit_metadata(full_trace, commit), "release notes or files include the commit id", ) check.expect( has_checksum_metadata(full_trace), "release includes checksum metadata", ) check.expect( has_toolchain_metadata(full_trace), "release includes toolchain metadata", ) check.expect( has_portable_checksums(full_trace), "release checksums use flat download filenames", ) check.expect( has_formatted_release_notes(full_trace), "release notes contain readable Markdown sections and code fences", ) check.expect( release_targets_commit(full_trace, commit), "release tag targets the exact verified commit", ) print(f"check_release_gate: {check.fails} failure(s)") return 1 if check.fails else 0 if __name__ == "__main__": sys.exit(main())