diff --git a/Dockerfile b/Dockerfile index af8634f..2730bd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -159,19 +159,18 @@ COPY --from=cargo-tools /usr/local/bin/ /usr/local/bin/ # means the sync never fires. RUN rustup component add rustfmt clippy rust-src -ENV CARGO_HOME=/workspace/.cargo \ - CARGO_TARGET_DIR=/workspace/target \ +ENV CARGO_HOME=/cargo/home \ + CARGO_TARGET_DIR=/cargo/target \ RUSTC_WRAPPER=sccache \ - SCCACHE_DIR=/workspace/.sccache \ + SCCACHE_DIR=/cargo/sccache \ RUST_BACKTRACE=1 -# Pre-create cache mount targets so the runtime volume mounts at -# /workspace/{target,.cargo,.sccache} can attach without docker -# needing to mkdirat() into a read-only `/workspace`. Without these -# the `:ro` bind mount of the source tree blocks volume attachment -# and `docker compose run --rm ci ...` fails at container start with -# "read-only file system" during mountpoint creation. -RUN mkdir -p /workspace/target /workspace/.cargo /workspace/.sccache +# Pre-create the cache mount targets at /cargo/* so the named volume +# mounts attach cleanly. These live OUTSIDE the /workspace bind mount +# on purpose (see docker-compose.yml): nesting them under /workspace +# made the daemon create root-owned ./target / ./.cargo / ./.sccache +# on the host, littering the working tree and breaking host-side cargo. +RUN mkdir -p /cargo/target /cargo/home/registry /cargo/home/git /cargo/sccache WORKDIR /workspace @@ -192,9 +191,9 @@ FROM dev AS fuzz # `rustup toolchain install` tries to self-update by looking for the # rustup binary at $CARGO_HOME/bin/rustup. The inherited -# `CARGO_HOME=/workspace/.cargo` (set in the dev stage for runtime +# `CARGO_HOME=/cargo/home` (set in the dev stage for runtime # volume mounts) is empty at image-build time, so the self-update step -# bails with "rustup is not installed at '/workspace/.cargo'". Override +# bails with "rustup is not installed at '/cargo/home'". Override # the env for this one RUN so rustup finds itself at the parent rust # image's `/usr/local/cargo` location; the runtime CARGO_HOME setting # is unaffected. diff --git a/docker-compose.yml b/docker-compose.yml index 2f33f71..776e33d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,17 @@ name: afm x-common-env: &common-env - CARGO_HOME: /workspace/.cargo - CARGO_TARGET_DIR: /workspace/target + # Cargo caches live at /cargo/* — OUTSIDE the /workspace bind mount — on + # purpose. Nesting a named volume inside the bind mount (the old + # /workspace/target etc.) makes the Docker daemon create the host-side + # mountpoint (./target, ./.cargo, ./.sccache) as **root**, which litters + # the working tree with root-owned junk and breaks any host-side cargo. + # Keeping the caches at /cargo/* leaves the host's own ./target free and + # keeps the checkout clean. The named volumes still persist them. + CARGO_HOME: /cargo/home + CARGO_TARGET_DIR: /cargo/target RUSTC_WRAPPER: sccache - SCCACHE_DIR: /workspace/.sccache + SCCACHE_DIR: /cargo/sccache # sccache cannot cache crates compiled with cargo's incremental mode. # Cargo defaults incremental=true for workspace members in dev/test profiles, # which silently bypasses sccache for the very crates we build most often. @@ -55,10 +62,10 @@ services: environment: *common-env volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache init: true tty: true stdin_open: true @@ -76,10 +83,10 @@ services: environment: *common-env volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache init: true tty: true stdin_open: true @@ -95,17 +102,16 @@ services: <<: *common-env CI: "true" volumes: - # `:cached` (not `:ro`): a `:ro` bind mount HIDES `/workspace/{target, - # .cargo,.sccache}` that were `mkdir -p`-ed in the image (overlayfs - # bind-mount semantics — the source dir replaces image contents), - # which then blocks the named-volume mountpoints below from - # attaching at runtime ("mkdirat ... read-only file system"). The - # runner is ephemeral; `:cached` is safe and the canonical CI mode. + # `:cached` (writable): CI steps emit artifacts back into the tree + # (wasm `pkg/`, generated docs, coverage reports), so the source + # mount stays writable. The cargo caches now live at /cargo/* + # (outside this bind mount), so the old ":ro hides the nested + # /workspace/{target,.cargo,.sccache} mountpoints" hazard is gone. - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache book: build: @@ -131,9 +137,9 @@ services: CI: "true" volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target # Vite dev / preview server for the browser playground. # Reuses the `dev` image (which already has Node 22 + wasm-pack) so no @@ -150,10 +156,10 @@ services: environment: *common-env volumes: - .:/workspace:cached - - cargo-registry:/workspace/.cargo/registry - - cargo-git:/workspace/.cargo/git - - cargo-target:/workspace/target - - sccache:/workspace/.sccache + - cargo-registry:/cargo/home/registry + - cargo-git:/cargo/home/git + - cargo-target:/cargo/target + - sccache:/cargo/sccache - playground-node-modules:/workspace/playground/node_modules ports: - "5173:5173" diff --git a/lefthook.yml b/lefthook.yml index 77a9ea1..7b0a54c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -5,6 +5,37 @@ # Disable: `just hooks-uninstall` # # Reference: https://lefthook.dev/configuration/ +# +# Status icons in the summary +# --------------------------- +# Lefthook prints one of three markers next to each command in its +# summary line: +# ✔️ success (exit 0) +# 🗙 failed +# 🥊 also a failure path — typically when the command exited +# non-zero but lefthook fell back to its branding icon because +# the underlying tooling (docker compose run, `just`) buried +# the status. Treat 🥊 the same as 🗙 — see the actual command +# output above the summary to find the real error. +# +# `fail_text` on each command below is the explicit human hint that +# lefthook displays on failure so it's obvious what to fix without +# scrolling back through hundreds of lines of cargo output. + +# Make the summary noisier on failures — show what command was +# executing, its stdout, the stderr, and the skip reasons (default +# already lists most of these, this is the explicit form so it +# survives lefthook version bumps that change the default set). +output: + - meta + - summary + - success + - failure + - execution + - execution_info + - execution_out + - skips + - empty_summary pre-commit: parallel: true @@ -20,14 +51,33 @@ pre-commit: glob: "*.rs" run: just fmt stage_fixed: true + fail_text: | + `cargo fmt` failed. Run `just fmt` manually to see the error. + Common cause: rustfmt edition mismatch with rust-toolchain.toml. clippy: glob: "*.{rs,toml}" run: just clippy + fail_text: | + clippy reported an error. Re-run `just clippy` for the full + diagnostic. `[workspace.lints]` in Cargo.toml controls which + lints fire — fix the code rather than silencing the lint. typos: run: just typos + fail_text: | + `typos` found a probable typo. Re-run `just typos` to see the + candidate fixes, or add an exception to `_typos.toml` if the + flagged token is intentional (proper noun, identifier, etc.). + # Guard the vendored comrak tree against any drift — ADR-0001 sets a + # 0-line diff budget. Runs only when files under `upstream/comrak/` + # actually changed. upstream-diff: glob: "upstream/comrak/**" run: just upstream-diff + fail_text: | + The vendored `upstream/comrak/` tree drifted from its pinned + upstream (ADR-0001, 0-line diff budget). Re-run + `just upstream-diff` to see the diff. A genuine comrak change + is a fork-divergence decision and needs its own ADR. commit-msg: commands: @@ -41,18 +91,67 @@ commit-msg: || { echo 'Commit message must follow Conventional Commits (https://www.conventionalcommits.org/)'; exit 1; }" pre-push: - parallel: true + # `just ci` runs gates sequentially in cheap-to-expensive order so + # the early ones fail fast. Parallelising at the lefthook level + # would let coverage start before lint catches a typo — and would + # also let the heavy steps race the cheap ones for the dev image's + # cargo cache. `just ci` already mirrors every CI job reachable from + # inside the dev image, so the hook just delegates to it. + # + # Push budget: a few minutes cold cache, less when warm. The + # alternative this hook exists to eliminate is "ship a PR, watch + # GitHub fail, re-push". + parallel: false skip: - rebase commands: - test: - run: just test - deny: - run: just deny + # Mirror CI. `just ci` is the 18-step fail-fast pipeline (typos / + # fmt-check / upstream-diff / strict-code / verify-version-pins / + # check / doc / deny / audit / lint / build / test / prop / + # spec-commonmark / spec-gfm / coverage / book-build / udeps). The + # CI-only jobs that need extra runtime (the PR-time wasm-build / + # playground build) are deliberately left out — `just ci` keeps the + # push under a typical attention budget. + ci: + run: just ci + fail_text: | + `just ci` failed. The error is the last `error: recipe + \`\` failed ...` line above this summary — that tells you + which sub-recipe (check / lint / build / test / spec-commonmark / + spec-gfm / coverage / book-build / udeps …) bombed. + + Common causes: + - upstream-diff → the vendored comrak tree drifted (ADR-0001); + run `just upstream-diff`. + - typos / clippy regressed → run the individual gate + (`just typos`, `just clippy`) for a cleaner repro. + - spec-commonmark / spec-gfm → a CommonMark/GFM conformance + example regressed; `just spec-commonmark` prints the failing + example index. + + Re-run `just ci` directly (not via lefthook) for the unwrapped + output if the lefthook summary truncates anything important. # Deep proptest sweep (4096 cases vs the CI baseline 128) — runs - # locally before push so a regression that needs many cases to - # surface is caught here instead of on a downstream PR. Fuzz is - # deliberately *not* wired in: libfuzzer's nightly + corpus + cold - # start budget overshoots a pre-push hook. + # after `just ci`'s 128-case `prop` step so a regression that needs + # many cases to surface is caught here instead of on a downstream + # PR. Fuzz is deliberately *not* wired in: libfuzzer's nightly + + # corpus + cold start budget overshoots a pre-push hook. prop-deep: run: just prop-deep + # Tag for opt-out via `SKIP_TAGS=deep git push`. A deep sweep can + # occasionally surface a pre-existing bug in the git-pinned aozora + # core (parse / serialize / round-trip) that is unrelated to the + # current change. `SKIP_TAGS=deep` lets that unrelated PR through + # without disabling the rest of the hook stack; file an issue + # against the failing aozora crate and bump the pin once fixed. + tags: + - deep + fail_text: | + Deep proptest regression. Re-run `just prop-deep` to reproduce. + The seed for the failing case is printed in nextest's PASS/FAIL + line; pass it via `AOZORA_PROPTEST_SEED=` to shrink locally. + + If the failure is in the git-pinned aozora CORE (parse / render / + pipeline) and is unrelated to your change, you may opt out with + `SKIP_TAGS=deep git push` + and file an issue against the failing crate.