No description
  • Python 39.2%
  • Nix 34.2%
  • Shell 26.6%
Find a file
2026-07-10 16:32:05 -05:00
npins created project 2026-07-10 10:23:39 -05:00
tests feat: added queue information 2026-07-10 16:32:05 -05:00
.gitignore created project 2026-07-10 10:23:39 -05:00
daemon.py feat: added queue information 2026-07-10 16:32:05 -05:00
default.nix created project 2026-07-10 10:23:39 -05:00
flake.nix created project 2026-07-10 10:23:39 -05:00
module.nix feat: added terminal output 2026-07-10 13:37:29 -05:00
package.nix created project 2026-07-10 10:23:39 -05:00
README.md feat: added queue information 2026-07-10 16:32:05 -05:00
shell.nix created project 2026-07-10 10:23:39 -05:00

lint-daemon

A small webhook-driven autoformat service for repos hosted on Forgejo, Gitea, or GitHub. It replaces "lint on push" CI actions committed into the repos themselves: the job is an external service, the lint commands are part of the service's own (deployment) configuration, and the repos only need a webhook pointed at it. A repo controls nothing about its own CI — what linting means for it is declared per repo in the config, which also keeps the service language agnostic: nix, prettier, gofmt, whatever each configured repo needs.

How it works

push to any branch ──▶ forge webhook ──▶ POST /webhook
                                              │  verify HMAC signature
                                              ▼
                                per-repo worker (serialized)
                                  1. fetch the pushed branch into a cache clone
                                  2. commit status: pending
                                  3. run the repo's configured commands in order
                                  4. command fails    → status: failure
                                     nothing to fix   → status: success
                                     fixes            → ssh-signed commit,
                                                        push back to the branch

Commands

Each configured repo carries its own lint commands — a list of argv lists, run in order from the repo root on every pushed branch:

"commands": [
  ["alejandra", "-q", ".", "--exclude", "./npins"],
  ["prettier", "--write", "."]
]

Each must exit 0 (a non-zero exit reports failure naming the command) and must be deterministic — convergence depends on it. Whatever they leave modified is committed and pushed back.

Semantics

  • Every branch is linted, not just main — each pushed branch gets its own run with the repo's commands. Tag pushes and branch deletions are ignored.
  • Statuses are posted through the forge's commit status API (context lint by default), so runs are visible on commits like any CI check.
  • Queueing: one worker per repo; a per-branch pending set coalesces events, so any number of pushes to a branch during a run collapse into exactly one follow-up run against its latest head. Runs for the same repo never overlap. A job posts a queued pending status the moment it is received, so waiting work is visible in the UI while another branch occupies the worker. Idle workers sleep in a condition-variable wait — zero CPU, no polling.
  • Live output: every line the lint commands print is mirrored (secrets scrubbed) into the daemon's own log, so journalctl -u lint-daemon -f shows exactly what a failing linter said; the status description carries the output tail on failure. A command that hangs past lintTimeoutSeconds is killed and reported as failure.
  • Races: if the push back is rejected because the branch moved, the commit is rolled back and the run still reports success — the lint of that commit passed; only the fix delivery was superseded, and the newer push's own event re-lints the merged state. No retries for failures that cannot solve themselves; only transient network failures are retried (3 attempts, backoff).
  • Startup sweep: on start the daemon enumerates every branch (ls-remote) and lints each once, covering pushes that happened while the service was down.
  • The daemon is a single-file Python program using only the standard library. Secrets are read from files at runtime and never appear in argv, the environment, or the nix store; tokens are scrubbed from logs.

Config

The daemon takes one argument, a JSON config file:

{
  "listen": {"host": "127.0.0.1", "port": 8423},
  "stateDir": "/var/lib/lint-daemon",
  "botName": "lint-bot",
  "botEmail": "lint-bot@example.com",
  "signingKeyFile": "/run/secrets/lint_signing_key",
  "lintTimeoutSeconds": 600,
  "statusContext": "lint",
  "commitMessage": "style: autoformat",
  "repos": [
    {
      "host": "git.example.com",
      "forge": "forgejo",
      "name": "owner/repo",
      "commands": [["alejandra", "-q", "."]],
      "tokenFile": "/run/secrets/lint_bot_token",
      "webhookSecretFile": "/run/secrets/lint_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 ones derived from other config: the per-repo cloneUrlTemplate ({token}/{name} are substituted; defaults to authenticated https for the forge flavor) and apiBaseUrl (defaults to https://<host>/api/v1 for forgejo/gitea and https://api.github.com for github).

NixOS module

module.nix (exposed as nixosModules.default from both default.nix and flake.nix) runs the daemon as a hardened systemd service under a dedicated lint-daemon user with state in /var/lib/lint-daemon — all of which is re-derivable cache; losing it only costs a re-clone.

services.lint-daemon = {
  enable = true;
  bot = {
    email = "lint-bot@example.com";
    signingKeyFile = "/run/secrets/lint_signing_key";
  };
  extraPackages = [pkgs.alejandra]; # tools the commands need on PATH
  repos = [
    {
      host = "git.example.com";
      name = "owner/repo";
      commands = [["alejandra" "-q" "."]];
      tokenFile = "/run/secrets/lint_bot_token";
      webhookSecretFile = "/run/secrets/lint_webhook_secret";
    }
  ];
};

Trust model: the configured commands run as the service user against whatever content was pushed — the same trust level as a host-mode CI runner. Pushers cannot change which commands run (that is deployment config), but the commands do process their content, so only point the daemon at repos whose committers you already trust.

The module makes no assumptions about secret management, storage persistence, or reverse proxies — credentials are plain file paths (render them with sops-nix, agenix, ...), and exposing the endpoint beyond loopback (needed for github.com repos) is up to the consumer.

Forge setup (per repo)

  1. Create a bot account; give it write access to the repo (it pushes to every branch it fixes).
  2. Create an access token for it (repository read/write) → tokenFile.
  3. Generate a signing key: ssh-keygen -t ed25519 -N '' -f lint-key. The private key → bot.signingKeyFile; upload the public key to the bot account as an SSH signing key (the bot's email must match bot.email) so autofix commits show as Verified.
  4. Pick a random webhook secret → webhookSecretFile.
  5. Add a webhook on the repo: target http://<daemon host>:<port>/webhook, POST, the secret from step 4, push events, no branch filter.

Forgejo note: if the daemon runs on the same host as Forgejo and listens on loopback, Forgejo must be allowed to deliver there: [webhook] ALLOWED_HOST_LIST = loopback.

Development

nix-shell                     # python3, flake8, alejandra, 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: signed autofix commits on main and feature branches, clean runs, failing and multi-command configs, bad signatures, tag/deletion ignoring, the superseded-push rollback, and per-branch event coalescing — for both the forgejo and github webhook flavors.