repolith
Declarative orchestrator for Rust toolchains spread across multiple sibling git repositories.
โก Parallel ยท ๐ Cancellation-aware ยท ๐พ Cache-first
repolith reads one repolith.toml describing remote repositories and the
actions to run against them โ git clone, cargo install, docker build โ then
executes the plan in parallel layers, with content-addressed caching
so untouched work never re-runs and a shared CancellationToken so
Ctrl-C cleanly aborts every in-flight subprocess.
What it looks like
Given a manifest:
[]
= "0.1"
= "my-stack"
[[]]
= "shared-types"
= "https://github.com/example-org/shared-types"
= "../libs/shared-types"
[[]]
= "git-clone"
[[]]
= "migration-tool"
= "https://github.com/example-org/migration-tool"
= "../tools/migration-tool"
[[]]
= "git-clone"
[[]]
= "cargo-install"
= "migrate"
= "~/.local/bin"
You preview, then sync:
$ repolith status
+-------------------------------+--------+---------------+
| Action | Status | Reason |
+===============================+========+===============+
| shared-types::git-clone::0 | stale | NoCachedBuild |
| migration-tool::git-clone::0 | stale | NoCachedBuild |
| migration-tool::cargo-install | stale | NoCachedBuild |
+-------------------------------+--------+---------------+
$ repolith sync --dry-run --explain
โข shared-types::git-clone::0: NoCachedBuild
โข migration-tool::git-clone::0: NoCachedBuild
โข migration-tool::cargo-install::1: NoCachedBuild
dry-run: 3 action(s) would run
$ repolith sync
OK shared-types::git-clone::0 (55 ms)
OK migration-tool::git-clone::0 (188 ms)
OK migration-tool::cargo-install::1 (4.2 s)
$ repolith sync
up to date โ 0 stale actions
The second sync is a no-op โ the cache holds last-run input hashes
per action, so nothing re-runs unless something actually changed. What
counts as "changed": the upstream HEAD, the content of a local
path source (edits are seen even uncommitted), a cargo feature, the
toolchain, the build platform โ or the artifact going missing from this
machine. That last one matters with a shared cache backend: another
machine having built something is not a reason to skip building it here.
Why use it
- One declarative file. Everything your stack needs to bootstrap, in
repolith.toml. No bash glue, no per-machine README dance. - Parallel by default.
tokio::FuturesUnordered+Semaphorecap concurrency at--jobs N(default =num_cpus). Layer N+1 starts only when layer N settles โ typed dependencies, no race conditions. - Cancels cleanly. A shared
CancellationTokenplumbed through every action'sCtx. First failure in--fail-fast(default) cancels in-flight peers;--keep-goinglets the layer settle then halts. On Unix the subprocess process group is signalled (SIGTERM โ grace โ SIGKILL) so cargo'srustc/ linker grandchildren get reaped too. - Cache-first, and honest about it. Every successful build writes a
BuildEventkeyed by a content-addressed input hash โ for localpathsources that means the tree's actual bytes (.gitignore-aware,target/excluded, mtimes ignored so a fresh clone doesn't look stale). Before trusting a cache hit the planner also checks the artifact is still here. Re-runs are near-instant when nothing changed, and never skipped when something did. - Hardened argv. URLs validated against a scheme allowlist with
nested-userinfo / host / path-segment leading-dash checks;
--argv separator before every user URL as defense in depth; crate names + feature flags rejected if they could break cargo's--featureslist.
What it is NOT
The negative scope is fixed and will not evolve:
- Not a CI runner โ no distributed execution, no remote workers.
- Not a toolchain manager โ
rustupis fine. - Not a package manager โ
cargois fine. - Not a process supervisor โ
systemd/launchd/docker composeare fine; repolith reads heartbeats, doesn't write them. - Not a monorepo tool โ
cargo workspacesalready covers single-repo workspace publishing. - Not a hermetic build system โ hermeticity is opt-in per action, not the default.
Quick start
# 1. Install the binary from crates.io
# 2. Write a manifest in your stack root (see the reference below,
# or start from repolith.toml.example)
&&
# 3. Preview what would happen
# 4. Go
Building from source works too: git clone https://github.com/anatta-rs/repolith && cargo build --release.
repolith status prints a cache hit/miss table without running anything.
repolith sync -k keeps a layer running after a failure (useful for
surfacing every failure of a layer in one pass).
See repolith.toml.example for a full annotated
manifest.
Manifest reference
A manifest is one [orchestrator] block plus any number of [[node]]
blocks; each node carries the actions to run against it, in order.
[orchestrator]
| Field | Required | Description |
|---|---|---|
schema_version |
yes | Manifest schema. Current requirement: ~0.1 (e.g. "0.1"). |
name |
yes | Human-readable stack name, shown in logs. |
[[node]]
| Field | Required | Description |
|---|---|---|
id |
yes | Unique node id. Also the default crate name for cargo-install, and the prefix of every action id ({id}::{kind}::{index}). |
git |
per action | Source URL (https://, ssh://, or git@host:path). Source for git-clone. |
path |
per action | Local checkout directory, relative to the manifest. Destination for git-clone, source tree for cargo-install. |
Action kinds
kind = "git-clone" โ fetch the node's source into path. No fields of
its own:
[[]]
= "git-clone"
kind = "cargo-install" โ cargo install from the node's source tree.
Every field is optional:
[[]]
= "cargo-install"
= "migrate" # default: the node's `id` โ the BINARY name
= "migration-tools" # default: none โ see below
= "dev" # default: none โ cargo's default, release
= ["postgres", "tls"] # default: none
= "~/.local/bin" # default: ~/.repolith/bin (`~` expands at run time)
crate is the binary target name, which often differs from the package
that contains it (this repo installs the binary repolith from the package
repolith-cli).
package selects which package to build when the source holds more than
one โ common for git repositories shipping test fixtures or a workspace of
tools, where cargo refuses to guess:
error: multiple packages with binaries found: โฆ
Please specify a package, e.g. `cargo install --git <url> bin_only`
Leave it out unless you hit that error; omitting it lets cargo resolve on its own, which is right for single-package sources.
profile picks the cargo profile (--profile) โ dev, or any profile your
Cargo.toml defines. Useful for keeping debug symbols, or for a custom
profile such as one with thin LTO.
Do not expect a large build-time win from dev. Measured on this repository,
cold and without a compiler cache: 26.8 s release against 25.0 s dev โ 7 %,
for a binary 3.5ร larger (11 MB โ 39 MB) and 1.8ร more build
artifacts. Crates whose build is dominated by optimisation will differ; many
are dominated by C dependencies and proc macros, which the profile does not
touch. Measure before assuming.
Switching profiles re-installs: the profile is part of the input hash, since both land the binary at the same path and nothing else could tell them apart.
kind = "docker" โ docker build an image from the node's checkout
(build-only: running containers stays out of scope, see
What it is NOT). Requires path on the node; tag is
the only required field:
[[]]
= "docker"
= "my-org/app:latest" # required โ docker reference charset only
= "build/Dockerfile" # default: Dockerfile (relative to context)
= "build" # default: the node's `path`
dockerfile and context must stay inside the node's checkout: relative
paths only, no .., validated at parse time and re-checked after symlink
resolution at build time.
kind = "repolith" โ federation: the node's checkout contains its own
repolith.toml, executed as a nested plan (orchestrator-of-orchestrators).
Requires path on the node; the one field is optional:
[[]]
= "repolith"
= "repolith.toml" # default โ relative to the node's `path`
The child stack keeps its own local cache
(<stack>/.repolith/cache.db), exactly as if you had run repolith sync
in that directory. Guard rails: manifest cycles (A -> B -> A) are
rejected with the offending chain, federation depth is capped at 8,
--jobs N bounds the whole tree (one global pool, never N per level),
and Ctrl-C cancels every level down to the subprocess groups. The
manifest path obeys the same two-stage containment as docker's paths.
Actions on the same node run in declaration order; independent nodes run in parallel.
Published version vs working copy
Two things you may want from the same stack: install what is released, or
install what you are editing right now. repolith has no --dev flag for
this โ the distinction is which manifest you name, so it stays visible at the
call site instead of hidden in a mode:
This repository ships both as a worked example. The pair usually differs in
two places: path (a checkout repolith owns, versus your workspace) and
profile (release versus dev). Switching between them rebuilds once โ
expected, since the profile is part of the input hash.
One trap worth stating: a node with a git-clone action resets its
checkout (git fetch + git reset --hard). Never point such a node at a
directory you work in; keep those under something like ~/.repolith/src and
let the dev manifest be the one that reads your workspace.
Faster rebuilds with sccache
repolith runs cargo install, and cargo builds each install in a throwaway
directory โ so nothing is reused between syncs out of the box. A shared
compiler cache fixes that, and the difference is not subtle. Measured on a
small binary crate: 7.4 s cold, 1.1 s warm.
sccache is the simplest way there:
# ~/.cargo/config.toml โ applies to every cargo invocation, repolith included
[]
= "sccache"
# sccache cannot cache incremental compilation; leave it off or it silently
# stops helping.
[]
= false
Cap the cache so it cannot grow without bound (SCCACHE_CACHE_SIZE="10G"),
and know the limits before expecting miracles: crates with procedural macros
are not cacheable, so the Rust hit rate is always partial. sccache --show-stats reports per-server-session counters that reset when the server
idles out โ the on-disk cache survives, only the numbers restart.
Cache backends
The build cache is pluggable (Cache trait in repolith-core). Two
backends ship today; --cache (or REPOLITH_CACHE) selects one:
| Backend | Select with | Storage | When |
|---|---|---|---|
sqlite (default) |
โ | local file, ~/.repolith/cache.db (--cache-path) |
single machine โ zero config |
neo4j |
--cache neo4j |
shared server, build events as graph data | multi-machine / federated stacks |
The Neo4j backend reads REPOLITH_NEO4J_URI, REPOLITH_NEO4J_USER, and
REPOLITH_NEO4J_PASS from the environment โ credentials never live in
repolith.toml, and these variables are never forwarded to spawned
subprocesses. Schema: one (:Action {id}) node per action with a LAST
relationship to its most recent (:BuildEvent); layer writes are one
transaction. NamespacedCache (library-level) lets multiple stacks share
one server without id collisions.
Architecture
5 crates, layered execution with FuturesUnordered + CancellationToken +
Semaphore. Full diagram + the FailFast / KeepGoing sequence diagrams
- design decisions live in
ARCHITECTURE.md.
| Crate | Purpose |
|---|---|
repolith-core |
Types, traits (Action, Cache), manifest parser, layered Plan. |
repolith-cache |
SqliteCache (rusqlite, bundled, WAL), Neo4jCache (feature neo4j), NamespacedCache. |
repolith-engine |
Async Orchestrator with cancellation + semaphore. |
repolith-actions |
GitClone (feature git), CargoInstall (feature cargo), DockerBuild (feature docker). |
repolith-cli |
repolith sync / status โ the binary you run. |
Status
M2 complete. 5 crates, 4 action kinds (git-clone, cargo-install,
docker, repolith federation), 2 cache backends (SQLite default,
Neo4j opt-in with a live-server contract suite in CI), parallel layered
execution with tree-wide cancellation, URL-injection + path-traversal
hardening. Full test suite under
cargo test --workspace --all-features (110+ tests at last count). The
crates.io badge above always shows the current published version; see
CHANGELOG.md for the per-release breakdown. All 5
crates are published on
crates.io; releases are automated
with release-plz โ merging the release PR
publishes, tags, and cuts the GitHub Release.
Roadmap
- M2 โ โ
complete:
dockeraction, federationkind = "repolith", Neo4j cache backend. - M3 โ watch mode (re-plan on file change),
template_applyaction drivingAttachedEntry::Outbound.
Security
See SECURITY.md for the threat model, the env-allowlist
policy, and the GitHub Security Advisories private-reporting form for
vulnerability disclosure.
Contributing
PRs welcome. See CONTRIBUTING.md for the dev setup,
local CI gates, and worked recipes for adding a new action or a new cache
backend.
License
Dual-licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.