Files
esh-pfi-infrastructure/stacks/gitea-runner/README.md
T
vh 89ffab69df docs(gitea-runner): record measured job capabilities, incl. root-equivalent docker access
Answering a CI-posture question from vastblue-dev meant measuring three
things rather than recalling them. Two came back the opposite of the way
the config reads:

- `container.valid_volumes: []` does NOT keep the docker daemon out of
  jobs. act_runner mounts /var/run/docker.sock on its own, so every job
  on the shared runner is uid 0 with `docker ps` over all 49 containers
  on ana-docker — gitea, synapse, phasefinal-web, adguardhome included.
  It is also load-bearing: four repos drive buildx through it, so the
  fix is isolation onto a dedicated runner, not tightening this one.
- A full-URL `uses: https://gitea.phasefinal.com/actions/checkout@v4`
  resolves from the local mirrors today. That is github-independence per
  workflow without the DEFAULT_ACTIONS_URL flip that has been parked on
  act_runner's action-fetch auth since 2026-08-05.

Also recorded: `services:` containers work (Postgres 16 on the service
name), job images need a node binary for JS actions, and `/actions/runs`
lists runs that `/actions/tasks` reports as empty on gitea 1.26.1.

Measured on a throwaway repo under the claude-bot account, since deleted.
config.yaml change is comment-only and deliberately not deployed — it
would bounce the runner for no runtime effect.
2026-09-02 08:57:25 -07:00

7.5 KiB

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:

  1. Verify Gitea Actions is enabled. In gitea 1.21+ Actions ships on by default, but check /-/admin/actions resolves. If not, add GITEA__actions__ENABLED=true to the gitea stack env and bounce.

  2. Generate a registration token. Pick the scope:

    Scope URL Use when
    Global (admin) https://gitea.phasefinal.com/-/admin/actions/runners runner serves any repo on the instance (recommended for the central PFI runner)
    Org/user https://gitea.phasefinal.com/<owner>/-/actions/runners runner serves all repos under one owner
    Repo https://gitea.phasefinal.com/<owner>/<repo>/settings/actions/runners runner serves one repo

    Register-as-admin is right for our use case: one runner, fleet-wide.

  3. 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).

  4. (Optional) Create a Gitea PAT with read:repository scope on vh/esh-pfi-infrastructure. Workflows need to check out the management repo to get at the playbooks; the auto-injected GITHUB_TOKEN only 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:

  1. runs-on: matching a runner label — pfi-fleet (cross-fleet) or ana-docker (pin to that host).
  2. 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_keys on every host the workflow targets.
  3. A token to clone vh/esh-pfi-infrastructure if the workflow wants to invoke an elway playbook from this repo. Stored as MGMT_REPO_TOKEN (Gitea PAT, read:repository scope).

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