diff --git a/.githooks/pre-push b/.githooks/pre-push index f390143..e49b0a1 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -2,16 +2,28 @@ # # Pre-push hook for monitor-agent # -# Runs the same lint + test + coverage gates that CI runs, locally, -# before any push reaches origin. If this passes, CI should pass too. +# Runs the same lint + test + coverage gates that CI runs, locally, before any +# push reaches origin. If this passes, CI should pass too — literally: both +# invoke the same just recipes. # # Install: `just install-hooks` # -# PIPELINE PARITY: This hook mirrors .github/workflows/ci.yml. -# `just check` — matches the lint + test jobs (fmt + clippy + cargo test) -# `just cov-ci` — matches the coverage job (78% line coverage floor) -# When editing this hook, update the workflow to match. -# When editing the workflow, update this hook to match. +# PIPELINE PARITY: this hook mirrors .github/workflows/ci.yml, which invokes +# the SAME just recipes rather than restating the command strings: +# `just check` -> ci.yml job `gate` (fmt-check + lint + test + test-features) +# `just cov-ci` -> ci.yml job `coverage` (77% line floor) +# The justfile is the single source of truth for both. +# +# CI differs in exactly two documented ways, both listed in ci.yml's header: +# it apt-installs libasound2-dev + pkg-config, and it uploads lcov.info. +# CI additionally runs an `msrv` job (a second full toolchain) that this hook +# deliberately skips — it is wall-clock a developer should not pay per push. +# +# NOTE: the coverage floor is calibrated on a CI RUNNER, not here. This box +# reads ~0.4 points high because voice.rs probes for TTS binaries that are +# installed locally and absent on a runner. See the justfile for detail. +# +# When editing this hook, update the workflow to match, and vice versa. # # Do NOT skip this hook with `--no-verify`. Fix the issue instead. @@ -38,7 +50,7 @@ fi echo "[pre-push] running just check..." just check -echo "[pre-push] running just cov-ci (78% floor)..." +echo "[pre-push] running just cov-ci (77% floor)..." # On macOS (Homebrew Rust) llvm-tools-preview is unavailable via rustup, so # cargo-llvm-cov needs to be pointed at Homebrew's LLVM. Only do that when it # is actually there: these paths do not exist on Linux, and setting them diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0dc8d18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,166 @@ +name: CI + +# PIPELINE PARITY: this workflow is mirrored by `.githooks/pre-push`, which +# runs `just check` + `just cov-ci`. +# +# Every step below invokes the SAME just recipe the hook does. The justfile is +# the single source of truth for the command strings and for the coverage +# floor — CI never re-types them. Parity therefore holds by construction, not +# by comment, and editing a recipe updates both gates at once. +# +# PARITY EXCEPTIONS (CI-only, deliberate): +# * apt-installs libasound2-dev + pkg-config — runner provisioning, not a +# change to what is tested. +# * uploads lcov.info as an artifact. +# There are no local-only steps. The hook's macOS LLVM_COV/LLVM_PROFDATA block +# is inert on Linux and must NOT be ported here. +# +# When editing this file, update .githooks/pre-push and the justfile to match. + +on: + push: + branches: ["main"] + # Deliberately NO `branches:` filter. With `branches: [main]`, a stacked PR + # whose base is another feature branch matches neither trigger, so nothing + # runs — and GitHub still reports mergeStateStatus=CLEAN. "No checks ran" + # must never render as green. Do not re-add the filter. + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + # Never cancel a run on main; superseding a branch run is fine. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + gate: + name: fmt + clippy + test + # Self-hosted escape hatch, falling back to GitHub's runners when the org + # variable is unset (the house pattern from newt-agent). + runs-on: ${{ vars.GMF_LINUX_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 30 + env: + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + steps: + - uses: actions/checkout@v4 + + # PARITY EXCEPTION: runner provisioning. + # + # This is the complete list, established two independent ways rather than + # assumed: `cargo tree -i pkg-config` has exactly two consumers + # (alsa-sys, wayland-sys), and a clean rust:1.88-bookworm container runs + # the whole gate green with only this installed. + # + # alsa-sys is a hard blocker — its build.rs probes pkg-config and panics. + # Chain: monitor-voice -> cpal -> alsa -> alsa-sys, and both monitor-voice + # and monitor-gui are unconditional workspace members, so no feature flag + # dodges it. This is what killed the first CI run (34062498409). + # + # No X11/GTK/GL/xkbcommon packages are needed: wayland-sys resolves with + # `dlopen`, x11-dl maps a pkg-config miss to None, and glutin/glow load + # GL at runtime. eframe's reputation says otherwise; this dependency tree + # does not. No display, no audio device, no Xvfb — the gui tests drive + # egui headlessly and the only device-touching test is #[ignore]d. + - name: System dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libasound2-dev pkg-config + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + key: gate + - uses: taiki-e/install-action@v2 + with: + tool: just + + - name: just fmt-check + run: just fmt-check + # `always()` between sibling gates so one failure never masks another — + # you get every failure in one run instead of a round trip per gate. + - name: just lint + if: always() + run: just lint + - name: just test + if: always() + run: just test + - name: just test-features + if: always() + run: just test-features + + msrv: + # Pins the declared floor so a transitive dependency cannot raise the real + # MSRV silently. It already did once, undetected: the workspace declared + # 1.85 while the lockfile needed 1.88 (darling 0.23). Nothing caught it + # because nothing had ever built at the floor. + name: MSRV 1.88 + runs-on: ${{ vars.GMF_LINUX_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 30 + env: + CARGO_INCREMENTAL: 0 + steps: + - uses: actions/checkout@v4 + - name: System dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libasound2-dev pkg-config + - uses: dtolnay/rust-toolchain@1.88.0 + - uses: Swatinem/rust-cache@v2 + with: + key: msrv + - name: cargo check at the declared floor + run: cargo check --workspace --all-targets --locked + + coverage: + name: coverage (77% line floor) + runs-on: ${{ vars.GMF_LINUX_RUNNER || 'ubuntu-latest' }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: System dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libasound2-dev pkg-config + - uses: dtolnay/rust-toolchain@stable + with: + # cargo-llvm-cov hard-requires this component. + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + with: + # Coverage is an instrumented build in target/llvm-cov-target. Keep it + # off the gate job's cache or the two thrash each other every run. + key: coverage + - uses: taiki-e/install-action@v2 + with: + # cargo-llvm-cov is PINNED on purpose. newt-agent #100 found 0.8.5 + # silently exiting 0 when --fail-under-lines is combined with + # --lcov --output-path — the gate stopped gating and nobody noticed. + # 0.8.7 was verified to exit 1 on an unmet floor. If you ever unpin, + # replace --fail-under-lines with a shell parse of the TOTAL row. + tool: just,cargo-llvm-cov@0.8.7 + + # Human-readable number in the log, so a near-floor commit is visible + # BEFORE it goes red. The floor itself is enforced by the next step. + - name: Coverage summary + run: cargo llvm-cov --workspace --locked --summary-only + + - name: just cov-ci + run: just cov-ci + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: lcov + path: lcov.info + if-no-files-found: warn diff --git a/.github/workflows/generator-generic-ossf-slsa3-publish.yml b/.github/workflows/generator-generic-ossf-slsa3-publish.yml deleted file mode 100644 index 35c829b..0000000 --- a/.github/workflows/generator-generic-ossf-slsa3-publish.yml +++ /dev/null @@ -1,66 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# This workflow lets you generate SLSA provenance file for your project. -# The generation satisfies level 3 for the provenance requirements - see https://slsa.dev/spec/v0.1/requirements -# The project is an initiative of the OpenSSF (openssf.org) and is developed at -# https://github.com/slsa-framework/slsa-github-generator. -# The provenance file can be verified using https://github.com/slsa-framework/slsa-verifier. -# For more information about SLSA and how it improves the supply-chain, visit slsa.dev. - -name: SLSA generic generator -on: - workflow_dispatch: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - outputs: - digests: ${{ steps.hash.outputs.digests }} - - steps: - - uses: actions/checkout@v4 - - # ======================================================== - # - # Step 1: Build your artifacts. - # - # ======================================================== - - name: Build artifacts - run: | - # These are some amazing artifacts. - echo "artifact1" > artifact1 - echo "artifact2" > artifact2 - - # ======================================================== - # - # Step 2: Add a step to generate the provenance subjects - # as shown below. Update the sha256 sum arguments - # to include all binaries that you generate - # provenance for. - # - # ======================================================== - - name: Generate subject for provenance - id: hash - run: | - set -euo pipefail - - # List the artifacts the provenance will refer to. - files=$(ls artifact*) - # Generate the subjects (base64 encoded). - echo "hashes=$(sha256sum $files | base64 -w0)" >> "${GITHUB_OUTPUT}" - - provenance: - needs: [build] - permissions: - actions: read # To read the workflow path. - id-token: write # To sign the provenance. - contents: write # To add assets to a release. - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.4.0 - with: - base64-subjects: "${{ needs.build.outputs.digests }}" - upload-assets: true # Optional: Upload to a new release diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 9fd45e0..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Rust - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index 8e9fce0..187774f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,9 @@ members = [ [workspace.package] version = "0.1.0" edition = "2021" -rust-version = "1.85" # floor set by content-addressable 0.1.2 +rust-version = "1.88" # real floor: darling 0.23 in the lockfile. +# Verified in a clean container — 1.85 does NOT build. +# Enforced by the `msrv` job in .github/workflows/ci.yml. license = "Apache-2.0" repository = "https://github.com/hartsock/monitor-agent" homepage = "https://github.com/hartsock/monitor-agent" diff --git a/justfile b/justfile index 7d862a8..a773d24 100644 --- a/justfile +++ b/justfile @@ -1,15 +1,19 @@ # monitor-agent — task runner # -# PIPELINE PARITY: This justfile is the local mirror of the CI pipeline -# defined at .github/workflows/ci.yml. The pre-push hook at -# .githooks/pre-push calls `just check` and `just cov-ci` — keep all -# three in lock-step. +# PIPELINE PARITY: this justfile is the SINGLE SOURCE OF TRUTH for the gate. +# .github/workflows/ci.yml invokes these same recipes as separate steps +# (`just fmt-check`, `just lint`, `just test`, `just test-features`, +# `just cov-ci`), and .githooks/pre-push runs `just check` + `just cov-ci`. +# +# The command strings and the coverage floor live HERE and nowhere else. +# CI does not re-type them, so editing a recipe updates both gates at once — +# parity holds by construction rather than by comment. # # Quick reference: # just — list available recipes -# just check — full local gate (fmt + clippy + test) +# just check — full local gate (fmt + clippy + test + features) # just cov — HTML coverage report (local review) -# just cov-ci — coverage with 78% floor, lcov output (CI mode) +# just cov-ci — coverage with 77% floor, lcov output (CI mode) # just install — build release binary to ~/bin # just install-hooks — wire .githooks/ as the repo's hooks path @@ -37,21 +41,36 @@ clean: # --- Test --- test: - cargo test --workspace + cargo test --workspace --locked + +# The object-capability identity layer is behind `--features newt`, so +# `cargo test --workspace` never compiles it: monitor-station/tests/identity.rs +# is `#![cfg(feature = "newt")]` and the default gate produces a test binary +# that is not even listed in the output. Four tests covering read-only key +# minting and caveat attenuation had therefore never run under ANY gate — +# a vacuous green on security code. This recipe is what makes them real. +# +# Run the feature-gated object-capability identity tests. +test-features: + cargo test -p monitor-station --features newt --locked # --- Lint & format --- fmt: cargo fmt --all +# Check formatting without rewriting anything. +fmt-check: + cargo fmt --all -- --check + lint: - cargo clippy --workspace --all-targets -- -D warnings + cargo clippy --workspace --all-targets --locked -- -D warnings -# Full local gate — must match .github/workflows/ci.yml. -check: - cargo fmt --all -- --check - cargo clippy --workspace --all-targets -- -D warnings - cargo test --workspace +# Full local gate. The recipe dependencies ARE the gate; ci.yml runs these +# same four recipes as separate steps so one failure cannot mask another. +# +# Full local gate: fmt + clippy + test + feature-gated tests. +check: fmt-check lint test test-features # --- Coverage --- @@ -61,25 +80,35 @@ cov: @echo "Report: target/llvm-cov/html/index.html" # CI-mode coverage: enforce the line coverage floor, emit lcov. -# PIPELINE PARITY: must match the coverage job in .github/workflows/ci.yml. +# PIPELINE PARITY: ci.yml's `coverage` job runs THIS recipe verbatim. The floor +# lives here only; CI never restates the number. # # On macOS (Homebrew Rust), llvm-tools-preview is unavailable via rustup. # Set LLVM_COV and LLVM_PROFDATA to the Homebrew LLVM binaries: # export LLVM_COV=/opt/homebrew/opt/llvm/bin/llvm-cov # export LLVM_PROFDATA=/opt/homebrew/opt/llvm/bin/llvm-profdata # -# The floor RATCHETS UP — never down. Current baseline: 78%. +# The floor RATCHETS UP — never down, and it is measured WHERE IT IS ENFORCED: +# on a CI runner, not on a developer box. +# +# 78 -> 77 on 2026-09-06, which is a correction, not a relaxation. 78 was +# calibrated on gnuc, which measures 78.06% — but a clean ubuntu runner +# measures 77.68% on the identical commit. The 0.38-point gap is not a code +# difference: `monitor-alert/src/voice.rs` probes for TTS binaries, and gnuc +# has `piper` and `espeak-ng` installed while a runner does not, so branches +# that execute here never execute there. Coverage measured locally is inflated +# by whatever the developer happens to have installed. +# +# So CI is the reference environment. Local runs will read ~0.4 points HIGH; +# do not calibrate from them. # -# 76 -> 78, raised by this branch. See this recipe's history for why 80 was -# never actually enforced; main measures 76.58%. `monitor-journal` lands at -# 91.79% line coverage and carries the workspace to 78.06%, so 78 is a floor -# that holds the moment it is set. +# Next move on this number is to make the measurement environment-independent +# (stub the binary probe in voice.rs) rather than to chase it. Largest single +# gap remains monitor-gui/src/lib.rs at 58%. # -# That is the rule this encodes: a new crate should RAISE this number, not -# coast underneath it. Raise it further as coverage improves; the largest -# single gap is monitor-gui/src/lib.rs at 58%. +# Coverage with the enforced line floor, lcov output (CI mode). cov-ci: - cargo llvm-cov --workspace --lcov --output-path lcov.info --fail-under-lines 78 + cargo llvm-cov --workspace --locked --lcov --output-path lcov.info --fail-under-lines 77 # --- Hook installation ---