Add self-hosted runtime CI orchestration
This commit is contained in:
26
.gitea/workflows/runtime-self-hosted.yml
Normal file
26
.gitea/workflows/runtime-self-hosted.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
209
scripts/self_hosted_runtime_ci.py
Executable file
209
scripts/self_hosted_runtime_ci.py
Executable 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())
|
||||
Reference in New Issue
Block a user