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