From 9a2f1b9479f2492f1005eaae43c7a3c02c0e4775 Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 18:45:30 +0200 Subject: [PATCH] Add self-hosted runtime CI orchestration --- .gitea/workflows/runtime-self-hosted.yml | 26 +++ README.md | 6 + docs/release-workflow.md | 1 + docs/testing.md | 50 ++++++ scripts/self_hosted_runtime_ci.py | 209 +++++++++++++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 .gitea/workflows/runtime-self-hosted.yml create mode 100755 scripts/self_hosted_runtime_ci.py diff --git a/.gitea/workflows/runtime-self-hosted.yml b/.gitea/workflows/runtime-self-hosted.yml new file mode 100644 index 0000000..42113be --- /dev/null +++ b/.gitea/workflows/runtime-self-hosted.yml @@ -0,0 +1,26 @@ +name: runtime-self-hosted + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + runtime-ci: + runs-on: + - self-hosted + - linux + - metin-runtime + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure + run: cmake -S . -B build -G Ninja + + - name: Build + run: cmake --build build -j2 + + - name: Self-hosted runtime CI + run: python3 scripts/self_hosted_runtime_ci.py --json diff --git a/README.md b/README.md index b103c0d..183e207 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,12 @@ python3 scripts/validate_audio_scenarios.py \ --runtime-root /tmp/m2dev-client-runtime-http ``` +Self-hosted runtime CI orchestration: + +```bash +python3 scripts/self_hosted_runtime_ci.py --json +``` + ## Build ```bash diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 72f1ff2..862f17f 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -79,6 +79,7 @@ delivery material. - Fail the pipeline if `verify` fails or `diff` reports unexpected changes. - Run the built-in Linux workflow in `.gitea/workflows/ci.yml` on every push. - Run the real client runtime gate separately on a machine that has the client runtime assets available. +- For that runner, use `.gitea/workflows/runtime-self-hosted.yml` plus `scripts/self_hosted_runtime_ci.py`. ## Production posture diff --git a/docs/testing.md b/docs/testing.md index 79baeb2..692a480 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -303,3 +303,53 @@ Built-in CI: - the CI workflow runs `scripts/headless_e2e.py` - the real client runtime gate remains a separate step because it depends on external client assets not stored in this repository + +Self-hosted runtime CI: + +- `.gitea/workflows/runtime-self-hosted.yml` +- `scripts/self_hosted_runtime_ci.py` + +This path is meant for a dedicated runner that already has: + +- a real client runtime checkout +- a Linux-built `m2dev-client-src` +- Wine runtime dependencies +- access to the runtime master key file + +Expected runner environment: + +- `M2_RUNTIME_ROOT` +- `M2_CLIENT_REPO` +- `M2_MASTER_KEY_PATH` +- `M2_KEY_ID` +- optional `M2_RUNTIME_SMOKE_PACKS` +- optional `M2_TIMEOUT` + +The self-hosted orchestrator runs, in order: + +1. `scripts/headless_e2e.py` +2. `scripts/validate_runtime_gate.py` +3. `scripts/runtime_smoke_wine.py` + +Default Wine smoke coverage uses the known startup-safe `.m2p` pack set: + +- `root` +- `patch1` +- `patch2` +- `season3_eu` +- `metin2_patch_snow` +- `metin2_patch_snow_dungeon` +- `metin2_patch_etc_costume1` +- `metin2_patch_pet1` +- `metin2_patch_pet2` +- `metin2_patch_ramadan_costume` +- `metin2_patch_flame` +- `metin2_patch_flame_dungeon` +- `locale` +- `uiscript` +- `uiloading` +- `ETC` +- `item` +- `effect` +- `icon` +- `property` diff --git a/scripts/self_hosted_runtime_ci.py b/scripts/self_hosted_runtime_ci.py new file mode 100755 index 0000000..fb5a1ae --- /dev/null +++ b/scripts/self_hosted_runtime_ci.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_SMOKE_PACKS = [ + "root", + "patch1", + "patch2", + "season3_eu", + "metin2_patch_snow", + "metin2_patch_snow_dungeon", + "metin2_patch_etc_costume1", + "metin2_patch_pet1", + "metin2_patch_pet2", + "metin2_patch_ramadan_costume", + "metin2_patch_flame", + "metin2_patch_flame_dungeon", + "locale", + "uiscript", + "uiloading", + "ETC", + "item", + "effect", + "icon", + "property", +] + + +def env_path(name: str) -> Path | None: + value = os.environ.get(name, "").strip() + return Path(value).expanduser().resolve() if value else None + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the Linux headless E2E, runtime gate, and optional Wine .m2p smoke on a self-hosted runner." + ) + parser.add_argument( + "--runtime-root", + type=Path, + default=env_path("M2_RUNTIME_ROOT"), + help="Client runtime root containing assets/, config/, and pack/. Defaults to $M2_RUNTIME_ROOT.", + ) + parser.add_argument( + "--client-repo", + type=Path, + default=env_path("M2_CLIENT_REPO"), + help="Path to m2dev-client-src. Defaults to $M2_CLIENT_REPO.", + ) + parser.add_argument( + "--master-key", + type=Path, + default=env_path("M2_MASTER_KEY_PATH"), + help="Path to runtime master key file. Defaults to $M2_MASTER_KEY_PATH.", + ) + parser.add_argument( + "--key-id", + type=str, + default=os.environ.get("M2_KEY_ID", "1"), + help="Runtime key id for Wine smoke. Defaults to $M2_KEY_ID or 1.", + ) + parser.add_argument( + "--timeout", + type=int, + default=int(os.environ.get("M2_TIMEOUT", "20")), + help="Wine smoke timeout in seconds. Defaults to $M2_TIMEOUT or 20.", + ) + parser.add_argument( + "--skip-headless-e2e", + action="store_true", + help="Skip scripts/headless_e2e.py.", + ) + parser.add_argument( + "--skip-runtime-gate", + action="store_true", + help="Skip scripts/validate_runtime_gate.py.", + ) + parser.add_argument( + "--skip-runtime-smoke", + action="store_true", + help="Skip scripts/runtime_smoke_wine.py.", + ) + parser.add_argument( + "--smoke-pack", + dest="smoke_packs", + action="append", + default=[], + help="Pack basename for Wine smoke. Repeatable. Defaults to a known startup-safe set or $M2_RUNTIME_SMOKE_PACKS.", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON output.", + ) + return parser.parse_args() + + +def resolve_smoke_packs(cli_values: list[str]) -> list[str]: + if cli_values: + return cli_values + env_value = os.environ.get("M2_RUNTIME_SMOKE_PACKS", "").strip() + if env_value: + return [part.strip() for part in env_value.split(",") if part.strip()] + return list(DEFAULT_SMOKE_PACKS) + + +def run_json(cmd: list[str]) -> dict: + completed = subprocess.run( + cmd, + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" + raise RuntimeError(f"command failed: {' '.join(cmd)}\n{detail}") + stdout = completed.stdout.strip() + return json.loads(stdout) if stdout else {"ok": True} + + +def main() -> int: + args = parse_args() + smoke_packs = resolve_smoke_packs(args.smoke_packs) + summary: dict[str, object] = { + "ok": True, + "runtime_root": str(args.runtime_root) if args.runtime_root else None, + "client_repo": str(args.client_repo) if args.client_repo else None, + "smoke_packs": smoke_packs, + "steps": {}, + } + + try: + if not args.skip_headless_e2e: + summary["steps"]["headless_e2e"] = run_json([sys.executable, "scripts/headless_e2e.py"]) + else: + summary["steps"]["headless_e2e"] = {"ok": True, "skipped": True} + + if not args.skip_runtime_gate: + if not args.runtime_root: + raise ValueError("runtime root is required for runtime gate") + summary["steps"]["runtime_gate"] = run_json( + [ + sys.executable, + "scripts/validate_runtime_gate.py", + "--runtime-root", + str(args.runtime_root), + "--json", + ] + ) + else: + summary["steps"]["runtime_gate"] = {"ok": True, "skipped": True} + + if not args.skip_runtime_smoke: + if not args.runtime_root: + raise ValueError("runtime root is required for runtime smoke") + if not args.client_repo: + raise ValueError("client repo is required for runtime smoke") + if not args.master_key: + raise ValueError("master key is required for runtime smoke") + + cmd = [ + sys.executable, + "scripts/runtime_smoke_wine.py", + "--runtime-root", + str(args.runtime_root), + "--client-repo", + str(args.client_repo), + "--master-key", + str(args.master_key), + "--key-id", + args.key_id, + "--timeout", + str(args.timeout), + "--json", + ] + for pack in smoke_packs: + cmd.extend(["--pack", pack]) + summary["steps"]["runtime_smoke"] = run_json(cmd) + else: + summary["steps"]["runtime_smoke"] = {"ok": True, "skipped": True} + + summary["ok"] = all(bool(step.get("ok", False)) for step in summary["steps"].values()) + except Exception as exc: + summary["ok"] = False + summary["error"] = str(exc) + + if args.json: + print(json.dumps(summary, indent=2)) + else: + print(f"ok={summary['ok']}") + for name, result in summary["steps"].items(): + print(f"{name}: ok={result.get('ok')} skipped={result.get('skipped', False)}") + if "error" in summary: + print(f"error: {summary['error']}") + + return 0 if summary["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main())