# Self-hosted runtime (ROM-gated) # # Runs the build + the 21-level headless sweep (tools_pc/verify.sh) on the # self-hosted runner, the one machine that has a legal ROM. # This is the job class ci.yml explicitly does NOT do: CI is build-only, no # ROM, so runtime checks live here. Setup + rollout plan (gitignored, local): # docs/dev/notes/SELFHOSTED-RUNNER-PLAN.md # # Persistence model — everything lives under the runner's own paths, managed # by the pipeline; no hand-maintained directories: # - ccache -> actions/cache (stored by the runner under _work/_temp) # - ROM -> copied every run from a seed dir on the runner: # {runner_home}/_rom-seed/, or the repo variable # GE_ROM_SEED_DIR. Bootstrap: put ge007.ntsc-final.z64, # pcmodels-ntsc-final/ and pccg-ntsc-final/ there once. # Integrity is re-verified every run against the committed # ge007.u.sha1 (repo-tracked US ROM hash). # The ROM is deliberately NOT stored in actions/cache (it # used to be, key ge-rom-data-ntsc-v1). Cache entries live on # GitHub's side, and pull_request workflows, including ci.yml # runs from fork PRs, can restore caches created on the base # branch, so a PR could read the ROM out of the cache. (The # old save never actually persisted on this box, so no entry # was ever created; checked 2026-09-30.) # - MSYS2 -> machine-level install at C:\msys64 (verify.sh hardcodes the # prefix), installed and its packages maintained BY HAND on # the box. msys2/setup-msys2 still runs every job (it's what # registers the `msys2 {0}` shell wrapper), but with # `release: false` and update/install both omitted — verified # against the action's source to do no pacman -Syu / pacman -S # / network round-trip, just a fast reuse of the existing # C:\msys64. Package presence is checked (not installed) by # tools_pc/check-selfhosted-deps.sh, which fails fast with # exact install instructions if something's missing. # # Trigger gating (non-negotiable): # - workflow_dispatch ONLY for now. Flip on `push: branches: [main]` once # the first manual runs are reliably green. # - NEVER add `pull_request`: unreviewed fork code must not be routable to # a runner that can see the ROM. # # Leak rules (docs/dev/notes/AGENT-VM-FORGEJO-PLAN.md checklist): # - The ROM + sidecars live only in the runner's local seed dir (never in # actions/cache); nothing in this workflow uploads from data/. # - Artifacts here are console logs + a JSON verdict only. Rendered frames # (ppm/) are ROM-derived content and are NOT uploaded. name: Self-hosted runtime on: workflow_dispatch: inputs: levels: description: "Sweep subset, SWEEP_LEVELS format (blank = all 21)" required: false default: "" type: string permissions: contents: read # One ROM job at a time per ref; never cancel an in-flight sweep out from # under itself (the sweep is serialized by its own lockdir + watchdog). Group # + cancel-in-progress:false is what actually serializes runs — GitHub # Actions' workflow-level `concurrency` has no `max-parallel` key (that only # exists under a job's `strategy:` for matrix jobs); a stray one here is a # hard parse error that blocks every dispatch. concurrency: group: selfhosted-${{ github.ref }} cancel-in-progress: false env: GE_ROM_SEED_DIR: ${{ vars.GE_ROM_SEED_DIR }} jobs: build-and-sweep: runs-on: [ self-hosted, windows ] defaults: run: shell: msys2 {0} env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 500M steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # `release: false` reuses the box's own C:\msys64 as-is instead of # downloading/installing MSYS2 fresh (verified against the action's # source: with this set, and update/install both omitted, it does # exactly one `uname -a` + a pacman.conf sed tweak — no pacman -Syu, # no pacman -S, no network round-trip). This step still has to run # every time — it's what registers the `msys2 {0}` shell wrapper # above — but does no install/update work. - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2.33.0 with: msystem: MINGW64 release: false # Packages themselves are hand-maintained on the box (never installed # by this workflow) — fails fast with exact install instructions if # one's missing, instead of a confusing failure deeper in the build. - name: Check self-hosted dependencies run: tools_pc/check-selfhosted-deps.sh # --- ROM + sidecars: runner-local seed dir (never actions/cache) ------ # {runner_home} = two levels up from RUNNER_WORKSPACE ({home}/_work/{repo}). # The seed dir is the only copy the workflow uses; it is never deleted. - name: Stage ROM + sidecars (runner seed dir) run: | seed="${GE_ROM_SEED_DIR:-$(dirname "$(dirname "$RUNNER_WORKSPACE")")/_rom-seed}" for f in ge007.ntsc-final.z64 pcmodels-ntsc-final pccg-ntsc-final; do [ -e "$seed/$f" ] || { echo "missing $seed/$f" >&2 echo "One-time bootstrap: copy ge007.ntsc-final.z64, pcmodels-ntsc-final/" >&2 echo "and pccg-ntsc-final/ into $seed (or set the repo variable GE_ROM_SEED_DIR)." >&2 exit 2 } done mkdir -p data # -r: pcmodels-*/pccg-* are directories, not single files (found by # testing — a plain cp failed with "omitting directory"). cp -r "$seed/ge007.ntsc-final.z64" "$seed/pcmodels-ntsc-final" \ "$seed/pccg-ntsc-final" data/ echo "staged ROM store from $seed" - name: Verify ROM integrity (committed US sha1) run: | cd data want="$(cut -d' ' -f1 ../ge007.u.sha1)" got="$(sha1sum ge007.ntsc-final.z64 | cut -d' ' -f1)" [ "$want" = "$got" ] || { echo "ROM hash mismatch: want $want got $got" >&2; exit 2; } echo "ROM sha1 ok: $got" # --- build ------------------------------------------------------------- # Same opt-in ccache CMakeLists.txt wires up (speed-ups plan Step 5); # persisted via actions/cache like ci.yml's windows-build. - name: Restore ccache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.CCACHE_DIR }} key: ccache-selfhosted-${{ hashFiles('CMakeLists.txt') }}-${{ github.sha }} restore-keys: | ccache-selfhosted-${{ hashFiles('CMakeLists.txt') }}- ccache-selfhosted- - name: Regenerate ROM-asset symbol file (US) run: python scripts/gen_romassets.py u - name: Build (ntsc-final) run: ./build-pc.sh ntsc-final # verify.sh: build-if-stale -> headless GE_PCDUMP per level -> crash # symbolication -> pixcount -> framediff vs tools_pc/golden/. Serialized # + watchdog + one retry by design (D117: not frame-deterministic). - name: Level sweep (all 21, or dispatch subset) env: INPUT_LEVELS: ${{ inputs.levels }} run: | if [ -n "$INPUT_LEVELS" ]; then if ! printf '%s' "$INPUT_LEVELS" | grep -Eq '^[A-Za-z0-9]+:[0-9]+( [A-Za-z0-9]+:[0-9]+)*$'; then echo "levels input must be space-separated Name:NN entries, e.g. 'Dam:33 Facility:34'" >&2 exit 1 fi export SWEEP_LEVELS="$INPUT_LEVELS" fi tools_pc/verify.sh sweep --json > sweep.log 2>&1 rc=$? cat sweep.log exit $rc # Pull the JSON verdict array out of the mixed stdout for easy review. - name: Extract JSON verdict if: always() run: | python -c " import json, sys s = open('sweep.log', encoding='utf-8', errors='replace').read() i = s.rfind('[{') out = '[]' if i >= 0: d, _ = json.JSONDecoder().raw_decode(s[i:]) out = json.dumps(d, indent=1) open('sweep-verdict.json', 'w').write(out + '\n') print(out) " # Console logs + verdict only — no frames, no ROM bytes (leak rules). - name: Upload sweep report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: selfhosted-sweep-${{ github.run_number }} path: | sweep.log sweep-verdict.json ge007.crash.log if-no-files-found: ignore retention-days: 14