Skip to content

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


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? --frozen prevents Cargo.lock from 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 build on 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