mirror of
https://github.com/suyu-emu/drippu-rep.git
synced 2026-09-21 14:44:58 -07:00
921 lines
31 KiB
Python
921 lines
31 KiB
Python
#!/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())
|