Add self-hosted runtime CI orchestration
Some checks failed
ci / headless-e2e (push) Has been cancelled
runtime-self-hosted / runtime-ci (push) Has been cancelled

This commit is contained in:
server
2026-04-14 18:45:30 +02:00
parent 1f1b48f4db
commit 9a2f1b9479
5 changed files with 292 additions and 0 deletions

View File

@@ -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

View File

@@ -118,6 +118,12 @@ python3 scripts/validate_audio_scenarios.py \
--runtime-root /tmp/m2dev-client-runtime-http --runtime-root /tmp/m2dev-client-runtime-http
``` ```
Self-hosted runtime CI orchestration:
```bash
python3 scripts/self_hosted_runtime_ci.py --json
```
## Build ## Build
```bash ```bash

View File

@@ -79,6 +79,7 @@ delivery material.
- Fail the pipeline if `verify` fails or `diff` reports unexpected changes. - 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 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. - 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 ## Production posture

View File

@@ -303,3 +303,53 @@ Built-in CI:
- the CI workflow runs `scripts/headless_e2e.py` - the CI workflow runs `scripts/headless_e2e.py`
- the real client runtime gate remains a separate step because it depends on - the real client runtime gate remains a separate step because it depends on
external client assets not stored in this repository 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`

209
scripts/self_hosted_runtime_ci.py Executable file
View File

@@ -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())