Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions ci/docker-linux-verify/README.md
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.)
140 changes: 140 additions & 0 deletions ci/docker-linux-verify/verify.py
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)
Comment on lines +65 to +69

Copy link
Copy Markdown

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 --wipe alone, 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 _wipe_volumes() -> None:
for vol in (VOLUME_TARGET, VOLUME_CARGO_HOME):
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)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ci/docker-linux-verify/verify.py` around lines 65 - 69, The _wipe_volumes
helper is treating every non-zero result from docker volume rm as a missing
volume, which hides real failures like “volume in use” or Docker errors. Update
_wipe_volumes to distinguish the “does not exist” case from other failures by
inspecting the command result from _run, and only print the skip message when
the volume is actually absent. For any other failure, surface the error and make
the wipe path fail so --wipe does not incorrectly report success; use the
existing _wipe_volumes and _run symbols to keep the fix localized.



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())
47 changes: 47 additions & 0 deletions ci/docker-linux-verify/verify.sh
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."
Loading