- Elixir 88.2%
- CSS 6.2%
- Nix 5.3%
- JavaScript 0.3%
CI_TRON_FORGEJO_TOKEN is one instance-wide token, not scoped per-repo or per-run, so an entrypoint pushing to a repo other than the one it runs for needs the operator to have separately granted that token push access to the target repo too. Not a ci-tron bug, but the failure mode (a generic git push error, no obvious permission-denied in the log) doesn't point at the cause. |
||
|---|---|---|
| assets | ||
| config | ||
| issues | ||
| lib | ||
| nix | ||
| priv/static | ||
| 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 declaring named entrypoints, and runs each
one — one CI run per entrypoint — inside nix develop. 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.
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 runs each declared entrypoint as its own CI run:
{
"devShell": ".#default",
"entrypoints": {
"test": ["mix", "test"],
"build": ["mix", "release"]
}
}
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"is the only supported value today. Present so a future"docker"runner won't need a schema change; for now, any other value (or a missing/invalidentrypoints, or no.ci-tron.jsonat all) means the push is silently skipped — 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/*) are considered; other
event types, 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. A branch push 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.
Each entrypoint 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 for a webhook-delivered push, manual for a run started via POST /api/triggers |
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_FORGEJO_TOKEN |
a Forgejo API token, for entrypoints that call the Forgejo API themselves (e.g. publishing a release) — only present if services.ci-tron.forgejoToken is configured. ci-tron itself also uses this token, if set, to post a commit status (pending → success/failure/error) back to Forgejo for each run — 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/push 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, jobs, and pushes 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.
| Page | Description |
|---|---|
/ |
Projects — runs grouped by repo, latest status, links to that project's jobs and pushes |
/jobs (optionally ?repo=...) |
Job list — status, ref, sha, pusher, relative start time, duration, total size (log + artifacts); a "N more from this push" hint links to the push view when a push fanned out into more than one entrypoint |
/jobs/:id |
Job detail — logs streaming live (ANSI colors rendered), artifacts, commit message, status updating live |
/jobs/:id/push |
Push — every entrypoint run from the same push (repo + sha) in one place, with an aggregate status and passing fraction |
/pushes (optionally ?repo=...) |
Push list — one row per push, indexing the same repo+sha groups the push view drills into |
The nav bar shows a live count of currently running jobs (top right,
updates without a page reload), who's signed in with a log-out link, and a
theme toggle that switches between light and dark, persisted in
localStorage; it otherwise follows the OS preference.
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.
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/pushes |
runs grouped by repo + sha with an aggregate status — same filters as /api/runs |
GET /api/pushes/:sha?repo=owner/name |
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 — 204, or 409 if it already finished |
POST /api/runs/:id/restart |
re-run the same entrypoint on the same commit — 201 with the new run. Re-clones rather than reusing the original wrapper (whose source snapshot is deleted once a run finishes), so it is synchronous like /api/triggers |
POST /api/triggers |
start runs without a push: {repo, ref, sha?, entrypoint?} — resolves the branch head, clones, reads the manifest, and responds 201 with the runs it created (synchronous, bounded at 60s; 504 means discovery is still going and runs may yet start) |
POST /api/manifest/validate |
validate a .ci-tron.json sent as the raw body, without pushing it — 200 with the parsed entrypoints, or 422 naming the rule it broke |
GET /api/runs accepts optional query params, all combinable:
| Param | Effect |
|---|---|
repo |
exact repo, e.g. antoine/ci-tron |
status |
one of running, success, failure, error, interrupted, cancelled |
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 |
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.
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),Reconciler, 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, reporting which rule an invalid one broke rather than a bare "invalid". |
CiTron.CommitStatus |
Posts a commit status to Forgejo (pending on start, 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 or an on-demand trigger: exclusion check, a throwaway discovery clone to read the manifest, one run (with its own independent clone, via a generated wrapper script) per entrypoint. Returns the runs it created. |
CiTron.Reconciler |
At boot, marks any run stuck in :running (server was killed mid-run) as :interrupted. |
CiTron.Push |
Aggregate status (:running/:success/:failure) and passing fraction over a group of runs from the same push — shared by the push show and index LiveViews. |
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 signed-in user's cached accessible-repo set covers a given repo. Shared by every LiveView and the two plain UI controllers below. |
CiTronWeb.WebhookController |
Verifies the instance-wide HMAC signature, filters to push events, 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; 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 |
POST cancel/restart — the two actions the UI offers on a run. |
CiTronWeb.RunStarter |
Shared by the two endpoints that start runs synchronously (trigger, 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, so the response carries the runs it created. |
CiTronWeb.OverviewController |
The pushes and projects groupings, mirroring the LiveViews of the same name. |
CiTronWeb.ManifestController |
Validates a manifest supplied in the request body. |
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, unrestricted by repo) and /jobs/:id/artifacts/:filename (session auth, repo-authorized). |
CiTronWeb.ProjectLive.Index, RunLive.Index, RunLive.Show, PushLive.Show, PushLive.Index |
The LiveView UI: projects, job list, job detail, the push view (every entrypoint run from one push, grouped by repo + sha), and the push list — each filtered to the signed-in user's accessible repos. |
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. |
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
};
}
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.