I wrote "one-line fallback to pfi-fleet costs you nothing" to a peer, meaning a manual edit; it was read as scheduler behaviour, and `runs-on: [vastblue, pfi-fleet]` would have shipped into a contract. A label list requires ALL the labels, so that form matches no runner and queues forever instead of failing — the worst outcome of the three, because a queued job looks busy rather than broken. Standard Actions semantics rather than a gitea quirk; the ambiguity was in my prose. Documented where the label guidance already lives so the next reader does not have to have been in the conversation.
gitea-runner
Self-hosted Gitea Actions
runner. Polls gitea.phasefinal.com for jobs from any repo that has a
.gitea/workflows/ directory and runs them in ephemeral docker
containers on this host.
Server: ana-docker (single central runner — see "Topology" below
for when to add more)
Image: gitea/act_runner:latest
Outbound only — no host port published; the runner connects out to
gitea, gitea never connects in.
Topology
We run one central runner on ana-docker. Reasoning:
- gitea is on ana-docker, so runner→API is local
- existing fleet tooling (
elway,sync-stacks,deploy-stack,refresh-server-info) already SSHes from one origin to all hosts; the runner inherits that pattern - single point to manage SSH keys, secrets, and runner upgrades
The deploy playbook (playbooks/deploy-gitea-runner.yaml) is
parameterized by host / runner-name / labels, so spinning up
nh3-docker-runner or an ESH runner later is a one-line elway
invocation — not a copy-pasted playbook.
When to add a site-local runner:
- Cross-site SSH from ana-docker to that site has become unreliable
- A workflow needs LAN access to something only reachable from inside that site's network segment
- You want failure isolation (NH3 can deploy itself when Anaheim is down)
Until one of those bites, one runner is enough.
What a job on this runner can actually do
Measured 2026-09-02 on pfi-fleet (throwaway repo, three jobs, since deleted).
Recorded because two of these are commonly assumed the other way.
| capability | result |
|---|---|
services: containers |
yes — Postgres 16 answered on the service name as hostname after ~6 s; wait on pg_isready, not on ordering |
| host docker daemon | yes, root-equivalent — /var/run/docker.sock is in every job container, docker ps showed all 49 host containers, docker compose v2.33.0 on PATH |
uses: from the local mirrors |
yes — uses: https://gitea.phasefinal.com/actions/checkout@v4 resolves and runs, with no DEFAULT_ACTIONS_URL change |
⚠ container.valid_volumes: [] does not keep docker out of jobs. act_runner
mounts the daemon socket itself, independently of that list, so a tight-looking
valid_volumes is not containment. Any repo the runner serves — it is registered
instance-wide — can control everything on ana-docker, gitea included. Four repos
(vh/Worldtree, vh/soong-lab, vh/skaldsong, vh/wt-matrix-bridge) drive
buildx through it, so it is load-bearing and closing it would break their CI.
Isolate sensitive builds onto a dedicated runner rather than tightening this one.
⚠ Job images need a node binary. JS actions execute as node /var/run/act/...,
so python:3-slim fails on the first uses:. Use node:20-bookworm, or
docker:cli plus apk add --no-cache git nodejs when the job also builds images.
Full-URL uses: is the un-parked half of the github-independence work. The
global DEFAULT_ACTIONS_URL=self flip is still blocked on act_runner's
action-fetch auth, but a per-workflow full-URL ref needs neither the flip nor the
auth path. Mirrors live under the actions and astral-sh orgs, all public:
checkout, cache, upload-artifact, download-artifact, setup-node, setup-python,
setup-uv.
Polling a run from the API: use /actions/runs, not /actions/tasks — on
gitea 1.26.1 tasks returned an empty workflow_runs for a run that runs listed
and executed.
Prereqs
Before running the deploy playbook:
-
Verify Gitea Actions is enabled. In gitea 1.21+ Actions ships on by default, but check
/-/admin/actionsresolves. If not, addGITEA__actions__ENABLED=trueto the gitea stack env and bounce. -
Generate a registration token. Pick the scope:
Scope URL Use when Global (admin) https://gitea.phasefinal.com/-/admin/actions/runnersrunner serves any repo on the instance (recommended for the central PFI runner) Org/user https://gitea.phasefinal.com/<owner>/-/actions/runnersrunner serves all repos under one owner Repo https://gitea.phasefinal.com/<owner>/<repo>/settings/actions/runnersrunner serves one repo Register-as-admin is right for our use case: one runner, fleet-wide.
-
Create an SSH deploy key for the runner that lets it execute the elway playbooks against fleet hosts. The key lives only on ana-docker (passed in as a workflow secret per repo, or mounted into the runner via volume — see "Wiring deploys" below).
-
(Optional) Create a Gitea PAT with
read:repositoryscope onvh/esh-pfi-infrastructure. Workflows need to check out the management repo to get at the playbooks; the auto-injectedGITHUB_TOKENonly works for the triggering repo.
Deploy
# Edit .env first if not using defaults — at minimum paste the registration token
$EDITOR stacks/gitea-runner/.env.example # template
# First-time deploy
scripts/elway ana-docker --playbook playbooks/deploy-gitea-runner.yaml
# Site-local runner later (NH3 or ESH)
scripts/elway nh3-docker --playbook playbooks/deploy-gitea-runner.yaml \
--var runner_name=nh3-docker-runner --var runner_labels=pfi-fleet,nh3-docker
The playbook seeds .env from .env.example only if absent; for the
first run, copy .env.example to /opt/docker/compose/gitea-runner/.env
on the host and paste the registration token in before running, OR
let the playbook seed it and edit on the host before the
docker compose up -d step (it's idempotent — second run will pick up
the edited token).
After successful registration, the token is consumed (it's one-time
use). You can clear GITEA_RUNNER_REGISTRATION_TOKEN from .env;
the runner reads its permanent credentials from ${DATA_DIR}/.runner
on subsequent starts.
Wiring deploys
A workflow that runs on the central runner needs three things:
-
runs-on:matching a runner label —pfi-fleet(cross-fleet) orana-docker(pin to that host).⚠ A label LIST is AND, not fallback.
runs-on: [vastblue, pfi-fleet]requires a runner carrying both labels — it does not try the first and fall back to the second. A list naming a label no runner has matches nothing and the job queues forever rather than failing, which is the worst of the three outcomes because the board looks busy rather than broken. Use one label, and when a workflow should move to a new runner, edit the label deliberately at that moment. (Caught by eitri-smithy-dev on 2026-09-02 against vastblue's U1 contract, after infra-ops wrote "one-line fallback topfi-fleet" meaning a manual edit and was read as scheduler behaviour. Standard Actions semantics, not a gitea quirk — the ambiguity was in the prose, not the product.) -
An SSH key to reach the deploy target. Stored as a repo or org-level Actions secret named e.g.
DEPLOY_SSH_KEY. The corresponding public key must be in~lkraven/.ssh/authorized_keyson every host the workflow targets. -
A token to clone
vh/esh-pfi-infrastructureif the workflow wants to invoke an elway playbook from this repo. Stored asMGMT_REPO_TOKEN(Gitea PAT,read:repositoryscope).
See stacks/task-board/gitea-workflow-deploy.yaml.example for a
complete deploy workflow that consumes all three.
Path layout (on ana-docker)
| Host path | Container path | Purpose | Restic? |
|---|---|---|---|
/opt/docker/compose/gitea-runner/ |
— | compose.yaml + .env | included (via /opt/docker) |
/opt/docker/conf/gitea-runner/data/ |
/data |
.runner creds, cache, job workspaces |
excluded (regenerable; nothing irreplaceable) |
Operations
# Tail runner logs
ssh ana-docker docker logs -f gitea-runner
# List currently registered runners (admin)
# https://gitea.phasefinal.com/-/admin/actions/runners
# Re-register (lost the .runner file? regenerate token, then:)
ssh ana-docker docker compose -f /opt/docker/compose/gitea-runner/compose.yaml down
ssh ana-docker rm /opt/docker/conf/gitea-runner/data/.runner
# paste new GITEA_RUNNER_REGISTRATION_TOKEN into .env
scripts/elway ana-docker --playbook playbooks/deploy-gitea-runner.yaml
# Pin to a specific act_runner version
# Edit RUNNER_IMAGE in /opt/docker/compose/gitea-runner/.env, then:
scripts/elway ana-docker --playbook playbooks/deploy-gitea-runner.yaml