diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e2f59ae..4280ef3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,17 +38,27 @@ jobs:
cache-dependency-path: site/pnpm-lock.yaml
- run: ./scripts/task check
+ static:
+ name: static
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ # musl-gcc compiles mimalloc's C for the musl target.
+ - run: sudo apt-get install -y --no-install-recommends musl-tools
+ - run: ./scripts/task build:static
+
# The one status check branch protection requires, so adding a job never means
# editing a protection rule.
required:
name: required
if: always()
- needs: [check]
+ needs: [check, static]
runs-on: ubuntu-latest
steps:
- name: Every job succeeded
run: |
- if [ "${{ needs.check.result }}" != "success" ]; then
- echo "check: ${{ needs.check.result }}"
+ if [ "${{ needs.check.result }}" != "success" ] || [ "${{ needs.static.result }}" != "success" ]; then
+ echo "check: ${{ needs.check.result }}, static: ${{ needs.static.result }}"
exit 1
fi
diff --git a/Cargo.lock b/Cargo.lock
index cc3e59c..67bfc14 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,57 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "cc"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
[[package]]
name = "liam"
version = "0.0.0"
+
+[[package]]
+name = "liam-init"
+version = "0.0.0"
+
+[[package]]
+name = "liamd"
+version = "0.0.0"
+dependencies = [
+ "mimalloc",
+]
+
+[[package]]
+name = "libmimalloc-sys"
+version = "0.1.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "mimalloc"
+version = "0.1.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
+dependencies = [
+ "libmimalloc-sys",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
diff --git a/Cargo.toml b/Cargo.toml
index 0b70465..02bcaaf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,3 +9,12 @@ rust-version = "1.98"
license = "MIT OR Apache-2.0"
repository = "https://github.com/oddurs/liam"
authors = ["Oddur Sigurdsson"]
+
+# Image binaries are built once and booted many times, so build time is spent
+# freely for speed and size.
+[profile.release]
+opt-level = 3
+lto = "fat"
+codegen-units = 1
+panic = "abort"
+strip = true
diff --git a/ROADMAP.md b/ROADMAP.md
index 09e94d1..c337d2d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -29,13 +29,12 @@ Releases ship when their gate passes, not on a date.
## v0.1 — Static sites in a microVM
-`··········` 0% · 0 of 24 done
+`#·········` 4% · 1 of 24 done
Serve a static site from a Firecracker microVM over HTTP/1.1, from cold boot to first byte inside the boot budget, measured in CI. For sites behind a load balancer or CDN that already terminates TLS.
### committed
-- [ ] `0012` Scaffold the Cargo workspace and the task seam chore · p0 · s · infra
- [ ] `0013` CI: format, lint and test every pull request chore · p0 · s · infra
- [ ] `0014` How fast can a stripped Linux LTS kernel boot in Firecracker? spike · p0 · m · kernel
- [ ] `0015` Which io_uring runtime does liamd build on? spike · p0 · m · io
@@ -63,6 +62,10 @@ Serve a static site from a Firecracker microVM over HTTP/1.1, from cold boot to
- [ ] `0035` Project microsite and design system feature · p2 · m · site
+### done
+
+- [x] `0012` Scaffold the Cargo workspace and the task seam chore · p0 · s · infra
+
## v0.2 — HTTPS and containers
`··········` 0% · 0 of 15 done
diff --git a/cairn/items/0012-scaffold-the-cargo-workspace-and-the-task-seam.md b/cairn/items/0012-scaffold-the-cargo-workspace-and-the-task-seam.md
index 2f24a5c..5a370bf 100644
--- a/cairn/items/0012-scaffold-the-cargo-workspace-and-the-task-seam.md
+++ b/cairn/items/0012-scaffold-the-cargo-workspace-and-the-task-seam.md
@@ -3,10 +3,12 @@ id: 12
uid: 10481a9f-b883-4165-bd4f-a3236d394e11
title: Scaffold the Cargo workspace and the task seam
type: chore
-status: planned
+status: done
milestone: v0.1
+assignee: Oddur Sigurdsson
created: 2026-09-28
updated: 2026-09-28
+closed_at: 2026-09-28
priority: p0
area: infra
effort: s
@@ -27,7 +29,31 @@ Nothing can be built, tested or gated until the repository has its shape. Every
## Acceptance criteria
-- [ ] `scripts/task check` passes on the empty workspace.
-- [ ] A deliberate clippy warning makes `scripts/task lint` exit non-zero.
-- [ ] `file` reports the release `liamd` and `liam-init` binaries as statically linked.
-- [ ] Both license files are present, and every crate declares `MIT OR Apache-2.0`.
+- [x] `scripts/task check` passes on the empty workspace.
+- [x] A deliberate clippy warning makes `scripts/task lint` exit non-zero.
+- [x] `file` reports the release `liamd` and `liam-init` binaries as statically linked.
+- [x] Both license files are present, and every crate declares `MIT OR Apache-2.0`.
+
+## 2026-09-28
+
+Library crates (liam-io, liam-http, liam-pack, liam-config) are not created here: empty crates are dead scaffolding, and each has an item (0017, 0018, 0019, 0020) that creates it with real code. This item builds the two image binaries the static pipeline needs.
+
+## 2026-09-28
+
+mimalloc is the global allocator in liamd only. liam-init barely allocates, and its size budget is 300 KB; the CLI runs on developer machines. The release profile also sets strip = true for the size budget.
+
+## 2026-09-28
+
+Static builds are a separate verb, scripts/task build:static, run by a 'static' CI job on Linux (musl-tools provides musl-gcc for mimalloc's C). It fails unless file(1) reports each binary as statically linked or static-pie linked, and prints each binary's size. It refuses on macOS rather than half-working.
+
+## 2026-09-28
+
+Evidence. 1: scripts/task check green on macOS. 2: a planted clippy::len_zero made scripts/task lint exit 101; removing it, 0. 4: LICENSE-MIT and LICENSE-APACHE present; cargo metadata shows liam, liam-init and liamd all MIT OR Apache-2.0.
+
+## 2026-09-28
+
+Evidence. 3: CI run 36514087437, static job: liamd 543,400 bytes and liam-init 397,880 bytes, both 'ELF 64-bit LSB pie executable, x86-64, static-pie linked, stripped'. Static-pie is fully static: there is no interpreter.
+
+## Result
+
+The workspace has liam (CLI), liamd and liam-init. The release profile is fat LTO, one codegen unit, panic=abort, stripped. scripts/task build:static builds static-pie musl binaries and CI's static job rejects anything dynamic. Baseline sizes: liamd 543 KB, liam-init 398 KB. Library crates are created by the items that fill them (0017-0020), not here.
diff --git a/cairn/items/0023-liam-init-pid-1-in-the-microvm.md b/cairn/items/0023-liam-init-pid-1-in-the-microvm.md
index 9d1e917..351f882 100644
--- a/cairn/items/0023-liam-init-pid-1-in-the-microvm.md
+++ b/cairn/items/0023-liam-init-pid-1-in-the-microvm.md
@@ -41,3 +41,7 @@ A small static Rust binary that:
- [ ] A test shows an orphaned process is reaped.
- [ ] Firecracker's `SendCtrlAltDel` powers the VM off cleanly.
- [ ] The stripped binary is under 300 KB.
+
+## 2026-09-28
+
+Starting point from 0012: a liam-init that only prints its version is already 397,880 bytes (x86_64 musl, static-pie, fat LTO, stripped, panic=abort). The 300 KB criterion needs more than the shared release profile: opt-level = "z" for this crate, avoiding std::fmt, or a per-package profile. Measure with scripts/task build:static.
diff --git a/crates/liam-init/Cargo.toml b/crates/liam-init/Cargo.toml
new file mode 100644
index 0000000..e649c22
--- /dev/null
+++ b/crates/liam-init/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "liam-init"
+description = "PID 1 for liam images"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+publish = false
diff --git a/crates/liam-init/src/main.rs b/crates/liam-init/src/main.rs
new file mode 100644
index 0000000..4ac4119
--- /dev/null
+++ b/crates/liam-init/src/main.rs
@@ -0,0 +1,16 @@
+use std::process::ExitCode;
+
+// No custom allocator: PID 1 barely allocates, and every byte counts against the
+// size budget.
+fn main() -> ExitCode {
+ match std::env::args().nth(1).as_deref() {
+ Some("--version" | "-V") => {
+ println!("liam-init {}", env!("CARGO_PKG_VERSION"));
+ ExitCode::SUCCESS
+ }
+ _ => {
+ eprintln!("liam-init: nothing to start yet; see ROADMAP.md");
+ ExitCode::from(1)
+ }
+ }
+}
diff --git a/crates/liam-init/tests/cli.rs b/crates/liam-init/tests/cli.rs
new file mode 100644
index 0000000..69a9a83
--- /dev/null
+++ b/crates/liam-init/tests/cli.rs
@@ -0,0 +1,23 @@
+use std::process::Command;
+
+fn run(args: &[&str]) -> std::process::Output {
+ Command::new(env!("CARGO_BIN_EXE_liam-init"))
+ .args(args)
+ .output()
+ .expect("the liam-init binary runs")
+}
+
+#[test]
+fn version_prints_the_crate_version() {
+ let out = run(&["--version"]);
+ assert!(out.status.success());
+ assert_eq!(
+ String::from_utf8_lossy(&out.stdout).trim(),
+ format!("liam-init {}", env!("CARGO_PKG_VERSION"))
+ );
+}
+
+#[test]
+fn without_arguments_it_refuses_to_pretend() {
+ assert_eq!(run(&[]).status.code(), Some(1));
+}
diff --git a/crates/liamd/Cargo.toml b/crates/liamd/Cargo.toml
new file mode 100644
index 0000000..86390da
--- /dev/null
+++ b/crates/liamd/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "liamd"
+description = "The liam web server"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+publish = false
+
+[dependencies]
+mimalloc = "0.1.52"
diff --git a/crates/liamd/src/main.rs b/crates/liamd/src/main.rs
new file mode 100644
index 0000000..ebbf84e
--- /dev/null
+++ b/crates/liamd/src/main.rs
@@ -0,0 +1,19 @@
+use std::process::ExitCode;
+
+// musl's allocator serialises on a global lock, which a thread-per-core server
+// would contend on constantly.
+#[global_allocator]
+static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
+
+fn main() -> ExitCode {
+ match std::env::args().nth(1).as_deref() {
+ Some("--version" | "-V") => {
+ println!("liamd {}", env!("CARGO_PKG_VERSION"));
+ ExitCode::SUCCESS
+ }
+ _ => {
+ eprintln!("liamd: nothing to serve yet; see ROADMAP.md");
+ ExitCode::from(1)
+ }
+ }
+}
diff --git a/crates/liamd/tests/cli.rs b/crates/liamd/tests/cli.rs
new file mode 100644
index 0000000..a269f27
--- /dev/null
+++ b/crates/liamd/tests/cli.rs
@@ -0,0 +1,23 @@
+use std::process::Command;
+
+fn run(args: &[&str]) -> std::process::Output {
+ Command::new(env!("CARGO_BIN_EXE_liamd"))
+ .args(args)
+ .output()
+ .expect("the liamd binary runs")
+}
+
+#[test]
+fn version_prints_the_crate_version() {
+ let out = run(&["--version"]);
+ assert!(out.status.success());
+ assert_eq!(
+ String::from_utf8_lossy(&out.stdout).trim(),
+ format!("liamd {}", env!("CARGO_PKG_VERSION"))
+ );
+}
+
+#[test]
+fn without_arguments_it_refuses_to_pretend() {
+ assert_eq!(run(&[]).status.code(), Some(1));
+}
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
index ef662bf..fcecd53 100644
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,3 +1,5 @@
[toolchain]
channel = "1.98.1"
components = ["clippy", "rustfmt"]
+# The images are static musl binaries; scripts/task build:static needs this.
+targets = ["x86_64-unknown-linux-musl"]
diff --git a/scripts/task b/scripts/task
index 3f8ceb4..600b8de 100755
--- a/scripts/task
+++ b/scripts/task
@@ -31,6 +31,7 @@ usage: scripts/task
lint lint; warnings are errors
test the full test suite
build compile everything, including the tests
+ build:static release musl binaries for the image; fails unless static (Linux)
check fmt:check, lint, test, build — what CI runs
USAGE
@@ -56,6 +57,28 @@ case "${1:-}" in
cargo build --all-targets
site run --silent build
;;
+ build:static)
+ # What goes into an image: release builds for musl, which must be static
+ # because the image has no dynamic linker. Linux only; CI runs it there.
+ target=x86_64-unknown-linux-musl
+ if [ "$(uname -s)" != Linux ]; then
+ printf 'scripts/task build:static: needs Linux with musl-gcc; CI runs it\n' >&2
+ exit 1
+ fi
+ cargo build --release --locked --target "$target" -p liamd -p liam-init
+ for bin in liamd liam-init; do
+ path="target/$target/release/$bin"
+ kind=$(file -b "$path")
+ case "$kind" in
+ *'statically linked'* | *'static-pie linked'*) ;;
+ *)
+ printf '%s is not static: %s\n' "$bin" "$kind" >&2
+ exit 1
+ ;;
+ esac
+ printf '%-10s %8s bytes %s\n' "$bin" "$(wc -c <"$path" | tr -d ' ')" "$kind"
+ done
+ ;;
check)
step 'format'
"$0" fmt:check