- Python 41.6%
- Nix 37.5%
- Shell 20.9%
| npins | ||
| scripts | ||
| tests | ||
| .gitignore | ||
| daemon.py | ||
| default.nix | ||
| flake.nix | ||
| module.nix | ||
| package.nix | ||
| README.md | ||
| shell.nix | ||
build-daemon
A small webhook-driven pull request check service for repos hosted on Forgejo, Gitea, or GitHub. It replaces PR-check CI actions committed into the repos themselves: the job is an external service, the check commands are part of the service's own (deployment) configuration, and the repos only need a webhook pointed at it. A repo — and its PR authors — control nothing about their own CI; what checking means is declared per repo in the config, which also keeps the service language agnostic. Branch protection then requires the status context this service posts.
How it works
PR opened/updated ──▶ forge webhook ──▶ POST /webhook
│ verify HMAC signature
▼
per-repo worker (serialized)
1. fetch refs/pull/N/head into a cache clone
2. commit status: pending
3. run the repo's configured commands in order
4. all exit 0 → status: success
exit ≠0 → status: failure (+ output tail)
timeout → status: failure
Commands
Each configured repo carries its own check commands — a list of argv lists, run in order from the PR checkout; the first non-zero exit (or per-command timeout) fails the check:
"commands": [
["build-all-hosts"],
["cargo", "test"]
]
The NixOS module ships a build-all-hosts helper on the daemon's PATH
(building every nixosConfiguration the checkout declares — deliberately
all of them, so on multi-fork setups no fork can merge something that
breaks the other's machines); it is also the default per-repo command.
Semantics
- Statuses are posted on the PR head sha through the forge's commit
status API (context
build); require that context in branch protection to gate merges. - Queueing: one worker per repo. Runs never overlap (they share the
cache clone and the build machine), and repeated pushes to the same PR
while a run is in flight collapse into exactly one follow-up run
against the latest head. A job posts a
queuedpending status the moment it is received, so waiting work is visible in the UI while an earlier run occupies the worker. Idle workers sleep in a condition-variable wait — zero CPU, no polling. - Private dependencies: configured
urlRewritesare rendered into a git config handed to the check commands viaGIT_CONFIG_GLOBAL, so evaluation can rewrite e.g. ssh-pinned private repos to authenticated https without credentials living in the checkout or its environment. - Restart resilience: on startup each worker re-queues every open PR (listed via the forge API) — a restart kills any check that was in flight and forges do not replay webhook deliveries, so without this those PRs would sit unreported forever.
- Live output: every line the check commands print is mirrored
(secrets scrubbed) into the daemon's own log, so
journalctl -u build-daemon -fis a live progress view of the running check; the status description carries the output tail on failure. - Retries: transient network failures get 3 attempts with backoff; a failing check is never retried — the next push to the PR fires its own event.
- The daemon is a single-file Python program using only the standard library. Secrets are read from files at runtime and scrubbed from logs.
Config
The daemon takes one argument, a JSON config file:
{
"listen": {"host": "127.0.0.1", "port": 8424},
"stateDir": "/var/lib/build-daemon",
"checkTimeoutSeconds": 7200,
"statusContext": "build",
"urlRewrites": [
{
"insteadOf": "ssh://git@git.example.com/",
"urlTemplate": "https://oauth2:{token}@git.example.com/",
"tokenFile": "/run/secrets/build_bot_token"
}
],
"repos": [
{
"host": "git.example.com",
"forge": "forgejo",
"name": "owner/repo",
"commands": [["build-all-hosts"]],
"tokenFile": "/run/secrets/build_bot_token",
"webhookSecretFile": "/run/secrets/build_webhook_secret"
}
]
}
All top-level fields are required — defaults live in the NixOS module
that renders this file, never in the daemon. The only values the daemon
fills in itself are the per-repo cloneUrlTemplate and apiBaseUrl,
which are derived from host/forge unless overridden (same semantics
as lint-daemon).
NixOS module
module.nix (exposed as nixosModules.default) runs the daemon as a
hardened systemd service under a dedicated build-daemon user with
access to the nix daemon for builds.
services.build-daemon = {
enable = true;
urlRewrites = [
{
insteadOf = "ssh://git@git.example.com/";
urlTemplate = "https://oauth2:{token}@git.example.com/";
tokenFile = "/run/secrets/build_bot_token";
}
];
repos = [
{
host = "git.example.com";
name = "owner/repo";
# commands defaults to [["build-all-hosts"]]
tokenFile = "/run/secrets/build_bot_token";
webhookSecretFile = "/run/secrets/build_webhook_secret";
}
];
};
Trust model: the configured commands run as the service user against PR
content (nix builds themselves run in the nix sandbox) — the same trust
level as a host-mode CI runner. PR authors cannot change which
commands run (that is deployment config), but the commands do process
their content, so only gate repos whose contributors you already gate
through review and branch protection. Put the tools the commands need
into extraPackages (the default provides build-all-hosts).
Forge setup (per repo)
- Create a bot account with read access to the repo (and to any
private repos evaluation fetches through
urlRewrites). - Create an access token for it →
tokenFile(it also authorizes the status API). - Pick a random webhook secret →
webhookSecretFile. - Add a webhook on the repo: target
http://<daemon host>:<port>/webhook, POST, the secret from step 3, pull request events — on Forgejo/Gitea tick both "Pull Request" AND "Pull Request Synchronized" (pushes to a PR branch are delivered as their ownpull_request_syncevent type). - Require the
buildstatus context in branch protection.
Forgejo note: for a loopback daemon on the Forgejo host itself, set
[webhook] ALLOWED_HOST_LIST = loopback.
Development
nix-shell # python3, flake8, shellcheck, git, npins
nix-shell --run tests/e2e.sh # end-to-end suite, no forge needed
nix-build -A packages.default
The e2e suite drives a real daemon against a local bare repo and a stub
status API: passing and failing command sets, the GIT_CONFIG_GLOBAL
rewrite plumbing, hung-check timeouts, bad signatures, and ignored
actions — for both the forgejo and github webhook flavors.