- Elixir 86.9%
- Nix 5%
- Python 3.8%
- CSS 3.6%
- JavaScript 0.7%
|
All checks were successful
ci-tron/ci Success
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. |
||
|---|---|---|
| assets | ||
| config | ||
| docs/superpowers | ||
| issues | ||
| journal | ||
| lib | ||
| nix | ||
| priv | ||
| test | ||
| .ci-tron.json | ||
| .formatter.exs | ||
| .gitignore | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| mix.exs | ||
| mix.lock | ||
| README.md | ||
| ROADMAP.md | ||
| SKILL.md | ||
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 asnix 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'sdefaultdevShell.falseskipsnix developentirely 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/invalidentrypoints, starts no runs but is not silent: ci-tron records a failed evaluation and, with a Forgejo token configured, posts aci-tron/evalcommit status carrying the reason. A repo with no.ci-tron.jsonat all is ignored, same as a non-pushevent.
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;
POSTendpoints need push access, since triggering a run makes ci-tron execute the repo's entrypoints. A repo the caller can't read returns404, not403, 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 aReq.Teststub),Queue(including boot recovery), andapplication_test.exsfor 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 viaPhoenix.LiveViewTest,AnsiHtml, andFormatters.
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.