No description
  • Elixir 86.9%
  • Nix 5%
  • Python 3.8%
  • CSS 3.6%
  • JavaScript 0.7%
Find a file
ant f607c11d3a
All checks were successful
ci-tron/ci Success
Public read access: anonymous reads of operator-listed public repos
Operators list repos in `publicAccess.repos`; a listed repo that Forgejo
reports as not private is readable without signing in, per the surfaces
`ui`, `downloads` and `api`. Anonymous visitors are read-only and never
see evaluation logs or errors.

Because a public repo's logs are public, its jobs get no
CI_TRON_FORGEJO_TOKEN and its CI (manifest v3 `repos`) may read only
repos anyone can read on Forgejo, asked of Forgejo without credentials
(`PublicRepos.world_readable?/1`). That rule is enforced wherever B is
mounted, not only at pin time: `RepoGrants.check_public_reads/2` runs
when a queued job starts and on a restart whose tree is still staged.

Open LiveViews re-check access before every update
(`CiTronWeb.LiveAccess`), so a repo made private stops streaming.

Also: `ci-tron` read commands work without a token, the evaluations
list emits `"error": null` for non-members, and SKILL.md documents the
instance-wide CI_TRON_FORGEJO_TOKEN scope.
2026-10-15 10:54:06 +02:00
assets Theme toggle: auto mode that follows the OS 2026-10-13 22:34:34 +02:00
config Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00
docs/superpowers Plan: project page Pipelines section 2026-10-04 17:08:40 +02:00
issues Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00
journal Add disposable NixOS VM job runner 2026-10-05 16:53:46 +02:00
lib Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00
nix Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00
priv Container jobs: UTF-8 locale, and /tmp and HOME on the job's disk instead of tmpfs 2026-10-09 17:16:01 +02:00
test Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00
.ci-tron.json CI: one v2 'ci' pipeline (lint, then test and flake-check in parallel) 2026-10-04 18:32:24 +02:00
.formatter.exs Initial ci-tron backend: webhook-triggered CI runs over flat files 2026-08-18 09:39:45 +02:00
.gitignore Add disposable NixOS VM job runner 2026-10-05 16:53:46 +02:00
CLAUDE.md Docs: project page Pipelines section, run-level authorisation, ProjectPipelines 2026-10-04 17:51:16 +02:00
flake.lock Initial ci-tron backend: webhook-triggered CI runs over flat files 2026-08-18 09:39:45 +02:00
flake.nix Add the container runner: rootless bubblewrap jobs behind a socket-activated worker 2026-10-09 16:59:26 +02:00
mix.exs Grow ci-tron-ssh-login into a full ci-tron CLI 2026-08-21 14:11:30 +02:00
mix.lock Update bandit, mint, lazy_html (and req, finch, hpax) to clear the mix deps audit 2026-10-09 00:53:56 +02:00
README.md Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00
ROADMAP.md Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00
SKILL.md Public read access: anonymous reads of operator-listed public repos 2026-10-15 10:54:06 +02:00

ci-tron

A minimal, self-hosted CI backend for Forgejo. It watches every repo on a trusted Forgejo instance automatically (one system webhook, no per-repo setup): on push, it clones the repo itself, reads a small .ci-tron.json manifest, turns it into a graph of pipelines and jobs (plain named entrypoints, a static pipeline graph, or a graph evaluated from the repo's own flake), and runs each job inside nix develop, in dependency order. Output and exit status are captured per run, and a JSON API covers everything the web UI does — run history, logs, cancel/restart, and starting a run without a push — so CI can be driven from a script as well as a browser. The manifest format is documented in SKILL.md, the reference other repos' agents fetch.

Built with Elixir / Phoenix: each run is a supervised process on the BEAM, so a crashed or hung script can't take the server down. Runs are stored as flat files (no database). A LiveView UI shows projects, job runs, and their logs streaming live — see Web UI.

Quick start

Try it with no setup, using throwaway config (random secrets, empty repo list, all state discarded on exit):

nix run .

This prints an API token and starts listening on http://localhost:4000, with a throwaway webhook secret and SSH key — real webhooks won't actually authenticate or clone anything in this mode. See Watching repos automatically below for real setup, or just point requests at the API to poke around:

curl -H "Authorization: Bearer <token from the output above>" http://localhost:4000/api/runs

For real development:

nix develop            # dev shell: elixir, erlang, elixir-ls, esbuild
mix setup               # fetch deps
mix test                 # full test suite
mix phx.server            # http://localhost:4000, reads priv/repos.json

First run also needs Hex/rebar locally: mix local.hex --force && mix local.rebar --force (installs into ./.mix).

To try the UI without wiring a real repo/webhook, run mix ci_tron.dummy instead of mix phx.server — it starts the server and seeds a handful of fake runs across every status, including one real run executing a demo script so its logs stream live.

Watching repos automatically

ci-tron doesn't track a per-repo list. It's wired to a single Forgejo System Webhook (Site Administration → Webhooks → Add Webhook), which Forgejo fires for every push on every repo on the instance, current and future — set the URL to https://your-ci-tron-host/webhook, content type application/json, and the secret to CI_TRON_WEBHOOK_SECRET's value. There's one shared secret for the whole instance, not one per repo.

On a push, ci-tron clones the repo itself over SSH (ssh://forgejo@<your forgejo host>/<repo>.git) using the key at CI_TRON_SSH_KEY_PATH — register the matching public key on the Forgejo account whose repos it should read (Settings → SSH/GPG Keys). If the pushed commit has a .ci-tron.json at its root, ci-tron turns it into a graph and runs its jobs. The simplest form declares named entrypoints, each its own CI run (a one-job pipeline):

{
  "devShell": ".#default",
  "entrypoints": {
    "test": ["mix", "test"],
    "build": ["mix", "release"]
  }
}

"version": 2 manifests declare pipelines (each with its own on triggers) of jobs that can needs each other, plus manual-only top-level jobs; {"version": 2, "eval": ".#ciTron"} instead evaluates a flake attribute of the repo (nix eval --json, as the unprivileged runner) into the same graph. "version": 3 is v2 plus allowReadFrom and per-job repos: the way one repo's nix-vm or container jobs read another private repo, which must list it (allowReadFrom) and whose reader must be allowed to see it on Forgejo; the controller stages a read-only copy, so neither its SSH key nor its admin token reaches the job (the optional CI_TRON_FORGEJO_TOKEN still does, except for public repos). SKILL.md has the full schema; the rest of this section describes the v1 form.

  • entrypoints — required, one or more names mapped to an argv array (not a shell string), each run as nix develop [<devShell>] --command <argv...> inside a checkout of the pushed commit. A push with 2 entrypoints produces 2 separate runs, visible independently in Jobs.
  • devShell — optional flake devShell selector (e.g. .#ci); omitted means the flake's default devShell. false skips nix develop entirely and runs the entrypoint's argv directly — for entrypoints that are already plain nix commands (nix build .#thing, nix flake check) in a repo with no devShells output at all.
  • runner — optional, "nix" (default), "nix-vm" (a disposable NixOS VM) or "container" (a rootless bubblewrap container); the last two require operator support. For v2 this is a per-job field. Any other value, or a missing/invalid entrypoints, starts no runs but is not silent: ci-tron records a failed evaluation and, with a Forgejo token configured, posts a ci-tron/eval commit status carrying the reason. A repo with no .ci-tron.json at all is ignored, same as a non-push event.

Repos you never want CI on (even with a valid manifest) go in services.ci-tron.excludedRepos (a NixOS module option — see Deployment), not a file inside the repo.

Only push events on a branch (refs/heads/*) and pull_request events (opened, synchronize, reopened) from a branch of the same repo are considered; other event types, fork PRs, and pushes to tags get a 200 {"ignored": true} immediately. Tag pushes are deliberately excluded, not just untested: Forgejo fires a push event for tag creation too (including indirectly, e.g. via the releases API), so an entrypoint that publishes a release would otherwise retrigger itself on every run. An accepted event gets an immediate 202 {"accepted": true} — the clone, manifest read, and fan-out all happen afterward in the background (so a slow clone never delays the webhook response), which means the response can't tell you how many runs, if any, will actually appear — check Jobs for that.

Pipelines can also run on a cron schedule ({"schedule": "<cron>"}, UTC): a per-minute CiTron.Scheduler starts the due ones against the head of the branch most recently pushed. The schedules found in each repo's manifest are cached in repos.json (a push resets them; an eval manifest is evaluated once by the next tick to learn them), and a repo whose discovery fails transiently is left alone with an exponential backoff — see SKILL.md for the exact timings.

Each job gets its own writable copy of a controller-created source snapshot (see How it fits together) and runs with the triggering commit's details in its environment. The authenticated clone is performed by the controller, so entrypoints never receive its SSH key.

Env var Meaning
CI_TRON_REPO repo full name, e.g. antoine/myrepo
CI_TRON_REF pushed ref, e.g. refs/heads/main
CI_TRON_SHA commit sha
CI_TRON_PUSHER who pushed
CI_TRON_EVENT push, pull_request (same-repo PRs), schedule (cron) or manual (POST /api/triggers, "run now", restarts)
CI_TRON_RUN_ID this run's id
CI_TRON_RUN_DIR this run's data directory (meta.json, log, artifacts — the entrypoint's writable source copy lives in a temp directory instead, since this one isn't exec-permitted)
CI_TRON_ARTIFACTS_DIR $CI_TRON_RUN_DIR/artifacts — files placed here become downloadable once the run finishes (flat files only, no subdirectories)
CI_TRON_PIPELINE, CI_TRON_PIPELINE_ID, CI_TRON_JOB, CI_TRON_EVALUATION_ID the pipeline name and id, the job name, and the evaluation id this run belongs to
CI_TRON_INPUTS_DIR read-only (0440) copies of the artifacts of the jobs this job needs, one subdirectory per upstream job (empty without needs). Top-level regular files only, copied by the job's own RunServer before the script starts, capped at services.ci-tron.maxInputBytes in total (default 512 MiB) and maxInputEntries entries (default 10 000) — over either cap the job ends error with "failed to stage inputs: …" in its log
CI_TRON_REPOS_DIR only for a nix-vm or container job that lists repos (manifest v3): read-only copies of those repos at <owner>/<name> (lower-case) under it
CI_TRON_RELEASE_DIR only for a release-mode job: where it writes release.json (and optional release-notes.md) for ci-tron to publish
CI_TRON_FORGEJO_TOKEN a Forgejo API token, for entrypoints that call the Forgejo API themselves — only present if services.ci-tron.forgejoToken is configured, and never for a repo listed in publicAccess.repos (release mode publishes from ci-tron itself and is unaffected). ci-tron itself also uses this token, if set, to post a commit status (pending → success/failure/error) back to Forgejo for each pipeline — otherwise a push shows nothing in Forgejo's own commit/PR view.

ci-tron also reads the pushed commit's message straight from the webhook payload (head_commit.message, falling back to the first entry of commits) and stores it alongside the run — shown as a tooltip in the Web UI's job rows and as visible text on the job detail page. Older runs recorded before this field existed simply have none.

ci-tron's own operational env — SECRET_KEY_BASE, CI_TRON_API_TOKEN, CI_TRON_WEBHOOK_SECRET, PHX_SERVER, PORT, PHX_HOST — is explicitly unset for every entrypoint, not merely omitted: the underlying process supervisor (MuonTrap) merges its given env with the full inherited environment by default, so scripts would otherwise see ci-tron's own secrets alongside the CI_TRON_* variables above.

Web UI

A LiveView UI is served alongside the JSON API, gated by "Sign in with Forgejo" — an OAuth2 login against the same Forgejo instance ci-tron watches, rather than a shared password. Any Forgejo account can sign in, but each signed-in user only sees projects and jobs for repos they have read access to on Forgejo (checked once at login via GET /api/v1/user/repos using their own OAuth token, cached for the session — log out and back in to pick up a permission change). A run on a repo you can't see behaves exactly like an unknown run id: "run not found," never a distinguishable 403, so private repos aren't detectable by someone without access.

Anonymous read access. Repos the operator lists in publicAccess.repos (see Deployment) and that Forgejo reports as public can be read without signing in — and by signed-in users who aren't members. The Forgejo answer is cached for 60 s and fails closed. It is read-only (repo_writable? is untouched), and what is open is chosen by publicAccess.surfaces: ui (pages, live logs) and downloads (raw log, artifacts) by default, api (unauthenticated GET /api/...) opt-in. For a reader who is not a member, evaluation logs and errors, the repos an evaluation pinned, the instance-wide running count and a fresh manifest read (a git clone) are withheld — the project page shows the latest stored evaluation's graph. Job logs of a public repo are public, so ci-tron keeps the two secrets it controls out of them: a repo that is listed and public (or whose visibility Forgejo did not confirm as private) gets no CI_TRON_FORGEJO_TOKEN in its jobs, and its CI may read only repos anyone can read through manifest v3 repos (asked of Forgejo without credentials; B need not be listed itself; a B that is private or needs a sign-in is "not available", even if it lists the repo). What remains is yours: manifest env values are echoed by the wrapper's set -x, and a job can print anything else it can reach. Listing or publishing a repo also publishes its earlier logs.

Page Description
/ Projects — runs grouped by repo, latest status, links to that project's jobs
/jobs (optionally ?repo=...) Job list — status, ref, sha, pusher, relative start time, duration, total size (log + artifacts); a commit that fanned out into several jobs is grouped into one expandable row
/jobs/:id Job detail — logs streaming live (ANSI colors rendered), artifacts, commit message, status updating live; links to its pipeline (pipeline ci #12 →), and a "N more pipelines from this commit" hint (other pipelines of that commit) links to /pipelines filtered to it
/project?repo=... Project overview — a Pipelines section (a card per pipeline: trigger chips with the next scheduled run in UTC, the job flow by level with each job's latest status, the latest run on any branch and a tape of the last 10; the name links to /pipelines?repo=…&name=…), standalone jobs, pipelines no longer in the manifest, a paused-schedules warning; "run now" / restart / cancel with push access), then the run history. An eval manifest shows its last successful evaluation's graph (never evaluated on view). Updates live
/pipelines (filters ?repo=&name=&status=&ref=&q=) Pipeline list — pipelines grouped by commit with a status summary, job counts, filter chips, search and paging; cancel / restart / restart failed (push access); a Failed evaluations panel above it shows evaluations that started no pipeline, each expandable to its full error and eval.log tail. Updates live
/pipelines/:id Pipeline detail — header with status, ref @ sha, actor and the same actions; the job graph as columns by dependency level (status, attempt, duration per job, each linking to the job page); the evaluation's log, loaded on demand. Updates live

The nav bar (Projects, Pipelines, Jobs) shows a live count of currently running jobs (top right, updates without a page reload), the running build's version (v0.1.0 · b36a755, hash in the tooltip; -dirty for a build from an uncommitted tree), who's signed in with a log-out link (a "sign in" link, and no running count, for an anonymous visitor), and a theme toggle that cycles auto → light → dark. Auto (the default) follows the OS preference and tracks it live; light and dark are overrides persisted in localStorage.

Setting this up requires a one-time manual step: register an OAuth2 Application in Forgejo (Settings → Applications → OAuth2 Applications, or site-admin equivalent) with its Redirect URI set to https://<your-ci-tron-host>/auth/forgejo/callback, then configure the resulting client id/secret as CI_TRON_FORGEJO_OAUTH_CLIENT_ID / CI_TRON_FORGEJO_OAUTH_CLIENT_SECRET (see Deployment).

API

Everything the UI can do is also an API endpoint, so ci-tron can be driven from a script without a browser. Authorization: Bearer <token> carries either of two credentials:

  • CI_TRON_API_TOKEN — the operator's instance-wide token. Deliberately unscoped: it sees and acts on every repo.
  • a Forgejo personal access token — resolved against Forgejo (cached briefly) into that user's identity and repo access, then scoped exactly as the UI is. Reads need read access to the repo; POST endpoints need push access, since triggering a run makes ci-tron execute the repo's entrypoints. A repo the caller can't read returns 404, not 403, so run ids and repo names can't be probed for.

The ci-tron CLI (nix build .#cli) wraps these endpoints (--json prints the raw response); SKILL.md lists its commands, including pipelines, pipeline <id> [cancel|restart|restart-failed], evaluations and evaluation <id> [--log]. pushes/push are deprecated: use pipelines/pipeline.

Nothing here is left open the way the webhook is (that's authenticated by its own instance-wide HMAC signature instead) — run logs can carry sensitive output.

Endpoint Description
POST /webhook Forgejo push webhook receiver — responds 202 {"accepted": true} immediately for a push event (discovery/fan-out happens asynchronously, so this doesn't report a run_id or how many runs, if any, will appear)
GET /api/runs list runs, newest first — see the filter/paging params below
GET /api/runs/:id a run's status/metadata
GET /api/runs/:id/log?offset=N log content from byte N; eof? is true once the run has finished
GET /api/runs/:id/log/raw the whole log as text/plain
GET /api/runs/:id/artifacts list a run's artifacts (filename, size)
GET /api/runs/:id/artifacts/:filename download one artifact
GET /api/pipelines list pipelines, newest first — see the params below. Items: id, evaluation_id, repo, name, number, status (running/success/failure, derived from each job's latest attempt), ref, branch, sha, event, actor, message, created_at, finished_at (null while running), jobs (count of latest attempts per status). Bare array plus x-total-count, like /api/runs
GET /api/pipelines/:id one pipeline with its jobs: the latest attempt of each job (id is the run id, name, needs, attempt, attempts = how many exist, level = depth in the needs graph, status, exit_code, started_at, finished_at) and job_counts. 404 for an unknown id or a repo the caller can't read — identical responses
POST /api/pipelines/:id/cancel cancel every queued and running job (queued first, under the evaluation's restart lock, so it waits for a restart in flight) — 200 with the pipeline as it is at that moment (a running job is killed asynchronously, so it may still read running), 409 not_running if no job is active
POST /api/pipelines/:id/restart a new pipeline (new id/number) of the same evaluation with fresh attempts of every job, from the frozen graph (nothing is re-evaluated; the source is re-cloned at the pinned sha if its snapshot is gone, so this is synchronous and bounded like /api/triggers) — 201 with the new pipeline; 409 running while a job is still active (in this pipeline or an earlier restart of it), legacy for a pipeline that predates graphs, invalid_graph
POST /api/pipelines/:id/restart-failed queue new attempts, in the same pipeline, of every latest job that is failure/error/interrupted/cancelled, plus the skipped ones — 200 with the pipeline and restarted (the new run ids); 409 nothing_failed, legacy, invalid_graph. Same re-clone caveat as restart
GET /api/evaluations list evaluations, newest first — how to find a failed one (it has no pipeline): ?repo=owner/name&status=failed. Params repo, status (evaluating/ok/failed/skipped), ref, limit (default 50, max 200), offset, with the same clamping/ignoring and x-total-count as /api/pipelines; repo-scoped before filtering. Items: id, repo, ref, branch, sha, event, status, error (first line only), created_at, finished_at, pipelines (ids); no log
GET /api/evaluations/:id an evaluation: status (evaluating/ok/failed/skipped), repo, ref, branch, sha, event, actor, message, error (why it failed), created_at, finished_at, pipelines (ids) and eval_log, the last 64 KiB of the evaluator's eval.log (null if none; eval_log_truncated says the start was cut)
GET /api/pushes deprecated: use /api/pipelines — runs grouped by repo + sha with an aggregate status, same filters as /api/runs
GET /api/pushes/:sha?repo=owner/name deprecated — every run for one commit (repo is a query param because a full name contains a slash)
GET /api/projects repos that have runs, each with a run count and its latest run
POST /api/runs/:id/cancel cancel a running run, or dequeue a queued one (it becomes cancelled) — 204, or 409 if it already finished
POST /api/runs/:id/restart queue a new attempt of the same job in the same pipeline, from the graph frozen at evaluation time — 201 with the new run, or 409 if the run is active, not the job's latest attempt, or its stored graph is invalid. Runs created before pipelines existed are re-cloned instead, so that path is synchronous like /api/triggers and also works while the run is active
DELETE /api/runs/:id permanently delete a finished run — 204, or 409 if it is queued/running or a job of a multi-job pipeline
POST /api/triggers start runs without a push: {repo, ref, sha?, pipeline?, job?, entrypoint?} (entrypoint aliases pipeline) — resolves the branch head, clones, reads the manifest (evaluating an eval one), and responds 201 with runs plus evaluation (id) and pipelines. Synchronous, bounded at 60s: 504 means discovery is still going and runs may yet start. Manifest and evaluation problems are 422 with a reason (invalid_graph + path, eval_failed/eval_timeout/…, unknown_entrypoint, unknown_job, excluded, the v1 manifest reasons); 502 clone_failed only when the clone itself failed
POST /api/manifest/validate validate a .ci-tron.json sent as the raw body, without pushing it. 200: a manifest without "version" (v1) answers {valid, version: 1, devShell, entrypoints}; "version": 2 answers the graph ({valid, version: 2, pipelines, jobs}), or {valid, version: 2, eval, note} for an eval descriptor (checked, not evaluated); "version": 3 answers the same with version: 3 and the manifest's allowReadFrom when it lists any. 422 {valid: false, reason, error} names the rule broken, with entrypoint (v1) or path (graph); a "version" other than 2 or 3 is invalid_version

GET /api/runs accepts optional query params, all combinable:

Param Effect
repo exact repo, e.g. antoine/ci-tron
status one of queued, running, success, failure, error, interrupted, cancelled, skipped
entrypoint exact entrypoint name
ref branch, as either main or refs/heads/main
q case-insensitive substring over repo, sha, pusher, entrypoint and commit message
limit window size, default and maximum 1000
offset how many matches to skip, for paging
pipeline_id the jobs (every attempt) of one pipeline

Unrecognized values are ignored rather than rejected — an unknown status behaves as no status filter, and out-of-range limit/offset are clamped — so a paging script never gets a 400 for overshooting. The response body is a bare JSON array; the number of matches before limit/offset is returned in the x-total-count header.

GET /api/pipelines takes repo, name (exact), status (running, success or failure), ref (as above), q (case-insensitive literal substring over repo, name, ref and sha — no patterns), limit (default 50, maximum 200) and offset. It behaves like /api/runs: unrecognized values are ignored, a negative or non-numeric limit/offset falls back to its default (50/0), a too-large limit is clamped, x-total-count counts the caller-visible matches. The scan is in memory over every pipeline the caller can read (O(pipelines) per request); only the returned window reads its jobs.

Tests

mix test
  • test/ci_tron/* — the storage/execution core: Run (struct + on-disk JSON shape), Runs (flat-file context, including artifacts), RunServer (script execution via MuonTrap), Git (clone/checkout and ref resolution, against real local bare repos — no network), Manifest (one case per rejection reason), PushHandler (fan-out, also against real local bare repos), Forgejo.Token (PAT resolution and caching, against a Req.Test stub), Queue (including boot recovery), and application_test.exs for the real supervision tree end to end.
  • test/ci_tron_web/* — webhook signature verification, the raw-body capture plug, API/UI auth including per-repo scoping, every controller (runs, actions, triggers, overview, manifest, artifacts) via full HTTP requests, the LiveViews via Phoenix.LiveViewTest, AnsiHtml, and Formatters.

How it fits together

Piece Role
CiTron.Run Pure struct + meta.json encode/decode.
CiTron.Runs Flat-file context: create/list/read runs, byte-offset log reads, PubSub notifications.
CiTron.RunServer One GenServer per run (restart: :temporary — a crash is never auto-retried). Runs the script via MuonTrap.Daemon, streaming output into the run's log file as it happens, so an in-progress run's log is already readable.
CiTron.Git git CLI wrapper: derives a repo's SSH clone URL, clones+checks out a sha.
CiTron.Manifest Reads/validates a checkout's .ci-tron.json (load/1: a v1 manifest translated to a graph, a v2 static graph, or an eval flake attribute), reporting which rule an invalid one broke rather than a bare "invalid".
CiTron.Graph The v2 pipeline/job graph and its strict validator (names, needs, cycles, env, on), plus trigger selection and the schedule list. Every producer's output goes through it.
CiTron.Evaluator Turns a manifest source into a validated graph. For eval it runs nix eval --json on the repo's own flake as the unprivileged runner (wrapper script, timeout, runner-writable work dir the controller seals and reads without following symlinks), bounded by CiTron.Evaluator.Pool (maxConcurrentEvals).
CiTron.CommitStatus Posts a commit status to Forgejo (pending while a pipeline is queued/running, then the final state) via CI_TRON_FORGEJO_TOKEN — best-effort, a no-op if the token isn't configured, never raises.
CiTron.PushHandler Orchestrates a verified push, pull request, cron tick or on-demand trigger: exclusion check, a throwaway discovery clone to read the manifest, evaluation, selection of the pipelines the event asks for, a frozen CiTron.Evaluation, one source snapshot, and one queued run per job (a generated wrapper script copies the snapshot). A bad manifest or failed evaluation is recorded and reported as a ci-tron/eval status. Returns the runs it created.
CiTron.Scheduler, CiTron.RepoRegistry The per-minute cron tick and the file it reads (repos.json): the most recently pushed ref per repo plus its cached schedules, a generation counter that drops results learned from a superseded push, and retry/backoff state for repos whose discovery fails transiently.
CiTron.Inputs Stages a job's upstream artifacts as read-only copies in CI_TRON_INPUTS_DIR, size- and entry-capped by maxInputBytes / maxInputEntries; run by the job's RunServer, never by the Queue.
CiTron.Queue Starts queued jobs in dependency order (a job whose needs did not succeed is skipped), posts one commit status per pipeline (via PipelineStatus), and at boot marks any run stuck in :running (server was killed mid-run) as :interrupted (Queue.recover/0).
CiTron.Push Aggregate status (:running/:success/:failure) over a group of runs from the same push — used by the project overview and the (deprecated) pushes API.
CiTron.Forgejo.OAuth Forgejo's OAuth2 provider client: authorize URL, code exchange, fetching the signed-in user's identity and accessible repos — via Req, stubbable in tests through :forgejo_req_options.
CiTron.Authorization repo_allowed?/2 — whether a caller's cached accessible-repo set covers a repo, or the repo is public (PublicRepos); repo_member?/2 — the list alone, for data public readers must not get. Shared by every LiveView and the plain controllers below.
CiTron.PublicRepos Anonymous read access: a repo is public when the operator's publicAccess.repos lists it and Forgejo says it is not private (60 s ETS cache, fails closed); exposed?/1 is the fail-safe mirror (listed and not definitively private) that withholds CI_TRON_FORGEJO_TOKEN and limits a repo's cross-repo reads to repos world_readable?/1 (an unauthenticated Forgejo request sees them, allowlist or not); surface_enabled?/1 for publicAccess.surfaces.
CiTronWeb.WebhookController Verifies the instance-wide HMAC signature, filters to branch push events (tags ignored) and same-repo pull_request events (opened/synchronize/reopened, forks ignored), dispatches PushHandler on a Task.Supervisor so a slow clone never delays the response.
CiTronWeb.Webhook.Payload Pure helpers for pulling fields (currently just the commit message) out of a decoded push payload.
CiTronWeb.AuthController The OAuth2 login flow: /auth/forgejo (start), /auth/forgejo/callback (verify CSRF state, exchange code, cache the user's accessible repos in session), /auth/logout.
CiTronWeb.Plugs.RequireUser Gates the UI scope on a signed-in session, redirecting to /auth/forgejo otherwise (unless publicAccess.surfaces opens that surface to anonymous visitors, who get empty repo lists); assigns current_user/accessible_repos for the two plain controllers below.
CiTronWeb.Auth.live_session_data/1 Builds the session map every LiveView's mount/3 receives (current_user, accessible_repos) from the plug session, via the router's live_session :ui, session: {...} option.
CiTronWeb.RunController The read side of the JSON API (runs, logs, artifact listing), repo-scoped per caller.
CiTronWeb.RunActionController Cancel, restart and delete — the actions the UI offers on a run.
CiTronWeb.PipelineController, EvaluationController The pipelines API (list/show, cancel, restart, restart-failed — all through CiTron.Pipelines, under its per-evaluation restart lock) and an evaluation's status, error and eval.log tail; repo-scoped with the same 404-for-unreadable rule as runs. CiTron.PipelineFilter is the pure filter/paging behind the list.
CiTronWeb.RunStarter Shared by the endpoints that start runs synchronously (trigger, run and pipeline restart): bounds how long the caller waits on a clone, and renders PushHandler's failure reasons as HTTP.
CiTronWeb.TriggerController POST /api/triggers: resolves a branch to a sha and runs PushHandler synchronously (optionally for one pipeline or job), so the response carries the runs it created.
CiTronWeb.OverviewController The pushes (deprecated) and projects JSON groupings.
CiTronWeb.ManifestController Validates a v1, v2 static or eval manifest supplied in the request body (an eval descriptor is checked, not evaluated).
CiTron.Forgejo.Token Resolves a Forgejo personal access token into a login plus readable/writable repo lists, ETS-cached with a short TTL. Lets the API authenticate non-operator callers without a second credential store.
CiTronWeb.ArtifactController Serves one artifact file, mounted under both /api/runs/:id/artifacts/:filename (Bearer auth) and /jobs/:id/artifacts/:filename (session auth), both repo-authorized.
CiTronWeb.ProjectLive.Index, ProjectLive.Show, RunLive.Index, RunLive.Show The LiveView UI: projects, project overview, job list and job detail — each filtered to the signed-in user's accessible repos. Run-level cancel / restart / remove events re-read the run on the server and need push access to its repo (LiveHelpers.authorize_run_action/2); the project page's pipeline restart / cancel additionally require the pipeline to belong to that page's repo. Its live updates are coalesced like the pipeline pages'.
CiTronWeb.PipelineLive.Index, PipelineLive.Show The pipeline list (grouped by commit, failed-evaluations panel) and the pipeline page (job graph by level, actions, lazy evaluation log). Same repo scoping as the API (unknown and unreadable ids look identical); actions go through CiTron.Pipelines off the LiveView process. Live updates are coalesced (LiveHelpers.coalesce/3): at most one reload per 250 ms however many events arrive.
CiTron.ProjectPipelines What the project page's Pipelines section shows for a repo: the manifest's pipelines as levels of jobs with triggers and next cron fire times (UTC, never raises), each one's latest execution on any branch with per-job statuses and a ≤10-entry history, standalone jobs, and stored pipelines the manifest no longer defines (compared by pipeline name). Reads the Store only; an eval manifest shows the latest successful stored evaluation's graph.
CiTron.PipelineInfo, CiTron.EvalLog Shared by the JSON API and the pipeline pages: a pipeline's derived status, per-status counts, finish time and jobs with their dependency level (PipelineInfo); the bounded (64 KiB), no-follow, UTF-8-safe tail of an evaluation's eval.log (EvalLog).
CiTronWeb.CoreComponents.nav/1 The top nav bar, rendered from inside each LiveView's own template (not the static root layout) so the live running-jobs count can update without a page reload.
CiTron.Version The build's version for the nav bar, fixed at compile time: CI_TRON_REV/CI_TRON_TAG (set by the Nix build from self.rev/self.dirtyRev, since the source has no .git), else git in a dev checkout, else dev. Never raises.
CiTronWeb.AnsiHtml Converts ANSI SGR color/bold codes in log output into styled HTML spans.
CiTronWeb.Formatters Shared view helpers: human-readable/relative dates, durations, byte sizes.

Runs live at <data_dir>/runs/<run_id>/{meta.json,log.txt,artifacts/}; run_id is a timestamp-prefixed, lexicographically sortable string.

Deployment

The flake exposes a NixOS module:

{
  imports = [ inputs.ci-tron.nixosModules.default ];

  services.ci-tron = {
    enable = true;
    domain = "ci.example.com";
    webhookSecret = "/run/secrets/ci-tron-webhook-secret"; # deployed directly, never in the Nix store
    sshKeyPath = "/run/secrets/ci-tron-ssh-key"; # same
    apiToken = "/run/secrets/ci-tron-api-token";
    forgejoUrl = "https://git.example.com"; # used to link to each repo in the UI, and to derive the SSH clone host
    excludedRepos = [ "antoine/scratch" ]; # optional, default []
    forgejoToken = "/run/secrets/ci-tron-forgejo-token"; # optional, default null — exposed to entrypoints as CI_TRON_FORGEJO_TOKEN
    forgejoOAuthClientId = "abcdef0123456789"; # from Forgejo's OAuth2 Application registration, not a secret
    forgejoOAuthClientSecret = "/run/secrets/ci-tron-forgejo-oauth-client-secret"; # same registration's client secret
  };
}

Operator tunables (all optional; each is exported to the release as the environment variable shown):

Option Default Env var Meaning
excludedRepos [] CI_TRON_EXCLUDED_REPOS repos that are never cloned, evaluated or run, whatever their manifest says
publicAccess.repos [] CI_TRON_PUBLIC_REPOS owner/name or owner/* repos anonymous visitors may read — only while Forgejo also reports them public. Empty = nothing public
publicAccess.surfaces ["ui" "downloads"] CI_TRON_PUBLIC_SURFACES what anonymous visitors may use: ui, downloads (raw logs, artifacts), api (unauthenticated GETs)
maxConcurrentRuns 4 CI_TRON_MAX_CONCURRENT_RUNS jobs executing at once; further ones stay queued
retentionDays 90 CI_TRON_RETENTION_DAYS age after which a finished pipeline is garbage-collected (the 5 newest per repo are always kept)
evalTimeout 120 CI_TRON_EVAL_TIMEOUT seconds an eval manifest's nix eval may run before it is killed and the evaluation fails
maxConcurrentEvals 2 CI_TRON_MAX_CONCURRENT_EVALS simultaneous nix evals; others wait up to a minute, then fail as unavailable
maxInputBytes 536870912 (512 MiB) CI_TRON_MAX_INPUT_BYTES total size of the upstream artifacts staged into one job's CI_TRON_INPUTS_DIR; over it the job ends error
maxInputEntries 10000 CI_TRON_MAX_INPUT_ENTRIES number of entries in the upstream artifacts/ directories staged into one job's CI_TRON_INPUTS_DIR, counted before copying; over it the job ends error

For jobs needing an ordinary account and isolated SSH/PTY/Wayland sessions, enable the optional disposable VM backend:

services.ci-tron.vmRunner = {
  enable = true;
  cpus = 2;
  memorySize = 4096; # MiB per active VM
  diskSize = 32768; # MiB, sparse disposable disk
  bootTimeout = 120; # seconds
};

Then select "runner": "nix-vm" on individual v2 jobs (or at the top level of a v1 manifest). It keeps the same run, devShell, needs, env and release contracts. Existing manifests and manifest evaluation continue using the host runner. Disabled/unavailable VM execution produces a visible infrastructure error; it never silently executes the job on the host.

The backend requires Linux and accessible /dev/kvm. The module creates a controller-only Unix socket and one socket-activated worker per job under the separate ci-tron-vm account. Workers supervise QEMU outside the controller's private-device namespace; host runner account restrictions and the controller's existing hardening remain intact. vmRunner.acceleration = "tcg" is provided for integration tests without nested KVM, and is much slower than the production default, "kvm".

Guests use an ordinary job account with Bash, an unusable but unlocked password field, private HOME/runtime directories, PTYs and loopback TCP. No SSH service or desktop session starts automatically. Public dependency fetching uses outbound QEMU user networking without inbound forwarding. Each guest has a separate Nix daemon/database and disposable disk-backed store overlay; the shared host store is read-only, with temporary GC roots protecting its registered snapshot during execution. Store preparation can cost time on large hosts, and roots temporarily prevent collecting those paths. Guest writes never populate the host store.

On completion, cancellation or controller loss, the worker stops QEMU, exports flat regular files into a private recovery spool, and removes the disk, credentials and GC roots. The controller imports its own copies before recording a terminal status. Failed imports leave the spool available for CiTron.VmRunner.recover(run); recovery errors remain visible in the logs. Unconfirmed teardown leaves the run active while recovery retries. Collection failures retain the stopped guest's exports for a retry while erasing its request credentials and disk. Normal artifacts use the existing UI/API/CLI and pipeline retention. The runner reserves vm-boot.log; tests must export their own diagnostic files before deleting scratch data. Archive allowlisted reports/logs rather than whole SSH fixture directories, which contain private keys.

For jobs that only need isolation from the host, not a whole OS, the lighter container backend avoids the VM's boot and store setup:

services.ci-tron.containerRunner.enable = true;

Select "runner": "container" the same way as nix-vm; the run, devShell, needs, env, repos and release contracts are the same, and an unavailable backend is an infrastructure error, never host execution. It uses the same socket-activated worker protocol as the VM backend (CiTron.VmRunner talks to both; priv/vm/container_worker.py reuses priv/vm/worker.py's request checks and spool), under a separate ci-tron-container account and unit so the controller keeps RestrictNamespaces. Each job runs under bubblewrap on unprivileged user namespaces (the kernel must allow them): the host store read-only, the host nix-daemon socket (the job's real uid is kept, so the daemon treats it as an untrusted user), the host network, a private /tmp/HOME/process table, a writable copy of the source, read-only inputs and only the repos the job asked for. The process dies with the worker. Exports go through the same spool and are imported by the controller. It shares the host kernel (weaker than a VM) and the host network, including loopback services. Verify with nix build .#checks.x86_64-linux.container-runner -L (real bubblewrap and nix-daemon, no nested virtualisation, so it runs in nix flake check).

Verify the VM backend with python3 -m unittest discover -s test/vm and nix develop --command mix test. The real-guest test, nix build .#vm-runner-test -L (a package, not a flake check), uses an offline development shell and exercises real SSH key authentication with UsePAM no, remote PTYs/tmux, software headless sway, artifact filtering and cancellation — but it does not pass yet: the nested guest never prints anything in the test VM (ROADMAP.md), which is why it is not part of nix flake check and CI. The runner-privilege-drop and repos-isolation checks are the host-runner and staging-isolation regression checks.

It runs the controller and CI commands as separate hardened system users (see nix/module.nix for the exact sandboxing and why it's deliberately less strict than a typical Phoenix app's — ci-tron's whole job is running arbitrary scripts) and generates SECRET_KEY_BASE on first boot. Put a TLS reverse proxy in front; it listens on loopback.

The controller drops to the unprivileged runner user via a security.sudo.extraRules entry, so security.sudo.enable must stay on (the default); security.sudo-rs.enable disables it and reads a separate rule set the module does not populate, and a module assertion catches that combination at eval time.

If you're upgrading a host that was already running before the runner privilege-drop fix landed, sources/, wrappers/, and runs/ under the data directory may already exist with stale permissions from before the fix and won't self-correct — mkdir -p only sets a mode when it creates a directory, never on one that already exists. Run chmod 0750 <dataDir>/{sources,wrappers,runs} once after upgrading if CI runs still fail with a Permission denied reading the source checkout.

If you're upgrading a host that ran with DynamicUser = true (removed when the module moved to static ci-tron/ci-tron-runner users), the data directory keeps the mount systemd set up for that dynamic user even after you switch to a static one: mkdir/stat/ls inside it keep working, but creating any new regular file (including artifacts, CI_TRON_ARTIFACTS_DIR in particular) fails with EOVERFLOW ("Value too large for defined data type"), because the directory is really still owned by nobody:nogroup on disk under a leftover id-mapped mount. Fix it once after upgrading: chown -R ci-tron:ci-tron <dataDir> && systemctl restart ci-tron.