collin/anvil
RenderedSource
anvil
A minimal, self-hosted git forge in Rust, built on gix (gitoxide).
Design rules
- Pure gitoxide — never the git binary. The product (server, CI broker,
mirroring, deploy image) must not invoke or depend on a
gitexecutable. When gix lacks a capability, implement the protocol on gix plumbing instead of shelling out — e.g. gix can't push yet, soanvil-git/src/push.rsspeaks the send-pack wire format itself (withgitserver-corecovering the server side). Tests may use the git CLI, but only as a fixture/interop check, never on the code path under test.
Dev setup: git config core.hooksPath .githooks — the pre-commit hook checks
that rust-toolchain.toml's pin satisfies Cargo.toml's rust-version floor,
then runs cargo +nightly fmt (nightly, for the unstable options in
rustfmt.toml), cargo sort-derives (cargo install cargo-sort-derives), and
cargo clippy --workspace --all-targets -- -D warnings.
The Rust version is set in exactly one place: rust-toolchain.toml. It
pins the toolchain and the musl cross target for every cargo invocation here,
and deploy/runner/build.sh parses [toolchain] channel out of it to bake the
same version into anvil-runner:rust — so a CI job and a checkout compile with
the same rustc. Bumping Rust means editing that file and Cargo.toml's floor
(the hook catches you if you forget the second), then rebuilding the image.
Viewing attachments referenced in tasks
TODO items and tickets may embed an uploaded image as
. These bytes live outside git
history, so to actually see one, get the bytes and Read the file. Two ways:
Preferred — over git, no credentials. anvil mirrors every attachment into
refs/anvil/attachments (a flat tree of <hash> → blob, off the branch
namespace so a default pull never drags it down). From a clone, opt in once:
git fetch origin '+refs/anvil/attachments:refs/anvil/attachments'
git cat-file -p "refs/anvil/attachments:<hash>" > /tmp/att && # then Read /tmp/att
This uses the clone's existing git auth — no PAT — and works offline afterward.
Fallback — HTTP with a PAT (no clone, or the ref isn't fetched). Credentials
live in the git-ignored .anvil-credentials at the repo root (ANVIL_BASE_URL +
a read-only ANVIL_TOKEN); source it, then:
curl -fsS -H "Authorization: Bearer $ANVIL_TOKEN" \
"$ANVIL_BASE_URL/{owner}/{repo}/-/attachments/{hash}" -o /tmp/att && # Read it
The PAT is read-only (GET/HEAD only), safe to hold. If neither the ref nor
.anvil-credentials is available, ask the user rather than guessing.
Attachments are often phone screenshots larger than the Read tool's 256KB
cap. Downscale before reading (macOS sips):
sips -Z 1100 -s formatOptions 70 /tmp/att --out /tmp/att-small.jpg # then Read that
note that we can't use portless for this app because of a websocets bug with http/2
Remote runners: where this stands
anvil does not execute CI. Runners dial out, claim jobs, and run them on
their own Docker daemon. Design and rationale: docs/remote-runners.md.
M1 is done: anvil-job (wire format), anvil-docker (connect/ensure_image),
anvil-worker (the runner binary), five endpoints under /-/runner/,
in-memory leases, and no Docker socket on the deployed container.
M2 is done: platform: in .anvil/ci.yml, a [ci] platform default, and
two-tier routing — a claiming runner is offered the runs that match its
architecture (or name none), then the runs no connected runner is native to,
so a lone arm64 Mac still runs linux/amd64 pipelines under Rosetta instead of
stranding them. Dispatch tracks who is connected from claims and heartbeats
(RUNNER_TTL, 5 min); anvil-docker::check_platform fails a job whose image
came back the wrong architecture.
Invariants to not break. These are the reasons the split is shaped the way it is, and each is easy to undo by accident:
- The runner never parses
.anvil/ci.yml. Image resolution, the allowlist check and script assembly happen inbuild_job, so a runner cannot widen what it is permitted to run. store_artifactstays server-side. The runner uploads a raw tar;browsedecides whether that becomes a servable directory tree, which is anvil's call, not a runner's.- A job's secrets live on its lease, not the vault, once dispatched.
Vault::takefails after the repo's unlock TTL lapses, so re-reading at finish time would silently skip log masking on exactly the long runs whose logs most need it. anvil-jobdepends on serde and nothing else. It exists so the runner does not link toasty, SQLite and gix.
Next, in order:
- M3, the deploy agent. Image builds cannot be CI jobs, because job
containers get no Docker socket by design.
docker build --platform linux/amd64plusdocker pushbelongs to a separate process on the build host at a different trust level, triggered by the existingdeploy_webhook. Folding it into the dial-out channel as a privileged "publish" job kind, authorized by theis_deploy_targetcheck that already scopes CD to one repo, would remove the last inbound path to the build host. - CI concurrency. "One job at a time" was a property of the old in-process
loop and is gone. Nothing bounds in-flight jobs now beyond how many runners
exist. Needs a
max_concurrentequivalent — more pressing now that platform routing makes a second runner worth having. - Per-runner credentials. One shared
[ci] runner_tokenmeans one revocation for every runner, nolast_used_at, and any holder can claim any job and receive its secrets. Wants the API-token write scope first (see TODO.md). - Live logs. The result POST is a single write, matching the old behaviour. Streaming needs chunked append with offsets and a UI that tolerates gaps. Newly worth doing now that a producer exists.
Operationally: nothing runs until a runner is started and [ci] runner_token is set; queued runs just sit, with a warning logged at startup.
Setting one up on the Mac mini (build, Rosetta, the launchd agent in
deploy/worker/) is written out in docs/remote-runners.md § Isolation on
macOS. Locally, compose.override.yaml runs two of them in containers
(./deploy/build.sh --debug --worker, then docker compose up -d --build) so
concurrency and routing are testable on one machine; that mount is a
development-only concession, never production's. Who is connected, and what
each is running, is at /-/admin/runners — presence comes from ordinary claims
and heartbeats rather than a ping of its own, so anything watching for a runner
to go quiet has to allow a full claim poll first.
Agent sessions still drive Docker locally and are the one thing the dropped
socket mount gives up. They are off by default.
Current status, resume notes, the agreed next steps (a/b/c), and the roadmap live in the TODO. Read it first: