No description
  • Python 41.6%
  • Nix 37.5%
  • Shell 20.9%
Find a file
2026-07-10 16:31:48 -05:00
npins created project 2026-07-10 10:27:17 -05:00
scripts created project 2026-07-10 10:27:17 -05:00
tests feat: made job more stable 2026-07-10 16:31:48 -05:00
.gitignore created project 2026-07-10 10:27:17 -05:00
daemon.py feat: made job more stable 2026-07-10 16:31:48 -05:00
default.nix created project 2026-07-10 10:27:17 -05:00
flake.nix created project 2026-07-10 10:27:17 -05:00
module.nix fix: commands are set in config not in repo 2026-07-10 11:37:24 -05:00
package.nix created project 2026-07-10 10:27:17 -05:00
README.md feat: made job more stable 2026-07-10 16:31:48 -05:00
shell.nix created project 2026-07-10 10:27:17 -05:00

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 queued pending 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 urlRewrites are rendered into a git config handed to the check commands via GIT_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 -f is 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)

  1. Create a bot account with read access to the repo (and to any private repos evaluation fetches through urlRewrites).
  2. Create an access token for it → tokenFile (it also authorizes the status API).
  3. Pick a random webhook secret → webhookSecretFile.
  4. 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 own pull_request_sync event type).
  5. Require the build status 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.