Elpe — the universal package installer
Elpe borrows from immutable distributions (Nix, Silverblue) and from conventional ones (Debian, Ubuntu) to give you:
- Ephemeral shells with packages from a real distribution, installed only for the life of the shell.
- Isolated, reproducible builds — your code compiles in a container with only its declared inputs in scope.
- Cross-language lockfiles — a single CycloneDX SBOM that doubles as a
lockfile, derived from the native lock your ecosystem already produces
(
uv.lock,Cargo.lock,pnpm-lock.yaml, …). - Bootable images — reproducible qcow2 / installer images from the same recipes.
- macOS artifacts (experimental) — cross-built and signed from Linux, using a real Apple Silicon Mac only as the pinned SDK source and isolation host.
Recipes are ordinary OCaml (fast to compile, sound types, no brain-teasers);
the build backend is Rust. The two talk over gRPC. The store is a flat,
content-addressed /opt/elpe — the same prefix on every platform.
Status: experimental — not for production. Security is a primary goal (see Security), but the surface is still moving.
Quick start
Everything below is one CLI, elpe, resolving a recipe (project.elpe,
build.ml, or shell.ml) in the current directory. See doc/ for the
full guides and examples/ for ~45 runnable recipes.
# Ephemeral shell with a distro package (no install, no daemon needed)
$ elpe shell -p cowsay -c "cowsay 'hello from elpe'"
# Build a C program in strict isolation
$ elpe build examples/hello/build.ml
# Build a Rust crate (rustc from Ubuntu, crates pinned by SHA256)
$ elpe build examples/rust-hello/build.ml
# Derive the CycloneDX lock from a committed native lock — offline, no network
$ elpe lock examples/pypi-native-lock/app.elpe # emits elpe.<target>.lock
# Assemble a signed .deb + a small repo
$ elpe build examples/deb/build.ml
# Build a bootable qcow2 image
$ elpe build examples/vm/build.ml
Verbs: build · lock · update · shell · switch · gc.
elpe -p pkg… is shorthand for elpe shell -p pkg…; elpe file.elpe for
elpe build file.elpe.
The CycloneDX lock is a real lockfile
Elpe’s lock (elpe.<target>.lock) is a CycloneDX SBOM, and it is derived from
the native lockfile your build already consumes — so it cannot drift from it.
Every component carries a pinned source hash. At build time the backend fetches
each source and verifies it against that pin: a source whose bytes no longer
hash to the pin is rejected, never silently substituted.
$ elpe build examples/syft/build.ml
Error: Hash mismatch: expected d2755869…c432, got 9f10…a1b3
→ a pinned hash drifted; re-pin with `elpe update` (build outputs)
or `elpe lock` (sources)
Because outputs are content-addressed too (elpe update records
input-hash → output-hash pins in elpe.<target>.outputs.lock), dependencies
form a Merkle tree: you can check both that a recipe hasn’t changed (like Nix)
and that an output hasn’t changed (like Ubuntu). This exact guarantee is
covered by tests/cli/cyclonedx_lock.bats.
Bootstrap your own Linux
The headline: go from a bare, pristine Ubuntu to a self-hosting Elpe — no Nix,
just qemu + curl + content hashes — then cross-build and sign a native
macOS Elpe from that same Linux. At the end you have the whole toolchain, on both
platforms, reproducibly.
1. Stage 0 — the first elpe, without Nix
The only thing Nix uniquely produced was the first elpe binary needed to run
build.ml (a chicken-and-egg). scripts/seed/ pins a stage0 toolchain by
content hash so that first binary builds from Ubuntu alone. Trust roots: the
Ubuntu archive PGP key (src/ubuntu-archive-keyring.asc, verified in-process by
src/pgp.rs), the toolchain .debs (scripts/seed/toolchain.lock, SHA256), and
the crates (Cargo.lock, SHA256).
Push-button, in a pristine VM (recommended):
# Any KVM-capable Linux host with qemu + cloud-image-utils + pijul:
scripts/seed/test-stage0-vm.sh
It fetches and verifies a pinned Ubuntu cloud image (vm.lock), boots it
headless under QEMU with -snapshot (the base is never mutated), mounts a
read-only clone of the repo over 9p, and runs stage0 inside the guest:
curl+ verify the pinned toolchain (rustc/cargo/gcc/clang/protoc) from the signed Ubuntu suite;cargo buildthe Rust backendelped(crates pinned by SHA256) — the only time cargo is used as the compiler;- if the OCaml deps are present, build the
elpefrontend, then run Phase B —elpe build build.mltwice — and assert the rebuilt daemon is bit-for-bit reproducible.
Manually, on a clean Ubuntu box:
scripts/seed/extract-hashes.sh # derive crates.lock from Cargo.lock (no build)
scripts/seed/bootstrap-seed.sh # fetch+verify toolchain, build stage0, close the loop
After stage0, Elpe rebuilds itself from its own recipe — cargo is never the
compiler again (rust.ml calls rustc directly):
elpe build project.elpe # the elpe binary, built by elpe
2. Cross-compile for macOS — from that same Linux
Elpe never emulates macOS and never vendors Apple software. A real Apple Silicon
Mac is used only as (a) the pinned SDK source — a one-time SSH fetch of the
non-redistributable MacOSX.sdk, streamed into the store and hash-verified — and
(b) an isolation VM host. Mach-O relocation itself runs on Linux
(machoedit). See doc/macos/darwin.md.
# arm64 macOS target, cross-linked from Linux against the pinned SDK
ELPE_TARGET_OS=darwin elpe build examples/darwin-elpe-bundle/build.ml
3. Sign it
Builds are ad-hoc signed by default (reproducible); a Developer-ID signature
is a later, distribution-only step. Either the Mac signs (codesign -f -s -) or
Linux does, cross, via rcodesign — both proven paths. examples/darwin-elpe-pkg
wraps the result as a native .pkg whose postinstall wires
/usr/local/bin/elpe:
ELPE_TARGET_OS=darwin elpe build examples/darwin-elpe-pkg/build.ml
4. …and then you have everything
A Linux Elpe, bootstrapped from Ubuntu with nothing but qemu + curl + hashes,
that reproducibly builds and signs a native macOS Elpe. The store prefix
(/opt/elpe), the output hashes, and the recipes are identical across both — the
same Merkle-pinned closure, end to end.
Testing
One orchestrator runs every layer the machine supports and skips (never fails) the ones whose tooling is absent:
scripts/run-tests.sh # dune + cargo + bats + doctest, offline
ELPE_ONLINE=1 scripts/run-tests.sh # also real-build / fetch-integrity tests
| Layer | Command | Needs |
|---|---|---|
| OCaml recipe library | dune runtest (in elpe/) | dune — offline |
| Rust unit + integration | cargo test | cargo — offline (macOS/net tests gated) |
| CLI smoke battery | bats tests/cli | bats + an elpe binary |
| Doc examples | doc/doctest.py | python3 + an elpe binary |
The CLI battery (tests/cli/) locates a binary via $ELPE (or
common build outputs) and skips cleanly when none is built — bootstrapping is
a prerequisite, not a test failure. The offline lock_offline.bats is the
cheapest real coverage (recipe compile → sandboxed OCaml VM → emit lock, no
daemon); cyclonedx_lock.bats and online.bats are ELPE_ONLINE-gated.
How it fits together
- Frontend (
elpe,eval-vm/cli): a Rust host embedding a sandboxed OCaml bytecode VM. It compiles yourbuild.mlat runtime and evaluates it with no ambient I/O — only curated host callbacks. - Backend (
elped,src/): a Rust gRPC server that runs each build in a Linux namespace/cgroup container against the/opt/elpestore. macOS builds run viaelpe-darwin(native) or a Virtualization.framework guest. - Store: flat, content-addressed
/opt/elpe/<blake3>— portable, shared across farm peers, identical prefix on every platform.
Project layout
| Path | What |
|---|---|
eval-vm/ | the OCaml-VM frontend CLI (elpe) |
src/ | the Rust backend daemon + build/container/store/macOS logic |
elpe/lib/ | the OCaml recipe library (the DSL: ubuntu, cargo, bazel, Image, Lock, …) |
elpe/test/ | OCaml recipe-library tests (dune runtest) |
elfedit/, machoedit/ | ELF / Mach-O relocation (binary patching) |
pkgedit/ | pure-Rust .pkg writer |
elpe-sched/ | build scheduler + CPU placement |
examples/ | ~45 runnable recipes (see examples/) |
scripts/seed/ | the Nix-free stage0 bootstrap (qemu + curl) |
tests/cli/ | the BATS CLI smoke battery |
doc/ | user + internals documentation (doctest.py-checked) |
Design notes
- We don’t bootstrap the whole system. Low-level packages come from existing distributions — mix-and-match versions and even distros. Bootstrapping the world is elegant but expensive to secure and maintain; outsourcing the base to Debian/Ubuntu lets us lean on external auditing while keeping your code in strict, reproducible isolation.
- OCaml for recipes. Of the languages we prototyped (Haskell, OCaml, Rust, Scala, TypeScript, Luau), OCaml alone gave us fast compilation, a sound and unexciting type system (no brain-teasers), bytecode we can embed and sandbox, and nominal types for strict configuration options.
- Separation of concerns. The primitive that pulls a distro package is deliberately hard to use for modifying the distro — so a security audit can cleanly separate your code from the distribution’s.
Security
Reproducibility is part of security, not the whole story. Elpe packages form a Merkle tree of dependencies, so both recipes (like Nix) and outputs (like Ubuntu) are verifiable. Builds are code running in an explicit, isolated container. And by outsourcing base-repository security to conventional distributions, audits can rely on external providers without giving up reproducibility.
Contributing
We intend the Elpe community to be inclusive and open, and enforce the Citizen Code of Conduct. Areas that want help: fetchers (tarball/Git imports), language-package-manager interfaces, documentation tooling, cross-language error handling, and a reproducible bootable ISO from Ubuntu packages.
The name
Elpe is the back-transliteration of the Franglais pronunciation of «Help» (Franglais quotes intended) — a tribute to our universal duty of always elping each other out.