diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4613d1a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +# CI Design Principles +# +# 1. The justfile is the source of truth. This workflow is plumbing +# (checkout, cache, `just ci`). Test logic lives in the justfile +# where contributors can run it locally. +# +# 2. Build the toolchain container from source, don't pull it. The +# image is defined by build.Containerfile in the same commit being +# tested---full traceability and repeatability, no external +# registry to trust or keep in sync. +# +# 3. Use `pull_request`, not `pull_request_target`. PR workflows run +# the code from the PR (including any Containerfile changes), with +# read-only repo access and no secrets. A PR author can modify the +# container and workflow, but that's visible in the diff and is the +# reviewer's job to catch. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + ci: + name: just ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v3 + + # Cache the build toolchain image as a portable tar archive. + # Podman's overlay storage can't be cached directly (tar fails + # on overlay whiteout files), so we round-trip through podman + # save/load. The cache key is the hash of build.Containerfile. + # The image is tagged with a short hash of the Containerfile so + # that build-image in the justfile recognizes it and skips the + # build. + - uses: actions/cache@v5 + id: image-cache + with: + path: /tmp/mujina-build.tar + key: build-image-${{ hashFiles('build.Containerfile') }} + + - name: Load cached build image + if: steps.image-cache.outputs.cache-hit == 'true' + run: podman load -i /tmp/mujina-build.tar + + # Cache compiled dependencies and downloaded crates to speed + # up builds. The key includes the toolchain (Containerfile) + # so a compiler bump invalidates stale artifacts. + - uses: actions/cache@v5 + with: + path: | + target + .cache + key: cargo-${{ hashFiles('build.Containerfile') }}-${{ hashFiles('Cargo.lock') }} + + - run: just ci + + # Prune project crate artifacts before the cache saves, + # keeping only compiled dependencies. + - name: Prune project artifacts from target cache + run: | + find target -name 'mujina*' -delete 2>/dev/null || true + find target -name 'libmujina*' -delete 2>/dev/null || true + + - name: Save build image for cache + if: steps.image-cache.outputs.cache-hit != 'true' + run: | + TAG=$(sha256sum build.Containerfile | cut -c1-12) + podman save -o /tmp/mujina-build.tar "mujina-build:$TAG" diff --git a/.gitignore b/.gitignore index 80faede..3e07533 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ Cargo.lock /target/ +/.cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3acd3db..64bbac4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,8 +105,10 @@ will be accepted with a high degree of certainty. ### Prerequisites - Rust toolchain (stable) +- [just](https://just.systems/) command runner - Linux development environment - Git +- Optional: [Podman](https://podman.io/) for reproducing CI locally - Optional: Hardware for testing (Bitaxe boards, etc.) ### Setting Up Your Development Environment @@ -147,9 +149,28 @@ will be accepted with a high degree of certainty. 2. Follow the project's module structure 3. Add tests for new functionality 4. Update documentation as needed -5. Ensure all tests pass: `cargo test` -6. Run clippy: `cargo clippy -- -D warnings` -7. Format your code: `cargo fmt` +5. Run all checks before committing: + ```bash + just checks + ``` + This runs `cargo fmt --check`, `cargo clippy`, and `cargo test` + using your local Rust toolchain. + +#### Reproducing CI locally + +Pull requests are gated on CI that runs inside a Podman container +with a pinned Rust toolchain. If `just checks` passes locally but +CI fails (or vice versa), you can run the same checks in the same +container CI uses: + +```bash +just ci +``` + +This builds a toolchain container from `build.Containerfile` and +runs `just checks` inside it. The container image is cached +locally and only rebuilds when the Containerfile changes. Podman +is required for this step but not for regular development. ### Testing diff --git a/Cargo.toml b/Cargo.toml index 813481a..6985a7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = ["mujina-miner", "tools/mujina-dissect"] resolver = "2" [workspace.package] +# When bumping the compiler version, also update build.Containerfile. edition = "2024" license = "GPL-3.0-or-later" authors = ["Ryan Kuester "] diff --git a/build.Containerfile b/build.Containerfile new file mode 100644 index 0000000..62e0f5d --- /dev/null +++ b/build.Containerfile @@ -0,0 +1,21 @@ +# Build toolchain image for running checks in a reproducible environment. +# +# The Rust compiler and just are pinned to exact versions. The base +# image is pinned by digest. Apt packages come from the Debian +# bookworm repos bundled in the base image; their exact versions can +# drift across builds but are stable system libraries that don't +# affect build output. + +FROM docker.io/library/rust:1.94-bookworm@sha256:b2fe2c0f26e0e1759752b6b2eb93b119d30a80b91304f5b18069b31ea73eaee8 + +# These are pinned to the exact toolchain release by the base image digest. +RUN rustup component add rustfmt clippy + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libudev-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +RUN cargo install just --version 1.40.0 + +WORKDIR /workspace diff --git a/justfile b/justfile index 63ce5e0..021a83a 100644 --- a/justfile +++ b/justfile @@ -21,6 +21,44 @@ test: run: cargo run --bin mujina-minerd +BUILD_IMAGE := "mujina-build" +# Tag with a content hash of the Containerfile so we can detect +# staleness without rebuilding. This matters in CI where podman +# save/load doesn't preserve layer cache---podman build would +# rebuild from scratch even with a loaded image. The content-hash +# tag lets `podman image exists` skip the build entirely. +BUILD_TAG := `sha256sum build.Containerfile | cut -c1-12` + +# Build the build toolchain image (skips if unchanged) +[group('container')] +build-image: + podman image exists {{BUILD_IMAGE}}:{{BUILD_TAG}} || \ + podman build -t {{BUILD_IMAGE}}:{{BUILD_TAG}} -f build.Containerfile . + +# Remove stale build toolchain images +[group('container')] +build-image-clean: + podman images --format '{{{{.Repository}}:{{{{.Tag}}' \ + | grep '^{{BUILD_IMAGE}}:' \ + | grep -v ':{{BUILD_TAG}}$' \ + | xargs -r podman rmi + +# Run a just recipe inside the build toolchain image +[group('container')] +in-container *args: build-image + mkdir -p .cache/cargo-registry .cache/cargo-git + podman run --rm \ + -v "$(pwd)":/workspace:Z \ + -v "$(pwd)/.cache/cargo-registry":/usr/local/cargo/registry \ + -v "$(pwd)/.cache/cargo-git":/usr/local/cargo/git \ + -w /workspace \ + {{BUILD_IMAGE}}:{{BUILD_TAG}} \ + just {{args}} + +# The CI pipeline. This is what GitHub Actions runs. +[group('ci')] +ci: (in-container "checks") + [group('container')] container-build tag=`git rev-parse --abbrev-ref HEAD`: podman build -t mujina-minerd:{{tag}} -f Containerfile .