diff --git a/ci/docker-linux-verify/README.md b/ci/docker-linux-verify/README.md new file mode 100644 index 00000000..4ee50dfb --- /dev/null +++ b/ci/docker-linux-verify/README.md @@ -0,0 +1,67 @@ +# `ci/docker-linux-verify/` — Local Linux check-ubuntu lane reproducer + +Reproduces `.github/workflows/check-ubuntu.yml` (cargo check + clippy + +subprocess lint + cargo test) inside a Docker container so a Windows +host can prove the Linux lane is green before pushing to a GHA-bound +branch. Useful for fast iteration on async / cross-platform changes +where the only difference between platforms is what cfg-gated code +gets exercised. + +## Why reuse `fbuild-mac-cross` + +The image at `ci/docker-mac-cross/Dockerfile` already ships soldr + +zccache + uv + the Rust toolchain bootstrapper on a vanilla +`ubuntu:24.04` base — the exact same OS image +`ubuntu-latest` resolves to on GHA today. The mac-cross image's *job* +is cross-compiling for Apple, but its *bill of materials* is "what a +GHA Linux runner has on day one." That's exactly what +check-ubuntu.yml needs, so reuse it instead of building a second +image. + +## Usage + +```bash +# Full verify (first run: 5-8 min cold; subsequent: seconds-to-minutes) +uv run python ci/docker-linux-verify/verify.py + +# Interactive shell (debugging) +uv run python ci/docker-linux-verify/verify.py --shell + +# Force a cold rebuild (delete cargo target/ and CARGO_HOME volumes) +uv run python ci/docker-linux-verify/verify.py --wipe + +# Force a rebuild of the docker image itself +uv run python ci/docker-linux-verify/verify.py --rebuild-image +``` + +## Volume conventions + +Two named Docker volumes back the cargo state across runs: + +| Volume | Mount | Purpose | +|--------------------------------|------------|----------------------------------------| +| `fbuild-linux-verify-target` | `/target` | `CARGO_TARGET_DIR` — incremental cache | +| `fbuild-linux-verify-cargo-home` | `/cargo-home` | `CARGO_HOME` — registry + crate sources | + +These are **named volumes, not host bind-mounts**. On Windows hosts, +WSL2's 9P translation rewrites mtimes per container start, which +defeats cargo's incremental fingerprint check (measured 4-6 min per +no-op rebuild). Named volumes live on Linux-native ext4 inside +Docker's VFS, so the same no-op rebuild is single-digit seconds. + +The repo itself is bind-mounted at `/src` because we want source +edits to be visible immediately without copying. + +## What gets verified + +`verify.sh` runs exactly the four steps from `check-ubuntu.yml`: + +1. `soldr cargo check --workspace --all-targets` +2. `soldr cargo clippy --workspace --all-targets -- -D warnings` +3. `uv run python ci/find_direct_subprocess.py --fail` +4. `soldr cargo test --workspace` + +If all four pass, the GHA "Check (ubuntu-latest)" lane will be green +on the same commit. (Caveat: this image does not exercise the per-MCU +build matrix — those are separate jobs in their own workflows, not +part of check-ubuntu.) diff --git a/ci/docker-linux-verify/verify.py b/ci/docker-linux-verify/verify.py new file mode 100644 index 00000000..bc09ea5f --- /dev/null +++ b/ci/docker-linux-verify/verify.py @@ -0,0 +1,140 @@ +"""Local Linux verification harness — reproduces `check-ubuntu.yml` +locally on a Windows host using the existing `fbuild-mac-cross` Docker +image, with named volumes for fast incremental rebuilds. + +The host bind-mount carries the repo source; cargo's `target/` and +`CARGO_HOME` live in **named Docker volumes** rather than host paths +because Windows-host WSL2 9P translation rewrites mtimes per container +start, defeating cargo's incremental fingerprint check. Named volumes +sit on Linux-native ext4 inside Docker's VFS and keep no-op rebuilds +in single-digit seconds. + +Usage:: + + uv run python ci/docker-linux-verify/verify.py # full check + clippy + test + uv run python ci/docker-linux-verify/verify.py --shell # interactive bash in the image + uv run python ci/docker-linux-verify/verify.py --wipe # remove named volumes (force cold rebuild) + +This is the local-loop equivalent of pushing a branch and waiting for +GHA's Check Ubuntu lane. First run is a full cold build (~5-8 min on a +fast machine). Subsequent runs after a source edit are seconds-to-minutes. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +IMAGE = "fbuild-mac-cross" +VOLUME_TARGET = "fbuild-linux-verify-target" +VOLUME_CARGO_HOME = "fbuild-linux-verify-cargo-home" + + +def _run(cmd: list[str], *, check: bool = True) -> int: + print(f"$ {' '.join(cmd)}", flush=True) + result = subprocess.run(cmd, check=False) + if check and result.returncode != 0: + sys.exit(result.returncode) + return result.returncode + + +def _ensure_image() -> None: + rc = subprocess.run( + ["docker", "image", "inspect", IMAGE], + capture_output=True, + ).returncode + if rc == 0: + return + print(f"image {IMAGE} not found — building from ci/docker-mac-cross/", flush=True) + _run( + [ + "docker", + "build", + "-f", + str(REPO_ROOT / "ci" / "docker-mac-cross" / "Dockerfile"), + "-t", + IMAGE, + str(REPO_ROOT), + ] + ) + + +def _wipe_volumes() -> None: + for vol in (VOLUME_TARGET, VOLUME_CARGO_HOME): + rc = _run(["docker", "volume", "rm", vol], check=False) + if rc != 0: + print(f" (volume {vol} did not exist — skipping)", flush=True) + + +def _docker_run(args: list[str], *, interactive: bool = False) -> int: + src_mount = str(REPO_ROOT).replace("\\", "/") + run_args = [ + "docker", + "run", + "--rm", + "-v", + f"{src_mount}:/src", + "-v", + f"{VOLUME_TARGET}:/target", + "-v", + f"{VOLUME_CARGO_HOME}:/cargo-home", + "-w", + "/src", + ] + if interactive: + run_args += ["-it"] + run_args += [IMAGE] + args + return _run(run_args, check=False) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--shell", + action="store_true", + help="drop into an interactive bash inside the image (skip verify.sh)", + ) + p.add_argument( + "--wipe", + action="store_true", + help="remove the named cargo volumes (force a cold rebuild next time)", + ) + p.add_argument( + "--rebuild-image", + action="store_true", + help=f"force a `docker build` of the {IMAGE} image before verifying", + ) + args = p.parse_args() + + if args.wipe: + _wipe_volumes() + if not (args.shell or args.rebuild_image): + return 0 + + if args.rebuild_image: + _run( + [ + "docker", + "build", + "--no-cache", + "-f", + str(REPO_ROOT / "ci" / "docker-mac-cross" / "Dockerfile"), + "-t", + IMAGE, + str(REPO_ROOT), + ] + ) + else: + _ensure_image() + + if args.shell: + return _docker_run(["bash"], interactive=True) + + return _docker_run(["bash", "ci/docker-linux-verify/verify.sh"]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/docker-linux-verify/verify.sh b/ci/docker-linux-verify/verify.sh new file mode 100644 index 00000000..87798cdb --- /dev/null +++ b/ci/docker-linux-verify/verify.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Run inside the `fbuild-mac-cross` docker image — exercises the same +# steps as `.github/workflows/check-ubuntu.yml` so a local Linux pass +# gives confidence the GHA Linux lanes will be green after a Windows-host +# admin-merge. +# +# Pre-built image: see ci/docker-mac-cross/Dockerfile. It already ships +# soldr + zccache + uv + the Rust toolchain bootstrapper, which is +# exactly what we need. +# +# Volumes (managed by verify.py for cross-run reuse): +# /src ← repo bind-mount (read-only would be nicer, but cargo +# writes Cargo.lock + the build emits dotfiles into the +# workspace, so RW is required) +# /target ← named volume `fbuild-linux-verify-target` +# /cargo-home ← named volume `fbuild-linux-verify-cargo-home` + +set -euo pipefail + +export CARGO_TARGET_DIR=/target +export CARGO_HOME=/cargo-home +export RUSTFLAGS="-D warnings" + +cd /src + +echo "::group::soldr version + rust toolchain" +soldr --version +soldr toolchain ensure +echo "::endgroup::" + +echo "::group::cargo check --workspace --all-targets" +soldr cargo check --workspace --all-targets +echo "::endgroup::" + +echo "::group::cargo clippy --workspace --all-targets -- -D warnings" +soldr cargo clippy --workspace --all-targets -- -D warnings +echo "::endgroup::" + +echo "::group::lint subprocess spawns" +uv run python ci/find_direct_subprocess.py --fail +echo "::endgroup::" + +echo "::group::cargo test --workspace" +soldr cargo test --workspace +echo "::endgroup::" + +echo "ALL GREEN — Linux check-ubuntu lane is satisfied."