Advanced: Rust CI Pipeline with StageX
Target audience: Developers who completed the Quick Start Time to complete: ~15 minutes Goal: Build a real Rust tool through a production-quality CI pipeline with formatting, linting, testing, and reproducible builds.
What You'll Build
mini-ls — a minimal ls clone in ~70 lines of Rust. It lists directory contents, handles errors properly, and sorts output alphabetically. More importantly, you'll set up a multi-stage CI pipeline that enforces code quality before producing a final 790 KB static binary in a FROM scratch image.
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐
│ check │ → │ test │ → │ build │ → │ scratch │
│ fmt │ │ cargo │ │ release │ │ binary │
│ clippy │ │ test │ │ static │ │ 790 KB │
└─────────┘ └─────────┘ └─────────┘ └──────────┘
This pattern mirrors real CI/CD pipelines (GitHub Actions, GitLab CI, etc.) — but runs locally in a single podman build command.
Prerequisites
- Completed the Quick Start: Your First Reproducible Build tutorial
- Podman installed
Step 1: Create the Project
mkdir mini-ls && cd mini-ls
cat > Cargo.toml << 'EOF'
[package]
name = "mini-ls"
version = "0.1.0"
edition = "2021"
EOF
mkdir src
The Code
Create src/main.rs:
use std::env;
use std::fs;
use std::io;
use std::path::Path;
fn main() {
let args: Vec<String> = env::args().collect();
let paths: &[String] = &args[1..];
if paths.is_empty() {
if let Err(e) = list_dir(".") {
eprintln!("mini-ls: .: {}", e);
std::process::exit(1);
}
return;
}
let mut exit_code = 0;
for path in paths {
if let Err(e) = list_dir(path) {
eprintln!("mini-ls: cannot access '{}': {}", path, e);
exit_code = 1;
}
}
std::process::exit(exit_code);
}
fn list_dir<P: AsRef<Path>>(path: P) -> io::Result<()> {
let path = path.as_ref();
if !path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no such file or directory",
));
}
if path.is_dir() {
let mut names: Vec<String> = fs::read_dir(path)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
names.sort();
for name in &names {
println!("{}", name);
}
} else {
println!("{}", path.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
#[test]
fn test_list_current_dir() {
assert!(list_dir(".").is_ok());
}
#[test]
fn test_list_nonexistent_path() {
assert!(
list_dir("/does/not/exist/12345").is_err()
);
}
#[test]
fn test_list_empty_dir() {
let tmp = std::env::temp_dir().join("mini_ls_test_empty");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
assert!(list_dir(&tmp).is_ok());
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_list_dir_with_entries() {
let tmp = std::env::temp_dir().join("mini_ls_test_entries");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
File::create(tmp.join("b.txt"))
.unwrap()
.write_all(b"x")
.unwrap();
File::create(tmp.join("a.txt"))
.unwrap()
.write_all(b"x")
.unwrap();
fs::create_dir(tmp.join("sub")).unwrap();
assert!(list_dir(&tmp).is_ok());
let _ = fs::remove_dir_all(&tmp);
}
}
Why This Code?
| Part | Purpose |
|---|---|
list_dir() |
Core function — checks if path exists, then lists dir or prints file |
path.exists() check |
Returns proper NotFound error instead of silently printing nothing |
filter_map + ok() |
Silently skips unreadable entries (permission denied, broken symlinks) — like real ls |
names.sort() |
Alphabetical sort — deterministic output for reproducibility and testing |
exit_code tracking |
Continues processing all arguments, then exits with 1 if any failed — like real ls |
#[cfg(test)] module |
Tests compile only during cargo test — zero overhead in release build |
Step 2: Write the Containerfile
This is the heart of the tutorial. Create Containerfile:
# Stage 1: Code quality — formatting and linting
FROM docker.io/stagex/pallet-rust@sha256:2fbe7b164dd92edb9c1096152f6d27592d8a69b1b8eb2fc907b5fadea7d11668 AS check
WORKDIR /app
COPY Cargo.toml ./
COPY src/ ./src/
RUN cargo fmt --check && \
cargo clippy --all-targets -- -D warnings
# Stage 2: Unit tests
FROM docker.io/stagex/pallet-rust@sha256:2fbe7b164dd92edb9c1096152f6d27592d8a69b1b8eb2fc907b5fadea7d11668 AS test
WORKDIR /app
COPY --from=check /app/ ./
RUN cargo test --frozen
# Stage 3: Release build with static linking
FROM docker.io/stagex/pallet-rust@sha256:2fbe7b164dd92edb9c1096152f6d27592d8a69b1b8eb2fc907b5fadea7d11668 AS build
WORKDIR /app
COPY --from=test /app/ ./
RUN RUSTFLAGS="-C target-feature=+crt-static" \
cargo build --frozen \
--release \
--target x86_64-unknown-linux-musl \
--bin mini-ls && \
cp target/x86_64-unknown-linux-musl/release/mini-ls /mini-ls
# Stage 4: Minimal runtime — only the binary
FROM scratch
COPY --from=build /mini-ls /mini-ls
ENTRYPOINT ["/mini-ls"]
Why Four Stages?
Each stage is a quality gate. If any stage fails, the build stops immediately, and you see exactly where:
| Stage | Gate | What happens if it fails |
|---|---|---|
| check | cargo fmt --check |
Code isn't formatted consistently — run cargo fmt to fix |
| check | cargo clippy -- -D warnings |
Clippy detected a code quality issue — read the warning and fix it |
| test | cargo test --frozen |
A test failed — check the test output and fix the code |
| build | cargo build --frozen |
Compilation error — fix the source code |
| scratch | — | Always succeeds (it just copies one file) |
Why
--frozen?--frozenpreventsCargo.lockfrom being modified. This ensures every build uses the exact same dependency versions — critical for reproducibility. Since our project has zero external dependencies, the lockfile is minimal, but the principle scales to any Rust project.
Why -- -D warnings?
The -- separates cargo args from the tool's args. -D warnings tells clippy to deny all warnings as errors. This means:
- A single clippy warning stops the build
- You can't ignore code quality issues
- CI enforces the same standards as local development
Step 3: Build the Pipeline
podman build -t mini-ls:latest -f Containerfile .
You'll see each stage execute in sequence:
[1/4] STEP 5/5: RUN cargo fmt --check && cargo clippy --all-targets -- -D warnings
Checking mini-ls v0.1.0 (/app)
Finished dev profile
[2/4] STEP 4/4: RUN cargo test --frozen
Running unittests src/main.rs
test tests::test_list_current_dir ... ok
test tests::test_list_empty_dir ... ok
test tests::test_list_dir_with_entries ... ok
test tests::test_list_nonexistent_path ... ok
test result: ok. 6 passed; 0 failed
[3/4] STEP 4/4: RUN cargo build --frozen --release ...
Compiling mini-ls v0.1.0 (/app)
Finished release profile
[4/4] COMMIT mini-ls:latest
If you've written the code exactly as above, the build succeeds on the first try. In real development, stages 1 and 2 catch most issues before the expensive release build.
Step 4: Test the Binary
podman run --rm mini-ls:latest /
Output:
dev
etc
mini-ls
proc
run
sys
podman run --rm mini-ls:latest /etc
Output:
hostname
hosts
mtab
resolv.conf
Test error handling:
podman run --rm mini-ls:latest /nonexistent; echo "exit: $?"
Output:
mini-ls: cannot access '/nonexistent': no such file or directory
exit: 1
Check the image size:
podman images mini-ls:latest --format '{{.Size}}'
790 kB
790 KB — the entire operating system for mini-ls is a single statically-linked binary. No shell, no libraries, no package manager. This is the minimal attack surface that StageX enables.
Step 5: Verify Reproducibility
# Record the first digest
podman inspect mini-ls:latest --format '{{.Digest}}'
# Rebuild with clean cache
podman build --no-cache -t mini-ls:rebuild -f Containerfile .
# Compare
echo "First: $(podman inspect mini-ls:latest --format '{{.Digest}}')"
echo "Second: $(podman inspect mini-ls:rebuild --format '{{.Digest}}')"
The digests will be identical — proof that the pipeline produces deterministic, reproducible artifacts.
What Makes This a CI Pipeline
In a real CI system (GitHub Actions, GitLab CI, Forgejo Actions), you'd typically define separate jobs for each stage. With StageX, the entire pipeline runs inside a single Containerfile. This means:
- Local reproduction — run
podman buildon your laptop and get the exact same result as CI - No external dependencies — everything is pinned by digest
- Atomic builds — either all stages pass and you get an image, or a stage fails and you get nothing (no half-built artifacts)
- Cache efficiency — if only source code changes, only the check stage re-runs; test and build caches are reused if their inputs didn't change
Try It: Break the Pipeline
To understand how the gates work, try introducing a deliberate issue:
Formatting gate
Remove the .sort() call and re-indent a line:
names.sort();
to:
names.sort(/* wrong indent */);
;
podman build -t mini-ls:broken -f Containerfile . 2>&1 | grep -A2 "fmt"
You'll see cargo fmt --check output a diff and fail. Run cargo fmt (inside the container) to auto-fix.
Lint gate
Change eprintln! to println! in the error handler:
eprintln!("mini-ls: .: {}", e);
to:
println!("mini-ls: .: {}", e);
Build again — clippy will warn that errors should go to stderr, not stdout, and the -D warnings flag turns this into a build failure.
These might seem strict, but they prevent real bugs. The println! vs eprintln! mistake would break pipelines that parse stdout as structured data.
Next Steps
- Understanding Full-Source Bootstrapping — How that 790 KB binary traces back to a 190-byte machine code seed
- Building a Go Application — Same CI pipeline pattern applied to Go
- Adding a New Package — Contribute mini-ls as an actual StageX package