Skip to content

Continuous integration

Prerequisite: Tests — which suite covers what.

Every suite this project has runs on a pull request, but not on the same machine, and the split is forced by one fact: the suites read the original game files. A gamefiles/ copy is a ~7 GB CD rip. It is gitignored, it will never be in the repository, and so no GitHub-hosted runner can ever open it.

Measured, in a checkout with no gamefiles/ present:

SuitesTestsRuns on
without the rip15 files111 pass, 3 skip, ~15 sGitHub's machines — any pull request, forks included
needs the rip9 files121 tests fail without itthe self-hosted runner, same-repo branches only

The nine are regression, savegame, re_builtins, interp, nav, text, audio-rates, sound-channels, shp-play-order. Everything else builds its own fixtures with the write path instead of reading the game, which is exactly why it travels.

They fail rather than skip on purpose — text.ts asserts that it found language trees, because a suite that silently checks nothing would let the table rot behind a green tick.

The four workflows

WorkflowTriggerWhat
tests.ymlevery PR, push to masterportable on GitHub's machines; full (whole auto suite + playthrough) self-hosted
browser.ymlnightly 02:00 UTC, manual, or the full-run label on a PRthe browser suite — ~39 min, because it costs what the game costs
docs.ymlpush to master under docs/publishes this site to Pages
deploy.ymla v* tag, or manualbuilds and uploads dist/ to the host — releasing and deploying

The browser suite is off the per-PR path deliberately. Add the full-run label to a pull request to pull it in for that PR.

Setting the runner up

bash
tools/setup-runner.sh /srv/taoot/gamefiles

That downloads the pinned runner (checksum-verified), registers it against this repository with the label taoot-gamefiles, records the rip's path in the runner's .env as TAOOT_GAMEFILES, warms Playwright's Chromium, and installs the runner as a service. Pass --no-service to run it in the foreground instead.

Self-hosted runners are free on every plan, private repositories included, and they consume none of the 2,000 GitHub-hosted Actions minutes.

On a machine of its own

The usual case — the runner belongs on a box that is always on, not on a laptop, because the nightly browser run needs it awake at 02:00.

A clone is not one of the things that host needs. The runner makes its own checkout under _work/ on every run, so a clone sitting next to it would only go stale and confuse. Two things have to be there: the runner and the rip.

bash
# 1. the rip — anywhere OUTSIDE the runner's directory, so no checkout can
#    clean it. ~7 GB, once.
rsync -a --info=progress2 gamefiles/ runner-host:/srv/taoot/gamefiles/

# 2. the script, on its own
scp tools/setup-runner.sh runner-host:~

# 3. a registration token, minted here, valid one hour, single use
gh api --method POST repos/dhobi/taoot-web/actions/runners/registration-token --jq .token

# 4. on that host
ssh runner-host
chmod +x ~/setup-runner.sh
./setup-runner.sh /srv/taoot/gamefiles --token PASTE_TOKEN_HERE

--token exists so the runner host never needs gh installed or a login on it. If it happens to have both, drop the flag and the script mints its own.

What that host needs: node 22+, npm, curl, tar, and about 5 GB free beyond the rip for the checkout, node_modules and Chromium. Headless is fine — the browser suite runs Chromium headless, and --with-deps pulls the system libraries it wants. The script checks the Node version and warns about the disk before it registers anything.

Worth doing once the rip is over: make it unwritable to the runner's user, since every suite only ever reads it.

bash
sudo chown -R root:root /srv/taoot/gamefiles && sudo chmod -R a-w /srv/taoot/gamefiles

Then confirm from anywhere:

bash
gh api repos/dhobi/taoot-web/actions/runners \
  --jq '.runners[] | {name, status, labels: [.labels[].name]}'

status: "online" and a taoot-gamefiles label is the whole check — the workflows select that label, not a hostname, so a replacement machine needs no change here.

The Playwright warm-up is skipped when the script runs without a checkout beside it. That is harmless: browser.yml installs Chromium itself, so the first nightly run is a few minutes longer and no later one is. The warm-up deliberately refuses to run bare npx --yes playwright, which would fetch a newer browser than the one the suite pins.

The rip is linked in, never copied

vite.config.ts resolves a bare resolve("gamefiles") — repo root, no environment override — so the workflows link the real directory into the workspace:

yaml
- uses: actions/checkout@v4
- run: ln -sfn "$TAOOT_GAMEFILES" gamefiles   # AFTER the checkout

The ordering is not cosmetic. actions/checkout cleans with git clean -ffdx, and -x deletes ignored files too — a link made before the checkout is gone by the time the tests run. Remaking it costs nothing; the 7 GB never moves.

The dev server does not get port 5173

The browser suite drives a live server and does not start one, so browser.yml starts it — on 5199, with --strictPort, and reachable through APP_URL. Two reasons it must not be 5173:

  • the runner shares a machine with a person, and 5173 is where their own npm run dev lives;
  • without --strictPort, Vite hops to the next free port and the suite would quietly test their working tree instead of the checkout.

It has to be the dev server, not vite preview: the /gamefiles middleware is a configureServer hook, which preview never runs. Cleanup kills the recorded pid — never pkill -f vite, which on a shared machine takes the owner's server with it.

The rip's directory must be named gamefiles

Not a convention — a constraint, and it costs exactly one test to get wrong.

gamefilesRoot() in tools/gamefiles.ts is process.env.TAOOT_GAMEFILES ?? "gamefiles", so the project already reads that variable. The harness therefore enumerates the shipped saves at whatever real path it names, and SHIPPED_SAVE in src/save-seed.ts matches them on a literal gamefiles/ segment:

js
const SHIPPED_SAVE = /(?:^|\/)gamefiles\/(?:[^/]+\/)*save\/(.+\.ti)$/i;

Point it at /srv/taoot-gamefiles and the regex sees taoot-gamefiles/ — the gamefiles is preceded by a hyphen, not a slash — so it matches nothing and one test fails on its own:

the dev-server manifest's shipped saves are all recognised for seeding
  → manifest=109 matched=0

So use /srv/taoot/gamefiles. Both setup-runner.sh and the container's entrypoint check the basename and refuse to start otherwise, because one red assertion in savegame.ts is a poor way to learn this.

The runner in a container

Everything for it is in tools/runner/.

bash
docker build -f tools/runner/Dockerfile -t taoot-runner .   # from the repo root

Or take the published one, which is the same bytes — danielhobi/taoot-runner, public, tagged both latest and by runner version:

bash
docker pull danielhobi/taoot-runner:2.336.0

It carries no game files and no registration, so there is nothing in it that is not in this directory. Pulling costs 1.2 GB compressed against a ~2.4 GB pull from Microsoft if you build instead, so the reason to prefer it is a pinned set of bytes rather than speed. If you do pull it, taoot-runner.service passes --pull=never on purpose — change that to --pull=always only if you want a restart to pick up a newly published image, which also means a rebuild can change what runs without you asking.

The image is built on mcr.microsoft.com/playwright:v1.61.1-noble, whose tag must equal the Playwright version in package-lock.json — the browsers come from the image, and a mismatch means the suite drives a browser it was never recorded against. runner.env holds that pin, the runner version and its checksum, and is shared with setup-runner.sh so the two cannot drift.

Three things are deliberately absent from the image:

  • the game files. A 7 GB rip in an image layer would be copied into every rebuild, and one docker push would publish copyrighted material. It is a read-only bind mount, which makes that mistake impossible rather than unlikely.
  • any registration. config.sh runs at start-up, so the image holds no credential and no identity — which is also what allows one registration per job.
  • a clone of the repository. The runner checks out its own copy per job.

One job per container

The entrypoint registers with --ephemeral: the runner takes exactly one job, de-registers, and the container exits. A supervisor starts the next one. That is what stops a job leaving anything behind for its successor — no files, no stray process, no half-installed dependency.

Because each start needs a fresh registration token and those live one hour, an unattended runner cannot be handed one at setup. It needs GITHUB_PAT (a fine-grained PAT with Administration: Read and write on this repository) to mint them per start. RUNNER_TOKEN covers a single manual start with no PAT on the box, but such a container will not survive a restart.

Two supervisors, and they are not equivalent:

What restartsIsolation between successive jobs
compose.ymlrestart: always restarts the container, reusing its writable layerfrom the host, yes; from the previous job, no
taoot-runner.servicedocker run --rm, a brand new container each timecomplete — a pristine filesystem per job

Use the systemd unit if you want the stronger property; compose is the quick way to try it.

Under Portainer

portainer-stack.yml is the same service adjusted for a web-editor stack. Stacks → Add stack → Web editor, paste it, and add one environment variable underneath:

GITHUB_PAT = github_pat_...

Keeping the PAT in Portainer's variables box rather than in the stack text is why the file reads it as ${GITHUB_PAT} — the value stays out of the stack definition and out of git.

Three things differ from compose.yml, and each is a thing Portainer cannot do:

  • no build: — there is no checkout to build from, so it pulls danielhobi/taoot-runner:2.336.0;
  • no env_file: — that wants a file beside the compose file, which a web-editor stack has not got;
  • RUNNER_EPHEMERAL=0 — the container stays up and takes job after job. This is the important one. In ephemeral mode the runner exits after every job, and Portainer draws that as a container restarting every few minutes, which at a glance is indistinguishable from a crash loop. Staying up trades the pristine-filesystem-per-job property for a supervisor display that means something.

What that trade actually costs is small: actions/checkout still runs git clean -ffdx at the start of every job, so what carries between jobs is the npm cache and anything a job wrote outside the workspace. Set RUNNER_EPHEMERAL: "1" if you would rather have the isolation and read the restarts as normal.

The stack assumes standalone Docker. Under Swarm, mem_limit and cpus have to be rewritten as deploy.resources.limits.

And the temptation Portainer puts one click away: do not mount /var/run/docker.sock. It hands any job root on the host.

The PAT goes in a root-owned 0600 file — /etc/taoot-runner.env for systemd, tools/runner/runner.secret for compose. The latter is gitignored by pattern (/tools/runner/*.secret), because a token that can register runners must never be committable.

What the container gives up

cap_drop: ALL, no-new-privileges, a non-root runner user, the rip mounted :ro, memory and CPU ceilings so a runaway job cannot take the host down, and shm_size: 1g because Playwright needs more than Docker's default 64 MB.

No docker socket, ever. Mounting /var/run/docker.sock hands any job root on the host and makes every other line here decorative.

Who can make this runner execute code

Worth being exact, because the honest answer is not about the runner at all: the runner runs whatever this repository's workflows tell it to, so the question is who can write to the repository.

WhoCan they run code on it?
Youyes — that is the point
Anyone with push accessyes. The collaborator list is the access list
A fork's pull requestno. Both rip-reading jobs require head.repo.full_name == github.repository
A stranger, while the repo is privateno — they cannot see it, let alone open a PR
Someone who steals the PATthey can register their machine as a runner for this repo, and read it. They cannot execute on yours. Still: treat it as a repo-admin credential

Two things that are not protections, and are easy to mistake for them:

  • Labels are not a boundary. Any workflow in the repository can target self-hosted. What keeps a fork out is the if: guard in the job, not the label.
  • "Require approval for first-time contributors" — the default — is weaker than it reads: one merged typo fix makes someone permanently trusted. Use all external contributors.

Never add pull_request_target to a workflow that checks out the PR head. That one combination hands a fork write-scoped credentials on your machine, and it is the most common way self-hosted runners are compromised.

What the plan does and does not give you

Running the tests on a pull request is free. Requiring them to pass before a merge is not, while the repository is private:

GET /repos/dhobi/taoot-web/branches/master/protection
GET /repos/dhobi/taoot-web/rulesets
→ 403 "Upgrade to GitHub Pro or make this repository public to enable this feature."

So on Free + private the checks are advisory: a red X you can see and merge past anyway. Two ways to get the hard gate — GitHub Pro, or make the repository public, which enables branch protection and rulesets at no cost.

Going public, and the one setting this changes

Public is what makes required checks free. It also makes the self-hosted runner dangerous, because a fork's pull request is untrusted code and the machine it would run on holds the rip. GitHub's guidance is blunt: "we recommend that you only use self-hosted runners with private repositories."

Three things keep the pairing safe, and only two of them are in this repository:

  1. The rip-reading jobs refuse fork pull requests. Both workflows guard on github.event.pull_request.head.repo.full_name == github.repository. A fork gets the portable job, which needs no rip and holds no secrets.
  2. No secrets reach those jobs, and permissions: contents: read.
  3. The setting you must make by hand, the day the repository goes public: Settings → Actions → General → Approval for running fork pull request workflows from contributors → Require approval for all external contributors. The default is only first-time contributors, which is weaker than it sounds — one merged typo fix makes someone permanently trusted.

Never add pull_request_target to a workflow that checks out the PR head. That combination hands a fork write-scoped credentials, and it is the single most common way self-hosted runners get compromised.

Adding a test that reads the rip

Add it and let it fail. A new file runs in portable by default, and if it opens gamefiles/ it fails there loudly — then add its name to the exclude glob in tests.yml. The list is written as the inverse (the nine that need the rip) for exactly this reason: the failure mode is "we noticed", not "silently untested".

Back to the reference index.

Docs licensed GPL-3.0 (the decoder is ported from DFET). Game data © CyberFlix — not included.