No description
  • Elixir 88.2%
  • CSS 6.2%
  • Nix 5.3%
  • JavaScript 0.3%
Find a file
ant 3dbbc0eeb4
Some checks are pending
ci-tron/check Running…
ci-tron/test Running…
issues: document Forgejo token scope blocking cross-repo push
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.
2026-09-03 14:22:25 +02:00
assets Show each entrypoint's latest run on the project page 2026-09-03 14:21:32 +02:00
config Grow ci-tron-ssh-login into a full ci-tron CLI 2026-08-21 14:11:30 +02:00
issues issues: document Forgejo token scope blocking cross-repo push 2026-09-03 14:22:25 +02:00
lib Render a logged-out confirmation page instead of redirecting home 2026-09-03 14:22:08 +02:00
nix Revamp the web UI: consistent components, responsive layout, self-hosted type 2026-08-22 14:26:36 +02:00
priv/static Revamp the web UI: consistent components, responsive layout, self-hosted type 2026-08-22 14:26:36 +02:00
test Render a logged-out confirmation page instead of redirecting home 2026-09-03 14:22:08 +02:00
.ci-tron.json Fix the sudo privilege drop broken since fe4acef 2026-08-20 15:31:46 +02:00
.formatter.exs Initial ci-tron backend: webhook-triggered CI runs over flat files 2026-08-18 09:39:45 +02:00
.gitignore Ignore CLAUDE.local.md, personal deploy notes 2026-09-03 14:22:17 +02:00
CLAUDE.md Add a per-project overview page and fix invisible zero-run projects 2026-08-20 18:29:14 +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 a Nix package for the ci-tron CLI escript 2026-08-21 14:24:38 +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 Add pull_request, scheduled (cron), and manual UI triggers for entrypoints 2026-08-20 16:43:14 +02:00
README.md Fix EOVERFLOW on artifact writes after DynamicUser migration 2026-08-21 11:33:09 +02:00
ROADMAP.md Remove stale CLI roadmap entry; fix formatting drift from earlier phases 2026-08-21 16:03:14 +02:00
SKILL.md Show each entrypoint's latest run on the project page 2026-09-03 14:21:32 +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 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 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" 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/invalid entrypoints, or no .ci-tron.json at all) means the push is silently skipped — 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/*) 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; 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.

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 a Req.Test stub), Reconciler, 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, 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.