Configure the launchd Forgejo Runner on indri
Run the Forgejo Actions runner as a native macOS LaunchAgent on
indri — both runners’ units, both config.yamls, the cache
maintenance agents and the build runner’s colima profile are owned by
the indri flake’s generation (eblume/blumeops#1528 for the main
runner, eblume/blumeops#1627 for the build runner’s config and colima
profile). Jobs run directly on the host with indri’s mise toolchain
(host-mode); Docker
Desktop stays only as the dagger engine host (the runner keeps dagger for the hephaestus/cv CI it hosts). This replaced the
minikube-hosted runner as phase 0 of retire-minikube, so source
builds no longer compete with the LGTM stack inside the minikube VM;
phase 6 then dropped the per-job container entirely.
Architecture
- Daemon: the nixpkgs
forgejo-runnerbinary (13.1.0, the indri flake’s nixpkgs pin) runs as the nix-managed LaunchAgentmcquack.eblume.forgejo-runner(same pattern as forgejo). - Jobs: run directly on the host as
erichblumewith indri’s mise toolchain (labels are registered:host) — no per-job container and norunner-job-image(phase 6), and no container engine at all: dagger work runs on indri-build’s colima VM (eblume/blumeops#1357) and Docker Desktop is retired (eblume/blumeops#1382). - Labels: advertises
indriplusindri-priv, the dedicated warrant-only label used by provision-indri, on the same physical runner. Originally also advertisedk8sfor compatibility with existing workflows; once blumeops workflows migrated toruns-on: indrithek8slabel was dropped from the runner’s label list (see forgejo-runner). Other forge repos still onruns-on: k8swill need to migrate before the label can be dropped there too. - Registry mirror: the indri-build colima profile gets
docker.registry-mirrors: ["http://host.lima.internal:5050"](zot pull-through cache — the replacement for the old Docker Desktopdaemon.jsonmirror, itself a replacement for the DinD config’shost.minikube.internal:5050). Mirrors only affect docker.io pulls (base images during builds).
One-time setup
1. The binary (nix-managed — no manual step)
The runner binary is the nixpkgs forgejo-runner package (13.1.0,
pinned by the indri flake’s nixpkgs input), and the LaunchAgent
unit is the flake’s
launchd.user.agents."mcquack.eblume.forgejo-runner". Version bumps are
flake PRs applied through the usual mise run provision-indri -- --tags rebuild; activation swaps the plist in place and restarts the
agent.
2. Register the runner identity
A new, distinct identity (not the k8s runner’s) so both runners can coexist during the transition and rollback stays trivial:
# secret must be exactly 40 hex chars; omit --scope for instance-wide
ssh indri 'cd ~/code/3rd/forgejo && ./forgejo forgejo-cli actions register \
--name indri-runner \
--secret "$(openssl rand -hex 20)" \
--config ~/forgejo/custom/conf/app.ini --work-path ~/forgejo'This prints the runner UUID; the generated secret is the token. Store
both on the “Forgejo Secrets” 1Password item as runner_indri_uuid /
runner_indri_token. mise run seed-secrets indri seats them as the
identity files under the runner’s home, and the generation renders them
into the runner config (mode 0600) — see eblume/blumeops#1528.
3. Provision
mise run provision-indri -- --tags rebuildThe nix switch renders config.yaml from the identity files and
forge/runners.json (and deploys the build runner’s user and
daemons). The same apply installs the build runner’s colima profile
when it changed: postActivation colima stops the VM and its launchd
wrapper restarts it with the new profile, so jobs running at that
moment lose their engine. colima rewrites the live profile with its
own full default form on start, so the generation compares the new
render against a copy of the last render it installed, kept at
/usr/local/etc/forgejo-runner-build/colima.yaml.applied - a hand
edit to the live profile is not detected (nothing relies on it). The
copy does not exist until the first install, so the first apply after
this change installs the profile and stops the VM once; every apply
after that is a no-op on a converged box.
The main side has no ansible drift check left — its pre-apply check
is the Indri Flake Check: the CI workflow
(.forgejo/workflows/indri-flake-check.yaml) builds the generation
on any PR (or push to main) touching darwin/indri/, ntfy/ or
forge/runners.*, or mise run indri-flake-check for a human
window, replacing the role’s old --check --diff — see
provision §Pre-apply check: indri-flake-check.
indri-build runner (second, unprivileged)
The second runner (label indri-build, eblume/blumeops#1357) is a
dedicated macOS user (no sudo, home 0700) whose jobs reach containers
through a colima VM. The user, the system launchd daemons
(mcquack.eblume.colima-build + mcquack.eblume.forgejo-runner-build),
the build user’s mise config, the home dir layout, the runner config
(gated on registration) and the colima profile are all nix-managed
(eblume/blumeops#1627).
-
One-time host prerequisites:
brew install dockeron indri. The colima package ships no docker client, so job steps that need docker get the CLI from Homebrew (/opt/homebrew/binis on the runner daemon’s PATH, see the flake unit); the socket path is the colima profile’s.- The first switch that creates the
indri-builduser must run in a graphical session on indri (console or Screen Sharing):sudo -H darwin-rebuild switch --flake /etc/blumeops/darwin/indri#indri. nix-darwin refuses to create users without Full Disk Access: over ssh it aborts, and from the warrant runner it raises a TCC prompt no one can answer. Later switches create no users and work from any path.
-
Register the runner, exactly like the original (no
--scope). Generate the secret locally, because it is the runner token and you need to keep it:set -l secret (openssl rand -hex 20) set -l uuid (ssh indri "cd ~/code/3rd/forgejo && ./forgejo forgejo-cli actions register --name indri-build --secret $secret --config ~/forgejo/custom/conf/app.ini --work-path ~/forgejo")The command prints the runner UUID.
-
The UUID + token go STRAIGHT into the “Forgejo Secrets” 1Password item as
runner_indri_build_uuid/runner_indri_build_token- never into any forge issue or PR thread:op item edit w3663ffnvkewbftncqxtcpeavy --vault vg6xf6vvfmoh5hqjjhlhbeoaie \ "runner_indri_build_uuid[concealed]=$uuid" "runner_indri_build_token[concealed]=$secret" >/dev/nullJob tool resolution uses the shared zero-scope
forge-ci-github-pat(see manage-forgejo-mirrors); there is no separate build-runner PAT. -
Seat the identity files with
mise run seed-secrets indri, then apply the generation withmise run provision-indri -- --tags rebuild. Its postActivation renders the config from the identity files andforge/runners.json, installs the colima profile, and kickstarts the runner daemon on change. Until the seed, nothing is rendered (the registration gate) and the daemons idle in their wait loops.
Verification
mise run services-check— theforgejo-runner (indri)launchd check is green.- The runner appears as
indri-runner(idle) under forge admin → Actions → Runners. - Trigger a real workflow (the prometheus container build is the
stress case that motivated this) and watch with
mise run runner-logs. - Logs:
~/Library/Logs/mcquack.forgejo-runner.{out,err}.log, shipped to Loki by the alloy role.
Cutover from the k8s runner (historical — completed with retire-minikube)
- Run both runners side by side; confirm several green runs on the
launchd runner (jobs may land on either while both advertise
k8s). - Scale the k8s runner to 0
(
kubectl --context=minikube-indri -n forgejo-runner scale deploy/forgejo-runner --replicas=0). - After a few days of clean runs: delete the
forgejo-runnerArgoCD app + manifests, remove the runner from forge admin, and delete therunner_k8s_uuid/runner_k8s_token1Password fields.
Rollback at any point before step 3: scale the k8s deployment back to 1 and unload the LaunchAgent.
Related
- forgejo-runner — service reference
- retire-minikube — the umbrella migration plan
- validate-forgejo-workflows — workflow schema validation