Anvil is a continuous integration runner for a single machine with a container runtime. It reads a pipeline written in YAML, resolves the dependency graph between steps, and runs each step in its own container. Steps that do not depend on each other run in parallel; a step that fails takes its dependents down with it while the rest of the graph keeps moving. Logs stream live to a panel and to the command line as the run happens.
Two binaries: anvil runs the daemon, anvilctl drives it. It speaks the Docker Engine API over the runtime
socket, so the only dependency outside the standard library is a YAML parser.
$ anvilctl run
run d1aabb227177 started
checkout │ step passed
build │ compiling ledger-service
lint │ no findings
build │ step passed
integration │ step failed with exit 1, tolerated
unit │ ok 142 passed
package │ packaged ledger-service:demo
publish │ published
anvil │ pipeline passed in 19.503s
run d1aabb227177 passedThe panel draws the same run as an engineering schematic. Steps are laid out by stage, dependencies are drawn as dimension-line connectors, and each node lights up as its container starts, passes, or fails.
- A trigger arrives from the panel or
anvilctl. Anvil loads the pipeline and builds the dependency graph. - The graph is validated. Self-edges, references to unknown steps, and cycles are rejected before anything runs — the offending cycle path is named in the error.
- A per-run workspace volume is created and seeded once from the source tree.
- The scheduler runs each step as soon as its dependencies pass, bounded by a worker pool. Independent steps run at the same time.
- Each step runs in its own container mounting the shared workspace and a cache volume per cache entry. The exit code comes from waiting on the container; output streams to the run log.
- A failed step marks its dependents skipped and the skip cascades. A step may be allowed to fail without failing the run.
Full detail and a component diagram are in docs/architecture.md.
A pipeline is a set of named steps. Each step names an image and a script; needs wires the graph. Top-level
image and env are defaults so common values are not repeated.
name: ledger-service
image: golang:1.25-alpine
env:
CI: "true"
steps:
build:
cache:
- key: modules
path: /go/pkg/mod
hash: [go.sum]
run: go build ./...
lint:
run: go vet ./...
test:
needs: [build]
memory: 512m
cpus: 1.5
run: go test ./...
integration:
needs: [build]
allow_failure: true
run: ./scripts/integration.sh
package:
needs: [test, lint]
run: ./scripts/package.sh| Field | Meaning |
|---|---|
image |
container image the step runs in; falls back to the top-level image |
run |
shell script executed with /bin/sh -c inside the workspace |
needs |
step ids this step depends on |
env |
environment for the step, merged over the top-level env |
cache |
key, path, and hash — the cache key is derived from the contents of the hashed files |
memory / cpus |
resource limits applied to the step container |
allow_failure |
a non-zero exit is tolerated and does not fail the run |
shell / workdir |
override the shell (/bin/sh) or working directory (/workspace) |
The cache key is content-addressed. It is derived from the contents of the files named in hash, never from
their path, so an unchanged dependency set reuses the cache and a changed one rotates it. The key names a
runtime volume, so persistence across runs is owned by the container runtime.
Build the binaries:
$ make buildRun the daemon against a source directory that holds an .anvil.yml:
$ ./bin/anvil -config configs/anvil.yamlThe panel is served on the configured address; anvilctl talks to the same endpoint.
$ anvilctl status
$ anvilctl pipeline
$ anvilctl runThe demo stack runs Anvil in a container against the host runtime socket and forges a sample eight-step pipeline end to end, including a tolerated failure.
$ make demo # panel on http://127.0.0.1:18110
$ make demo-downThe screenshots above are captured from this demo. The command-line session:
| Command | Purpose |
|---|---|
anvilctl status |
daemon version, runtime reachability, and run tally |
anvilctl pipeline |
the current pipeline grouped by stage |
anvilctl runs |
run history |
anvilctl run |
trigger a run and follow its log to completion, exiting non-zero on failure |
anvilctl logs <id> |
follow the log of a specific run |
anvilctl reads ANVIL_ADDR and ANVIL_TOKEN, or takes -addr and -token.
server:
listen: "0.0.0.0:8080"
state_file: /var/lib/anvil/state.json
admin_token: "${ANVIL_TOKEN}"
ui: true
run:
source_dir: /var/lib/anvil/source
pipeline_file: .anvil.yml
concurrency: 2
network: ""
keep_runs: 20
label: anvil
runtime:
socket: /var/run/docker.sock
logging:
level: info
format: json${VAR} references are expanded from the environment. anvil -config <path> -check validates a configuration
and exits. When admin_token is set, the API and panel require it as a bearer token.
$ make test # unit tests
$ make race # tests under the race detector
$ make lint # golangci-lint: errcheck, govet, staticcheck, gosec, and more
$ make cover # coverage summaryThe graph, scheduler, and cache carry the load-bearing tests: cycle detection and deterministic ordering, skip propagation and the concurrency limit, and content-addressed keys that ignore paths and refuse to reach outside the source tree.


