- Shell 57.3%
- Nix 35.4%
- Just 7.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| examples | ||
| guest | ||
| nix | ||
| scripts | ||
| src | ||
| .envrc | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| README.md | ||
forgejo-local-runner
Run one Forgejo Actions job on this machine, in a disposable NixOS microVM, without putting a registry or release credential in a file on the host.
The tool is a push-mode local runner:
- It asks Forgejo for a new ephemeral, repo-scoped runner.
- It boots a NixOS microVM with that runner's UUID and token.
- It dispatches a workflow that targets the runner's label.
- The VM runs exactly one job, writes the exit status back through a 9p share, and powers itself off.
- The tool deletes the runner if Forgejo has not already done so, and removes the run directory.
The only credential on the host is the user's existing fj login (or
FORGEJO_TOKEN). The registry and release secrets stay in Forgejo and are
injected into the job by the runner for the duration of that job.
What is in here
| Path | Purpose |
|---|---|
nix/microvm.nix |
defines the x86_64-linux guest |
guest/configuration.nix |
the NixOS system, tools, volumes and runner service |
guest/run-job.sh |
the one-shot forgejo-runner one-job --wait wrapper |
nix/package.nix |
the forgejo-local-runner command, with the guest baked in |
src/forgejo-local-runner.sh |
the host-side API and VM lifecycle |
scripts/register-fj-client.sh |
one-time OAuth2 app registration for fj |
The guest adapts the toolset from
git.shine.town/infra/runners. That repository builds a
container rootfs, not a VM image, so it cannot be booted directly; a full
NixOS guest is the adaptation it would need for a microVM.
Requirements
x86_64-linuxwith KVM. The cluster is amd64, so the guest is amd64 too.- Nix with flakes.
- A Forgejo account that can create a runner on the target repository. The user needs repo write/admin; the wrapper creates a repo-level runner, not an instance-level one.
- The target repository must contain a workflow with a single job on
runs-on: local, for exampleqr-app/.forgejo/workflows/release-local.yml.
The guest is x86_64-linux and uses QEMU, so the first supported host is
x86_64-linux. The flake also evaluates on aarch64-linux and
aarch64-darwin for the devshell and wrapper, but running this guest there is
a follow-up: on Apple Silicon, microvm.nix would use vfkit with an aarch64
guest, and qr images would still need an x86_64-linux builder. Evaluability
is not runtime support.
Authentication
The tool uses fj's token store. To log in through Keycloak, Forgejo first
needs a public OAuth2 application for fj:
-
Create a personal access token with
write:useron https://git.shine.town/user/settings/applications. Create it as the user the runner should act as (normallysshine, not the admin account). -
Run:
export FORGEJO_TOKEN=<the token> just auth-clientThis creates the OAuth2 application
forgejo-local-runnerwith redirect URIhttp://127.0.0.1:26218/, and writes its client ID to~/.config/forgejo-cli/client_ids. The script is idempotent. -
Log in. Run this inside a git repository, because
fjuses the repository to choose the host:just login # or: fj auth login --host https://git.shine.townThe browser goes to Forgejo, which delegates the actual login to Keycloak.
fjstores the OAuth token and refresh token in~/.local/share/forgejo-cli/keys.json(mode 0600).
If you would rather not use the browser flow, fj auth add-token with a PAT
also works; the wrapper reads either kind of login. You can also skip fj
entirely with FORGEJO_TOKEN=....
Usage
nix run .# -- run \
--repo sshine/qr \
--workflow release-local.yml \
--ref main \
--input tag=v0.4.0
Useful options:
--host URL Forgejo base URL (default https://git.shine.town)
--label LABEL runner label (default local)
--timeout DURATION wall clock limit (default 3h)
--keep keep the run directory and logs
--dry-run print what would happen; create nothing
The repository must be OWNER/NAME. There is deliberately no default
repository: the tool should not guess which project it is releasing.
First test
Copy examples/smoke-local.yml into the repository (qr-app already has
.forgejo/workflows/smoke-local.yml), commit and push it, then run:
nix run .# -- run \
--repo sshine/qr \
--workflow smoke-local.yml
This proves the runner registration, the microVM boot, the Nix store and the
job execution path without pushing an image or creating a release. Once it is
green, dispatch release-local.yml with the real tag.
Logs land in /var/tmp/forgejo-local-runner/run/ while the run is active.
On failure the directory is kept and the wrapper prints the tail of
job.log. --keep keeps it on success as well.
The guest
- QEMU user networking, one
eth0. - Read-only erofs base store plus persistent block volumes for the writable store overlay, the Nix database, and the runner work directory.
local:hostlabel, host-mode steps. The VM is the isolation boundary; the job has the guest's Nix store and tools, not the host's filesystem.- Nix sandbox is on. The guest is a real VM, so the sandbox does not need the privileged-container workaround the in-cluster runner uses.
as-runneris available for steps that refuse to run as root, matching the container runner's escape hatch.
The first run is cold: the guest boots, fetches the qr flake, and populates
the writable store. Later runs reuse /var/tmp/forgejo-local-runner/*.img.
To throw the store and work volume away:
just reset
Why a microVM and not a host runner
A host runner with no container runs arbitrary repository code with the
user's files and SSH keys. The microVM keeps the job in a separate kernel and
filesystem, while still letting it build with the local CPU and a persistent
Nix store. The trade is a little boot time and a fixed guest size (4 vCPU,
8 GiB by default; edit guest/configuration.nix and rebuild).
CI-triggered runs
This version is push-only. A CI pipeline cannot wake a sleeping laptop; it can
only queue a job with runs-on: local. Supporting that would add a watch
subcommand that polls Forgejo for a waiting job, creates the same ephemeral
runner, and runs the same one-job lifecycle. The core is deliberately factored
so that is an additional front-end, not a redesign.
Troubleshooting
no fj login for ...: runjust loginin a repository, or setFORGEJO_TOKEN.OAuth unsupported: runjust auth-clientonce;fjhas no built-in client ID forgit.shine.town.virtiofsd/9perrors: the control share is 9p on purpose. Build output should never go through it; it is onlyrunner.yaml,job.logandstatus.- QEMU is very slow: check
/dev/kvmexists and the user can open it. Without KVM the guest falls back to TCG. - A runner stays offline: it is ephemeral and repo-scoped. Check the repository settings page; a failed run should have deleted its own row, but an aborted host process can leave one behind.
keelwebhook failures in a release workflow are expected: Keel is a ClusterIP. Its poll loop still rolls:latestwithin five minutes, or the job can usekubectlif the workflow has a kubeconfig.