Files
EVEngine/scripts/release.py
T
西风逍遥游 31880f0021 feat(examples): gate examples in CI and make repository scripts UTF-8 on every host
`examples/<name>/` was a tested surface in name only. The source-quality job had
no examples check at all, and scripts/smoke_examples.sh accepted any process that
had not printed one of its error markers: a project that died during startup,
exited after a single frame, or crashed without a marker was reported as PASS.
Nothing stopped a malformed or unregistered example from reaching dev, which is
how 14 examples arrived without READMEs and 20 were never registered in the
overview table.

Source side
- scripts/check_examples.py classifies every directory under examples/ as either
  runnable (main.nut plus a config.nut that assigns the engine `config` table) or
  as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a
  reason. Both kinds must own a README.md and appear in examples/README.md.
  Stale exemptions and overview links to missing directories fail as well.
- `make check/examples` and a new source-quality CI step run the gate.
- scripts/tests/test_check_examples.py covers it and pins each failure mode
  against fixtures.

Runtime side
- scripts/smoke_examples.sh now enforces liveness, not just marker absence: every
  runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI
  passes 2s), and a process that dies inside that window fails whatever its exit
  code. A clean self-exit after the window passes with an explicit note instead
  of being silently accepted as a pass.
- The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's
  wall clock is unchanged (13.7 min of its 20 min budget on dev).

Windows / GBK
- scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py
  program calls it as the first statement of its __main__ block. On a Chinese
  Windows a redirected stream falls back to the ANSI code page (cp936), and
  printing a character that code page cannot encode (arrows, superscripts, bullet
  separators, emoji - all common in this repository) raised UnicodeEncodeError
  and turned a checker into a traceback.
- The mirror image is input: read_text() without encoding= decoded UTF-8
  repository text as cp936 and raised UnicodeDecodeError.
- scripts/tests/test_utf8_stdio.py enforces both conventions for every script and
  test module; text file I/O and child-process decoding now pass encoding="utf-8"
  explicitly.
- Unit-test invocations run with `python3 -X utf8` so the runner's own output
  never follows the host code page.

Violations the new gates found on top of dev, fixed here: 14 examples shipped
without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on
the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite,
pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration,
pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and
pcg-underwater added by #397), 20 were missing from the overview table (those 14
plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation,
triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no
UTF-8 stdio guard.

Verified locally: `python -m ruff check scripts` (All checks passed);
scripts/check_test_manifest.py; scripts/check_quality_metadata.py;
scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py;
`make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable,
7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests
-p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux
headless build, so it is verified by the CI examples smoke step rather than
locally.
2026-09-15 20:20:23 +08:00

828 lines
28 KiB
Python

from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import utf8_stdio
TAG_RE = re.compile(r"^v([0-9]+)\.([0-9]+)\.([0-9]+)$")
PROMOTE_HEAD_RE = re.compile(r"^promote/v[0-9]+\.[0-9]+\.[0-9]+$")
VERSION_BRANCH_RE = re.compile(r"^v([0-9]+)\.([0-9]+)\.([0-9]+)$")
PROMOTE_BRANCH_RE = re.compile(r"^promote/v([0-9]+)\.([0-9]+)\.([0-9]+)$")
REBASE_BRANCH_RE = re.compile(r"^rebase/v([0-9]+)\.([0-9]+)\.([0-9]+)$")
DOC_FILES = frozenset({"README.md", "Readme.md", "Readme.en.md", "Doxyfile"})
DEV_BRANCH = "dev"
MAIN_BRANCH = "main"
MAIN_GATE_CHECK = "main-gate"
# Non-doc files the release pipeline (`start`) rewrites on main. sync-docs must
# not reject them: the same changes reach `dev` through the release rebase PR.
RELEASE_PATHS = frozenset(
{
"CMakeLists.txt",
"platform/android/apk/app/build.gradle.kts",
"src/engine/devtools/McpServer.cpp",
"examples/basic/root.nut",
"platform/ios/game-shell/root.nut",
"platform/android/game-shell/root.nut",
}
)
_SET_RE = {
"major": re.compile(r'(set\(EVENGINE_MAJOR_VERSION\s+")([^"]*)("\))'),
"minor": re.compile(r'(set\(EVENGINE_MINOR_VERSION\s+")([^"]*)("\))'),
"patch": re.compile(r'(set\(EVENGINE_PATCH_VERSION\s+")([^"]*)("\))'),
"dev": re.compile(r'(set\(EVENGINE_DEV_VERSION\s+")([^"]*)("\))'),
}
@dataclass(frozen=True)
class VersionTouchpoint:
"""A file that carries the engine version outside CMakeLists.txt.
`kind` is either "required" (synced by `start`, enforced by
`check-versions` in CI) or "tracked" (standalone tools that version
independently; only reported as warnings).
`patterns` is a list of (compiled regex, value-kind) pairs. Each regex has
three groups: prefix, current value, suffix. Value-kind is one of
"major"/"minor"/"patch"/"triple"/"version_code".
"""
path: str
kind: str
patterns: tuple[tuple[re.Pattern[str], str], ...]
_VER_CODE = re.compile(r'(versionCode = )(\d+)([^\n]*)')
_VER_NAME = re.compile(r'(versionName = ")([^"]*)(")')
_DOC_VER = {
key: re.compile(rf'(set\(EVENGINE_{key.upper()}_VERSION ")([^"]*)("\))')
for key in ("major", "minor", "patch")
}
_PRINT_VER = re.compile(r"(EVEngine v)([0-9][0-9.]*)([^\n]*)")
_MCP_VER = re.compile(r'(EVEngine MCP\\",\\"version\\":\\")([0-9.]+)(\\")')
_JSON_VER = re.compile(r'("version": ")([^"]*)(")')
_PY_VER = re.compile(r'(__version__ = ")([^"]*)(")')
VERSION_TOUCHPOINTS: tuple[VersionTouchpoint, ...] = (
# Engine release artifacts: must carry the official MAJOR.MINOR.PATCH.
VersionTouchpoint(
"docs/CMakeLists.txt",
"required",
((_DOC_VER["major"], "major"), (_DOC_VER["minor"], "minor"), (_DOC_VER["patch"], "patch")),
),
VersionTouchpoint(
"platform/android/apk/app/build.gradle.kts",
"required",
((_VER_NAME, "triple"), (_VER_CODE, "version_code")),
),
VersionTouchpoint(
"src/engine/devtools/McpServer.cpp",
"required",
((_MCP_VER, "triple"),),
),
VersionTouchpoint("examples/basic/root.nut", "required", ((_PRINT_VER, "triple"),)),
VersionTouchpoint("platform/ios/game-shell/root.nut", "required", ((_PRINT_VER, "triple"),)),
VersionTouchpoint("platform/android/game-shell/root.nut", "required", ((_PRINT_VER, "triple"),)),
# Standalone tools: version independently, reported but never rewritten.
VersionTouchpoint("tools/eve-mcp/package.json", "tracked", ((_JSON_VER, "triple"),)),
VersionTouchpoint("tools/vscode-eve-debug/package.json", "tracked", ((_JSON_VER, "triple"),)),
VersionTouchpoint(
"modelconverter/python/eve_blender_converter/__init__.py", "tracked", ((_PY_VER, "triple"),)
),
VersionTouchpoint("tools/asset-pipeline-agent/asset_pipeline_agent/__init__.py", "tracked", ((_PY_VER, "triple"),)),
VersionTouchpoint("tools/scene-qc-agent/scene_qc_agent/__init__.py", "tracked", ((_PY_VER, "triple"),)),
VersionTouchpoint("tools/vision-prefilter/vision_prefilter/__init__.py", "tracked", ((_PY_VER, "triple"),)),
)
@dataclass(frozen=True)
class Version:
major: int
minor: int
patch: int
dev: str = ""
def triple(self) -> tuple[int, int, int]:
return (self.major, self.minor, self.patch)
def display(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}{self.dev}"
def official(self) -> Version:
return Version(self.major, self.minor, self.patch, "")
def as_dev(self) -> Version:
return Version(self.major, self.minor, self.patch, "-dev")
def parse_tag(tag: str) -> Version:
m = TAG_RE.fullmatch(tag)
if not m:
raise ValueError(f"tag must match vMAJOR.MINOR.PATCH, got {tag!r}")
return Version(int(m.group(1)), int(m.group(2)), int(m.group(3)), "")
def is_downgrade(current: Version, incoming: Version) -> bool:
return incoming.triple() < current.triple()
def read_version(cmake_text: str) -> Version:
def grab(key: str) -> str:
m = _SET_RE[key].search(cmake_text)
if not m:
raise ValueError(f"CMakeLists.txt missing EVENGINE_{key.upper()}_VERSION")
return m.group(2)
return Version(int(grab("major")), int(grab("minor")), int(grab("patch")), grab("dev"))
def write_version(cmake_text: str, version: Version) -> str:
values = {
"major": str(version.major),
"minor": str(version.minor),
"patch": str(version.patch),
"dev": version.dev,
}
def repl(key: str, text: str) -> str:
new, n = _SET_RE[key].subn(rf"\g<1>{values[key]}\g<3>", text, count=1)
if n != 1:
raise ValueError(f"CMakeLists.txt missing EVENGINE_{key.upper()}_VERSION")
return new
for key in ("major", "minor", "patch", "dev"):
cmake_text = repl(key, cmake_text)
return cmake_text
def _touchpoint_value(kind: str, version: Version) -> str:
if kind == "major":
return str(version.major)
if kind == "minor":
return str(version.minor)
if kind == "patch":
return str(version.patch)
if kind == "version_code":
return str(version.major * 10000 + version.minor * 100 + version.patch)
if kind == "triple":
return f"{version.major}.{version.minor}.{version.patch}"
raise ValueError(f"unknown touchpoint value kind: {kind}")
def _check_touchpoint(root: Path, tp: VersionTouchpoint, version: Version) -> list[str]:
"""Return a list of problems for one touchpoint (empty means consistent)."""
path = root / tp.path
if not path.exists():
return [f"{tp.path}: file missing"]
text = path.read_text(encoding="utf-8")
problems: list[str] = []
for pattern, kind in tp.patterns:
expected = _touchpoint_value(kind, version)
matches = pattern.findall(text)
if not matches:
problems.append(f"{tp.path}: version pattern not found ({pattern.pattern})")
continue
for groups in matches:
current = groups[1]
if current != expected:
problems.append(
f"{tp.path}: {current!r} != expected {expected!r} ({kind})"
)
return problems
def sync_version_files(root: Path, version: Version, *, dry_run: bool = False) -> list[str]:
"""Rewrite all required version touchpoints under `root` to `version`.
Returns the repo-relative paths that actually changed. Missing required
files are treated as a hard error: a release must not silently ship
without a version-bearing artifact.
"""
changed: list[str] = []
for tp in VERSION_TOUCHPOINTS:
if tp.kind != "required":
continue
path = root / tp.path
if not path.exists():
print(f"error: required version file missing: {tp.path}", file=sys.stderr)
raise SystemExit(1)
text = path.read_text(encoding="utf-8")
new_text = text
for pattern, kind in tp.patterns:
value = _touchpoint_value(kind, version)
new_text = pattern.sub(rf"\g<1>{value}\g<3>", new_text)
if new_text != text:
if dry_run:
print(f"+ update {tp.path} -> {version.display()}")
else:
path.write_text(new_text, encoding="utf-8")
changed.append(tp.path)
return changed
def cmd_check_versions(runner: Runner, *, root: Path) -> None:
"""Validate that every version touchpoint matches CMakeLists.txt."""
version = read_version((root / "CMakeLists.txt").read_text(encoding="utf-8"))
errors: list[str] = []
warnings: list[str] = []
for tp in VERSION_TOUCHPOINTS:
problems = _check_touchpoint(root, tp, version)
if tp.kind == "required":
errors += problems
else:
warnings += problems
for warning in warnings:
print(f"WARN: {warning}")
if errors:
for error in errors:
print(f"error: {error}", file=sys.stderr)
print(
f"error: tree version {version.display()} is not consistent across required "
"touchpoints (fix by hand or rerun `release.py start`)",
file=sys.stderr,
)
raise SystemExit(1)
required = sum(1 for tp in VERSION_TOUCHPOINTS if tp.kind == "required")
print(f"OK: {required} required version touchpoints match {version.display()}")
def is_doc_path(path: str) -> bool:
norm = path.replace("\\", "/").lstrip("./")
if norm in DOC_FILES:
return True
return norm == "docs" or norm.startswith("docs/")
def is_release_path(path: str) -> bool:
return path.replace("\\", "/").lstrip("./") in RELEASE_PATHS
def is_promote_head(head_ref: str) -> bool:
ref = head_ref.replace("\\", "/")
prefix = "refs/heads/"
if ref.startswith(prefix):
ref = ref[len(prefix) :]
return bool(PROMOTE_HEAD_RE.fullmatch(ref))
def main_pr_allowed(head_ref: str, changed_files: list[str]) -> bool:
if is_promote_head(head_ref):
return True
if not changed_files:
return False
return all(is_doc_path(p) for p in changed_files)
class CommandError(RuntimeError):
def __init__(self, argv: list[str], returncode: int, stderr: str):
super().__init__(f"command failed ({returncode}): {' '.join(argv)}\n{stderr}")
self.argv = argv
self.returncode = returncode
self.stderr = stderr
class Runner:
def run(self, argv: list[str], *, check: bool = True) -> str:
raise NotImplementedError
class RealRunner(Runner):
def __init__(self, *, dry_run: bool = False, cwd: Path | None = None):
self.dry_run = dry_run
self.cwd = cwd
def run(self, argv: list[str], *, check: bool = True) -> str:
if self.dry_run:
print("+", " ".join(argv))
return ""
proc = subprocess.run(argv, capture_output=True, text=True, cwd=self.cwd)
if check and proc.returncode != 0:
raise CommandError(argv, proc.returncode, proc.stderr)
return proc.stdout
class FakeRunner(Runner):
"""Match commands by argv prefix. cmake_text simulates CMakeLists.txt I/O."""
def __init__(self):
self.calls: list[list[str]] = []
self._rules: list[tuple[list[str], str, int]] = []
self.cmake_text: str = ""
def when(self, prefix: list[str], *, stdout: str = "", rc: int = 0) -> None:
self._rules.append((prefix, stdout, rc))
def run(self, argv: list[str], *, check: bool = True) -> str:
self.calls.append(list(argv))
for prefix, stdout, rc in reversed(self._rules):
if argv[: len(prefix)] == prefix:
if rc != 0 and check:
raise CommandError(argv, rc, stdout)
return stdout
raise AssertionError(f"unexpected command: {argv}")
def require_gh(which: Callable[[str], str | None] | None = None) -> None:
lookup = which or shutil.which
if lookup("gh"):
return
print(
"error: GitHub CLI (gh) not found on PATH. Install from https://cli.github.com/",
file=sys.stderr,
)
raise SystemExit(2)
def _is_ancestor(runner: Runner, commit: str, ref: str) -> bool:
try:
runner.run(["git", "merge-base", "--is-ancestor", commit, ref])
return True
except CommandError:
return False
def _existing_pr_url(runner: Runner, *, base: str, head: str) -> str:
raw = runner.run(
[
"gh",
"pr",
"list",
"--base",
base,
"--head",
head,
"--state",
"open",
"--json",
"url",
]
)
if not raw.strip():
return ""
items = json.loads(raw)
if not items:
return ""
return str(items[0].get("url") or "")
def _ensure_pr(
runner: Runner,
*,
base: str,
head: str,
title: str,
body: str,
) -> str:
existing = _existing_pr_url(runner, base=base, head=head)
if existing:
print(f"PR already open: {existing}")
return existing
return runner.run(
[
"gh",
"pr",
"create",
"--base",
base,
"--head",
head,
"--title",
title,
"--body",
body,
]
).strip()
def post_check(
runner: Runner,
*,
sha: str,
conclusion: str,
title: str,
summary: str,
name: str = MAIN_GATE_CHECK,
) -> None:
if not sha:
return
runner.run(
[
"gh",
"api",
"--method",
"POST",
"repos/{owner}/{repo}/check-runs",
"-f",
f"name={name}",
"-f",
f"head_sha={sha}",
"-f",
"status=completed",
"-f",
f"conclusion={conclusion}",
"-f",
f"output[title]={title}",
"-f",
f"output[summary]={summary}",
]
)
def _write_github_output(tag: str, branch: str) -> None:
path = os.environ.get("GITHUB_OUTPUT")
if not path:
return
with open(path, "a", encoding="utf-8") as fh:
fh.write(f"tag={tag}\nbranch={branch}\n")
def _load_cmake(runner: Runner, cmake_path: Path) -> str:
if isinstance(runner, FakeRunner):
return runner.cmake_text
return cmake_path.read_text(encoding="utf-8")
def _store_cmake(runner: Runner, cmake_path: Path, text: str, *, dry_run: bool) -> None:
if isinstance(runner, FakeRunner):
runner.cmake_text = text
elif not dry_run:
cmake_path.write_text(text, encoding="utf-8")
def cmd_start(
runner: Runner,
*,
tag: str,
cmake_path: Path,
repo_root: Path,
ci: bool,
dry_run: bool = False,
) -> None:
incoming = parse_tag(tag)
view = runner.run(["gh", "release", "view", tag, "--json", "tagName,isPrerelease"])
meta = json.loads(view) if view.strip() else {"tagName": tag, "isPrerelease": True}
is_pre = bool(meta.get("isPrerelease"))
dirty = runner.run(["git", "status", "--porcelain"], check=False)
if not ci and dirty.strip():
print("error: working tree is dirty; commit or stash first", file=sys.stderr)
raise SystemExit(1)
runner.run(["git", "fetch", "origin", "tag", tag, "--force"])
# The release branch and the release tag share the same short name
# (refs/heads/vX.Y.Z vs refs/tags/vX.Y.Z). Always use explicit refspecs:
# a short refspec like `git push origin vX.Y.Z` is ambiguous and git
# refuses it ("src refspec matches more than one").
runner.run(["git", "checkout", "-B", tag, f"refs/tags/{tag}"])
text = _load_cmake(runner, cmake_path)
current = read_version(text)
if is_downgrade(current, incoming):
print(
f"error: refusing to downgrade {current.display()} to {incoming.display()}",
file=sys.stderr,
)
raise SystemExit(1)
already_official = current.triple() == incoming.triple() and current.dev == ""
if not is_pre and not already_official:
print(
"error: release is already formal but the tree is not the official version; "
"will not rewrite a published tag",
file=sys.stderr,
)
raise SystemExit(1)
new_text = write_version(text, incoming.official())
_store_cmake(runner, cmake_path, new_text, dry_run=dry_run)
touched = ["CMakeLists.txt"]
touched += sync_version_files(repo_root, incoming.official(), dry_run=dry_run)
runner.run(["git", "add", *touched])
try:
runner.run(["git", "diff", "--cached", "--quiet"])
except CommandError:
runner.run(["git", "commit", "-m", f"release: {incoming.display()}"])
runner.run(["git", "tag", "-f", tag])
runner.run(["git", "push", "-u", "origin", f"refs/heads/{tag}"])
runner.run(["git", "push", "--force", "origin", f"refs/tags/{tag}"])
_write_github_output(tag, tag)
def _open_promote_pr(runner: Runner, *, tag: str, official: str) -> None:
promote = f"promote/{tag}"
if _is_ancestor(runner, official, f"origin/{MAIN_BRANCH}"):
print(f"{MAIN_BRANCH} already contains {tag}")
return
runner.run(["git", "branch", "-f", promote, official])
runner.run(["git", "push", "-u", "origin", promote, "--force-with-lease"])
body = (
f"Promote `{tag}` to `{MAIN_BRANCH}`.\n\n"
f"This PR points at the official commit (no `-dev` suffix). "
f"Merge it to update the default branch. Do not merge `{DEV_BRANCH}` into `{MAIN_BRANCH}`.\n"
)
_ensure_pr(
runner,
base=MAIN_BRANCH,
head=promote,
title=f"release: {tag}",
body=body,
)
post_check(
runner,
sha=official,
conclusion="success",
title="release promote",
summary=f"{promote} is allowed onto {MAIN_BRANCH}.",
)
def _open_rebase_pr(runner: Runner, *, tag: str, conflict: bool) -> None:
head = f"rebase/{tag}"
if conflict:
body = (
f"Automatic rebase of `{DEV_BRANCH}` onto `{tag}` failed.\n\n"
f"Resolve on this branch or locally:\n\n"
f"```\n"
f"git fetch origin\n"
f"git checkout {DEV_BRANCH}\n"
f"git rebase {tag}\n"
f"# fix conflicts, then push this rebase branch (do not force-push {DEV_BRANCH} unless you intend to):\n"
f"git push --force-with-lease origin {head}\n"
f"```\n"
)
else:
body = (
f"Automatic rebase of `{DEV_BRANCH}` onto `{tag}` succeeded.\n\n"
f"Merge this PR to update `{DEV_BRANCH}`. Do not merge `{DEV_BRANCH}` into `{MAIN_BRANCH}`.\n"
)
_ensure_pr(
runner,
base=DEV_BRANCH,
head=head,
title=f"rebase {DEV_BRANCH} onto {tag}",
body=body,
)
def cmd_finish(
runner: Runner,
*,
tag: str,
cmake_path: Path,
repo_root: Path,
dry_run: bool = False,
) -> None:
incoming = parse_tag(tag)
runner.run(["gh", "release", "edit", tag, "--prerelease=false"])
runner.run(
[
"git",
"fetch",
"origin",
MAIN_BRANCH,
DEV_BRANCH,
f"refs/heads/{tag}",
f"refs/tags/{tag}",
]
)
official = runner.run(["git", "rev-parse", f"refs/tags/{tag}"]).strip()
_open_promote_pr(runner, tag=tag, official=official)
# Reset the local release branch to what CI pushed (refs/remotes/origin)
# so the -dev write-back always lands on the branch, never on the tag.
runner.run(["git", "checkout", "-B", tag, f"refs/remotes/origin/{tag}"])
text = _load_cmake(runner, cmake_path)
new_text = write_version(text, incoming.as_dev())
_store_cmake(runner, cmake_path, new_text, dry_run=dry_run)
runner.run(["git", "add", "CMakeLists.txt"])
try:
runner.run(["git", "diff", "--cached", "--quiet"])
except CommandError:
runner.run(["git", "commit", "-m", f"release: {incoming.as_dev().display()}"])
runner.run(["git", "push", "origin", f"refs/heads/{tag}"])
runner.run(["git", "checkout", "-B", DEV_BRANCH, f"origin/{DEV_BRANCH}"])
rebase_head = f"rebase/{tag}"
try:
# Rebase dev onto the branch tip (which now carries the -dev write-back),
# not onto the tag (which stays on the official commit).
runner.run(["git", "rebase", f"refs/heads/{tag}"])
except CommandError:
runner.run(["git", "rebase", "--abort"])
runner.run(["git", "checkout", "-B", rebase_head, f"refs/heads/{tag}"])
runner.run(["git", "push", "-u", "origin", rebase_head, "--force-with-lease"])
_open_rebase_pr(runner, tag=tag, conflict=True)
return
ahead_raw = runner.run(["git", "rev-list", "--count", f"origin/{DEV_BRANCH}..HEAD"]).strip()
ahead = int(ahead_raw or "0")
if ahead == 0:
print(f"{DEV_BRANCH} already contains the rebased history")
return
runner.run(["git", "checkout", "-B", rebase_head, "HEAD"])
runner.run(["git", "push", "-u", "origin", rebase_head, "--force-with-lease"])
_open_rebase_pr(runner, tag=tag, conflict=False)
def cmd_check_main_pr(runner: Runner, *, head_ref: str | None = None) -> None:
head = head_ref or os.environ.get("GITHUB_HEAD_REF") or ""
runner.run(["git", "fetch", "origin", MAIN_BRANCH], check=False)
names = runner.run(["git", "diff", "--name-only", f"origin/{MAIN_BRANCH}...HEAD"])
files = [line.strip() for line in names.splitlines() if line.strip()]
sha = runner.run(["git", "rev-parse", "HEAD"]).strip()
allowed = main_pr_allowed(head, files)
post_check(
runner,
sha=sha,
conclusion="success" if allowed else "failure",
title="main gate",
summary=(
f"{head} is allowed onto {MAIN_BRANCH}."
if allowed
else f"{head} is not a docs-only or promote/vX.X.X PR."
),
)
if allowed:
return
print(
f"error: PRs to {MAIN_BRANCH} must be documentation-only "
f"or come from a `promote/vMAJOR.MINOR.PATCH` release branch. Got {head!r}: "
+ ", ".join(files),
file=sys.stderr,
)
raise SystemExit(1)
def cmd_cleanup_branches(runner: Runner, *, dry_run: bool = False) -> None:
"""Delete release branches once their PRs have been merged.
- vX.Y.Z: deleted when its -dev write-back reached `dev` (rebase PR merged)
- promote/vX.Y.Z: deleted when its official commit reached `main`
- rebase/vX.Y.Z: deleted when its tip reached `dev`
The tag refs/tags/vX.Y.Z is never touched, so re-runs of the release
pipeline can still recreate the branch. `main` / `dev` are never deleted.
"""
runner.run(["git", "fetch", "origin", "--prune"])
refs = runner.run(
["git", "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"]
)
deleted: list[str] = []
for line in refs.splitlines():
if not line.strip():
continue
short, sha = line.split(" ", 1)
if not short.startswith("origin/"):
continue
name = short[len("origin/"):]
if VERSION_BRANCH_RE.fullmatch(name):
target = DEV_BRANCH
elif PROMOTE_BRANCH_RE.fullmatch(name):
target = MAIN_BRANCH
elif REBASE_BRANCH_RE.fullmatch(name):
target = DEV_BRANCH
else:
continue
if not _is_ancestor(runner, sha, f"origin/{target}"):
print(f"keep {name}: not merged into {target}")
continue
if dry_run:
print(f"+ delete {name} (merged into {target})")
continue
runner.run(["git", "push", "origin", "--delete", f"refs/heads/{name}"])
deleted.append(name)
print(f"deleted {name} (merged into {target})")
if not deleted and not dry_run:
print("no release branches to clean up")
def cmd_sync_docs(runner: Runner, *, dry_run: bool = False) -> None:
"""Sync documentation-only changes from `main` onto `dev`.
- Only paths that changed on `main` since the merge-base with `dev` are
considered, so `dev`-only work is never touched.
- Non-doc changes on `main` are rejected unless they are release-version
touchpoints (RELEASE_PATHS); those reach `dev` through the release
pipeline's rebase PR, and treating them as errors would make every
sync-docs run after a release fail spuriously.
- The sync is PR-based (`sync-docs/from-main` -> `dev`), matching the
"no direct push to dev" model. Doc content is copied from `main`, so
merge / squash / rebase merges on `main` are all handled.
"""
runner.run(["git", "fetch", "origin", MAIN_BRANCH, DEV_BRANCH])
base = runner.run(
["git", "merge-base", f"origin/{MAIN_BRANCH}", f"origin/{DEV_BRANCH}"]
).strip()
changed = runner.run(["git", "diff", "--name-only", base, f"origin/{MAIN_BRANCH}"])
files = [line.strip() for line in changed.splitlines() if line.strip()]
bad = [f for f in files if not is_doc_path(f) and not is_release_path(f)]
if bad:
print(
f"error: {MAIN_BRANCH} is ahead of {DEV_BRANCH} with non-doc files: "
+ ", ".join(bad)
+ f". Land those changes on {DEV_BRANCH} instead.",
file=sys.stderr,
)
raise SystemExit(1)
doc_paths = [f for f in files if is_doc_path(f)]
if not doc_paths:
print("nothing to sync")
return
print(f"syncing {len(doc_paths)} documentation path(s) from {MAIN_BRANCH}:")
for path in doc_paths:
print(f" {path}")
if dry_run:
return
runner.run(["git", "checkout", "-B", "sync-docs/from-main", f"origin/{DEV_BRANCH}"])
runner.run(["git", "checkout", f"origin/{MAIN_BRANCH}", "--", *doc_paths])
try:
runner.run(["git", "diff", "--cached", "--quiet"])
except CommandError:
runner.run(["git", "commit", "-m", f"docs: sync documentation from {MAIN_BRANCH}"])
else:
print("nothing to sync (docs already in sync)")
return
runner.run(["git", "push", "-u", "origin", "sync-docs/from-main", "--force-with-lease"])
body = (
f"Sync documentation changes from `{MAIN_BRANCH}` onto `{DEV_BRANCH}`.\n\n"
f"Merge this PR to keep `{DEV_BRANCH}` docs in sync. "
f"Do not merge `{DEV_BRANCH}` into `{MAIN_BRANCH}`.\n"
)
_ensure_pr(
runner,
base=DEV_BRANCH,
head="sync-docs/from-main",
title=f"docs: sync from {MAIN_BRANCH}",
body=body,
)
def _latest_prerelease_tag(runner: Runner) -> str:
raw = runner.run(["gh", "release", "list", "--json", "tagName,isPrerelease", "--limit", "20"])
items = json.loads(raw) if raw.strip() else []
for item in items:
if item.get("isPrerelease"):
return str(item["tagName"])
print("error: no open pre-release found; pass --tag", file=sys.stderr)
raise SystemExit(1)
def main(
argv: list[str] | None = None,
*,
which: Callable[[str], str | None] | None = None,
runner: Runner | None = None,
) -> None:
import argparse
parser = argparse.ArgumentParser(description="EVEngine release / branch helper")
parser.add_argument("--dry-run", action="store_true")
sub = parser.add_subparsers(required=True)
sub.dest = "cmd"
p_start = sub.add_parser("start")
p_start.add_argument("--tag")
p_finish = sub.add_parser("finish")
p_finish.add_argument("--tag", required=True)
sub.add_parser("sync-docs")
sub.add_parser("check-main-pr")
sub.add_parser("check-versions")
sub.add_parser("cleanup-branches")
args = parser.parse_args(argv)
real = runner or RealRunner(dry_run=args.dry_run)
root = Path(__file__).resolve().parent.parent
cmake = root / "CMakeLists.txt"
ci = os.environ.get("CI", "").lower() in {"1", "true", "yes"}
if args.cmd == "start":
require_gh(which=which)
tag = args.tag or _latest_prerelease_tag(real)
cmd_start(real, tag=tag, cmake_path=cmake, repo_root=root, ci=ci, dry_run=args.dry_run)
elif args.cmd == "finish":
require_gh(which=which)
cmd_finish(real, tag=args.tag, cmake_path=cmake, repo_root=root, dry_run=args.dry_run)
elif args.cmd == "sync-docs":
require_gh(which=which)
cmd_sync_docs(real, dry_run=args.dry_run)
elif args.cmd == "check-main-pr":
require_gh(which=which)
cmd_check_main_pr(real)
elif args.cmd == "check-versions":
cmd_check_versions(real, root=root)
elif args.cmd == "cleanup-branches":
cmd_cleanup_branches(real, dry_run=args.dry_run)
if __name__ == "__main__":
utf8_stdio.enable_utf8_stdio()
main()