-
Notifications
You must be signed in to change notification settings - Fork 1
ci: local Linux check-ubuntu reproducer (#813 follow-up) #824
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+254
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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." |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Don’t collapse every volume-removal failure into “did not exist.”
Line 67 can also fail when the volume is still in use or Docker itself errors. In those cases this branch still prints a benign skip message and, with
--wipealone, exits 0, so users can think they forced a cold rebuild while the old cache is still intact.Proposed fix
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) + result = subprocess.run( + ["docker", "volume", "rm", vol], + check=False, + capture_output=True, + text=True, + ) + if result.returncode == 0: + continue + if "no such volume" in result.stderr.lower(): + print(f" (volume {vol} did not exist — skipping)", flush=True) + continue + sys.stderr.write(result.stderr) + sys.exit(result.returncode)📝 Committable suggestion
🤖 Prompt for AI Agents