From 750b7e912f0652a25ea7d2bbcfbd287bd20a2d90 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Fri, 23 Oct 2026 16:27:03 -0300 Subject: [PATCH 1/2] ci(workflows): cut PR regression gates wall time (refs #3184) - Split the IS #1850 release build out of the three rust-regression jobs into issue-1850-release-windows (light tier, gate-release cache), issue-1850-release-linux and issue-1850-release-macos (full tier). - cache-warm warm-release also compiles the release test harnesses. - New light-tier job rust-slow-test-windows runs the ~16 min source-transition driver test; rust-regression-windows skips it. - Drop cargo check from the three Rust jobs. - ci:full is one-shot: only the run started by adding the label goes full. - macOS #1938 step timeout 10 -> 20 min. - Rename rust-regression -> rust-regression-windows, windows-release-cli-smoke -> release-cli-smoke-windows, issue-1850-windows-profile -> issue-1850-profile-windows, rust-linux-release-parity -> rust-release-parity-linux, verify-*-cache -> verify-warm-*; quote six step names YAML cut at " #". - Add npm run ci:local (scripts/ci-local.mjs) and document it. --- .github/workflows/cache-warm.yml | 21 +- .github/workflows/pr-regression-gates.yml | 641 ++++++++++++++-------- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 12 + docs/quality/cognitive-complexity-gate.md | 29 +- package.json | 1 + scripts/ci-local.mjs | 145 +++++ src-tauri/src/cli/mod.rs | 2 +- src-tauri/src/pty/job.rs | 2 +- 9 files changed, 601 insertions(+), 254 deletions(-) create mode 100644 scripts/ci-local.mjs diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index 4211f9c34..34c330dec 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -37,7 +37,7 @@ jobs: - name: Build frontend assets for Tauri config validation run: npm run build - - name: Install Rust stable + - name: Install Rust 1.98.1 uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234 with: components: clippy @@ -110,8 +110,17 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: npm run build:prod:no-bundle - verify-debug-cache: - name: verify-debug-cache + # The release test harnesses pull in dev-dependencies and their features, + # which the binary build above never compiles. Warm them too, so + # issue-1850-release-windows and issue-1850-profile-windows (release) + # restore them instead of compiling them on every pull request. + - name: cargo test --release (compile only) + if: steps.cache.outputs.cache-hit != 'true' + working-directory: src-tauri + run: cargo test --locked --release --lib --test issue_1850_default_root --no-run + + verify-warm-debug: + name: verify-warm-debug needs: warm-debug runs-on: windows-latest permissions: @@ -120,7 +129,7 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install Rust stable + - name: Install Rust 1.98.1 uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234 with: components: clippy @@ -142,8 +151,8 @@ jobs: Write-Error "No exact gate-debug cache entry exists after the warm job. The warm job's save step failed silently or the key diverged. PR gates will run cold until this is fixed." exit 1 - verify-release-cache: - name: verify-release-cache + verify-warm-release: + name: verify-warm-release needs: warm-release runs-on: windows-latest permissions: diff --git a/.github/workflows/pr-regression-gates.yml b/.github/workflows/pr-regression-gates.yml index c6a4f29c4..8475c4e67 100644 --- a/.github/workflows/pr-regression-gates.yml +++ b/.github/workflows/pr-regression-gates.yml @@ -14,10 +14,12 @@ concurrency: cancel-in-progress: true jobs: - # Light tier runs on every event: rust-regression (Windows, the only job - # above a 2.5% failure rate), plus rust-fmt and test-debt (cheap). - # Full tier adds every other job on every 10th run, on the `ci:full` - # label, or on a manual dispatch. See #2417 for the failure-rate sample. + # Light tier runs on every event: rust-regression-windows, rust-slow-test-windows and + # issue-1850-release-windows (Windows, the only leg above a 2.5% failure + # rate), clippy-linux, plus rust-fmt and test-debt (cheap). + # Full tier adds every other job on every 10th run, on the run started by + # adding the `ci:full` label, or on a manual dispatch. See #2417 for the + # failure-rate sample. ci-tier: name: ci-tier runs-on: ubuntu-latest @@ -28,10 +30,13 @@ jobs: shell: bash env: RUN_NUMBER: ${{ github.run_number }} - HAS_FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }} + # One-shot: only the run started by adding `ci:full` goes full. Later + # pushes to a labelled pull request stay light; remove and re-add the + # label to run the full tier again on the new head. + ADDED_FULL_LABEL: ${{ github.event.action == 'labeled' && github.event.label.name == 'ci:full' }} run: | full=false - if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] || [ "$HAS_FULL_LABEL" = true ] || [ $((RUN_NUMBER % 10)) -eq 0 ]; then + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] || [ "$ADDED_FULL_LABEL" = true ] || [ $((RUN_NUMBER % 10)) -eq 0 ]; then full=true fi echo "run_number=$RUN_NUMBER full=$full" @@ -124,8 +129,8 @@ jobs: npm run sanitizing:global_state -- --base-ref "${GATE_BASE_REF:-origin/main}" npm run sanitizing:process_env -- --base-ref "${GATE_BASE_REF:-origin/main}" - rust-regression: - name: rust-regression + rust-regression-windows: + name: rust-regression-windows # Skip branch-deletion push events while still requiring pull request validation. if: github.event_name != 'push' || (github.event.deleted != true && github.event.after != '0000000000000000000000000000000000000000') runs-on: windows-latest @@ -147,7 +152,7 @@ jobs: - name: Build frontend assets for Tauri config validation run: npm run build - - name: Install Rust stable + - name: Install Rust 1.98.1 uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234 with: components: clippy @@ -222,10 +227,6 @@ jobs: retention-days: 90 if-no-files-found: error - - name: cargo check - working-directory: src-tauri - run: cargo check --locked --all-targets - # IS #2234: one run serves both purposes. --force-warn emits the cognitive # diagnostics without failing the build and without being silenceable by a lint # attribute, while -D warnings still denies every other lint. Asking cargo for the @@ -273,9 +274,12 @@ jobs: path: ${{ runner.temp }}/cog-observed-windows.json if-no-files-found: warn + # The one test skipped here runs alone in rust-slow-test-windows: on Windows it + # takes about 16 minutes while every other test finishes within 9. Both jobs + # use the same name, so a test is either skipped here and run there, or run here. - name: cargo test working-directory: src-tauri - run: cargo test --locked --lib --bins --tests --no-fail-fast + run: cargo test --locked --lib --bins --tests --no-fail-fast -- --skip single_editor_driver_nonempty_actual_pair_guard_cuts_resume_original_packets # The path-identity reparse test uses simulated flags; real Windows # reparse metadata and no-follow integration are outside its coverage. @@ -283,7 +287,7 @@ jobs: # refusal safety only. Refusal safety and the mechanism control moved off # the lib target (whose `ISSUE1850_REFUSAL_ONLY` printer this round # deletes) onto the integration target that actually runs here. - - name: "cargo test (IS #1850 default-route acceptance, integration)" + - name: "cargo test (IS #1850 default-root acceptance, integration)" working-directory: src-tauri shell: bash timeout-minutes: 20 @@ -342,12 +346,111 @@ jobs: exit 1 fi + # The slowest Windows test, split out of rust-regression-windows so its run + # overlaps that job instead of extending it. Same toolchain pin and gate-debug + # cache as that job. A rename of the test fails the guards below instead of + # silently running nothing. + rust-slow-test-windows: + name: rust-slow-test-windows + # Skip branch-deletion push events while still requiring pull request validation. + if: github.event_name != 'push' || (github.event.deleted != true && github.event.after != '0000000000000000000000000000000000000000') + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: 'npm' + + - name: Pin npm version + run: npm install -g npm@11.6.2 + + - name: Install frontend dependencies + run: npm ci + + - name: Build frontend assets for Tauri config validation + run: npm run build + + - name: Install Rust 1.98.1 + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234 + with: + components: clippy + toolchain: '1.98.1' + + - name: Rust cache + uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: '. -> target' + shared-key: 'gate-debug' + env-vars: 'ImageOS' + save-if: 'false' + + - name: cargo test (slow source-transition driver test) + working-directory: src-tauri + shell: bash + timeout-minutes: 40 + run: | + set -euo pipefail + TEST='single_editor_driver_nonempty_actual_pair_guard_cuts_resume_original_packets' + cargo test --locked --lib "$TEST" 2>&1 | tee slow-test.log + grep -qE "^test [^ ]*::${TEST} \.\.\. ok$" slow-test.log || { + echo "::error::${TEST} never ran; it was renamed, moved, or gated out. Update the name here and in rust-regression-windows." + exit 1 + } + grep -qE '^test result: ok\. 1 passed; 0 failed' slow-test.log || { + echo "::error::expected exactly one passing test for ${TEST}." + exit 1 + } + + # IS #1850 focused release tests (Windows), split out of rust-regression-windows so + # their release compile runs in parallel with the debug suite instead of + # after it; the steps and assertions are unchanged. It restores gate-release, + # the release dependency cache cache-warm.yml saves on main, so it installs + # the same floating stable toolchain as that cache and as release.yml. + issue-1850-release-windows: + name: issue-1850-release-windows + # Skip branch-deletion push events while still requiring pull request validation. + if: github.event_name != 'push' || (github.event.deleted != true && github.event.after != '0000000000000000000000000000000000000000') + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: 'npm' + + - name: Pin npm version + run: npm install -g npm@11.6.2 + + - name: Install frontend dependencies + run: npm ci + + - name: Build frontend assets for Tauri config validation + run: npm run build + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: x86_64-pc-windows-msvc + + - name: Rust cache + uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: '. -> target' + shared-key: 'gate-release' + env-vars: 'ImageOS' + save-if: 'false' + # IS #1929: the release step below used to compile and run under a single # 20-minute bound, so on a cold cache the build alone could consume the # whole budget and the step could die after the tests had already passed # (#1917: the same tree was killed at 20m00s on a push run and went green # in 17m25s on a pull request run). Split it the way the dedicated - # `issue-1850-windows-profile` job already splits its build and proof: + # `issue-1850-profile-windows` job already splits its build and proof: # the build gets room, the run keeps the tight assertion bound. Launch the # exact Cargo-reported release harness directly, bypassing Cargo freshness # assessment and application compilation during assertions. @@ -861,7 +964,7 @@ jobs: - name: Build frontend assets for Tauri config validation run: npm run build - - name: Install Rust stable + - name: Install Rust 1.98.1 uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234 with: components: clippy @@ -913,10 +1016,6 @@ jobs: path: ${{ runner.temp }}/cog-observed-linux.json if-no-files-found: warn - # IS #1938: cross-process local-config write-lock proof. pipefail catches a - # failing test, the identifier greps catch a filter that selected nothing - # or a renamed test, and the anchored result line requires a positive - # passing count with zero failures. rust-regression-linux: name: rust-regression-linux # Skip branch-deletion push events while still requiring pull request validation. @@ -958,7 +1057,7 @@ jobs: - name: Build frontend assets for Tauri config validation run: npm run build - - name: Install Rust stable + - name: Install Rust 1.98.1 uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234 with: components: clippy @@ -970,17 +1069,10 @@ jobs: workspaces: . -> target key: rust-regression-linux - - name: cargo check - working-directory: src-tauri - run: cargo check --locked --all-targets - - # IS #2234: one run serves both purposes. --force-warn emits the cognitive - # diagnostics without failing the build and without being silenceable by a lint - # attribute, while -D warnings still denies every other lint. Asking cargo for the - # JSON message format moves the human text into each record, so the script reprints it. - # `set +e` is mandatory: Actions runs `bash --noprofile --norc -eo pipefail`, so - # without it the step dies on the redirect line when clippy fails and the - # developer sees no diagnostics at all. + # IS #1938: cross-process local-config write-lock proof. pipefail catches a + # failing test, the identifier greps catch a filter that selected nothing + # or a renamed test, and the anchored result line requires a positive + # passing count with zero failures. - name: "cargo test (IS #1938 config write locks, focused)" working-directory: src-tauri shell: bash @@ -1013,7 +1105,7 @@ jobs: # Only this one test runs here, by name. The suite runs on the Windows # leg; enabling it wholesale on Linux would light up #[cfg(unix)] tests # that have compiled for months and never once executed. - - name: cargo test (IS #1577 primary case) + - name: "cargo test (IS #1577 primary case)" working-directory: src-tauri shell: bash run: | @@ -1073,7 +1165,7 @@ jobs: # also match api/mod.rs's window-screenshot limiter tests — gated # `any(target_os = "windows", test)`, compiled for months, never run on # Linux, and lighting those up is what the comment above forbids. - - name: cargo test (IS #1842 screenshot module on Linux) + - name: "cargo test (IS #1842 screenshot module on Linux)" working-directory: src-tauri shell: bash run: | @@ -1194,7 +1286,7 @@ jobs: set -euo pipefail FILTER='issue_2683' # T1-T6 all compile on this unix leg. The Windows leg of - # rust-regression has no such step because T1 and T2 are cfg(unix). + # rust-regression-windows has no such step because T1 and T2 are cfg(unix). # Update EXPECTED and the name list below in the same commit that # adds, removes or renames an issue_2683 test. EXPECTED=6 @@ -1309,12 +1401,153 @@ jobs: exit 1 } + # IS #1850: the debug acceptance target on a native Linux filesystem. The + # copied product binary must select the injected HOME, run all seven + # cases, and materialise app.log/.gitignore only under + # /.agentscommander. Release behaviour of this route differs only + # by the debug-only override being compiled out, which the Windows + # real-profile release leg and the release focused lib tests cover. + - name: "cargo test (IS #1850 default-root acceptance, debug)" + working-directory: src-tauri + shell: bash + timeout-minutes: 30 + run: | + set -euo pipefail + cargo test --locked --test issue_1850_default_root -- --test-threads=1 --nocapture 2>&1 | tee test-1850-default-root.log + for name in issue_1850_admission_gate_table issue_1850_default_root_acceptance; do + grep -qF "test ${name} ..." test-1850-default-root.log || { echo "::error::${name} never ran"; exit 1; } + done + grep -qE '^ISSUE1850_MECHANISM_OK' test-1850-default-root.log || { echo "::error::copied-binary mechanism control did not report success"; exit 1; } + for case in adjacent-legacy-alone home-new-alone home-new-dev-alone all-legacy-conflicting \ + canonical-valid-settings canonical-invalid-settings canonical-absent-without-marker; do + grep -qE "^ISSUE1850_CASE_OK case=${case}$" test-1850-default-root.log || { echo "::error::missing case record ${case}"; exit 1; } + done + test "$(grep -oE '^ISSUE1850_CASE_OK case=[^ ]+$' test-1850-default-root.log | sort -u | wc -l)" -eq 7 || { echo "::error::expected 7 unique case records"; exit 1; } + grep -qE '^ISSUE1850_DEFAULT_ROOT_PROOF_OK cases=7$' test-1850-default-root.log || { echo "::error::final proof record missing"; exit 1; } + grep -qE '^test result: ok\. 2 passed; 0 failed' test-1850-default-root.log || { echo "::error::expected exactly two passing tests"; exit 1; } + grep -qE '^ISSUE1850_REFUSAL_ONLY' test-1850-default-root.log && { echo "::error::refusal-only route taken on a non-Windows leg"; exit 1; } || true + grep -qE '^ISSUE1850_REFUSAL_SELFCONTROL_OK entries=3$' test-1850-default-root.log || { echo "::error::the refusal census self-control did not report success"; exit 1; } + if grep -qF 'ISSUE1850_REFUSAL_OCCUPIED' test-1850-default-root.log; then + echo "::error::the hosted runner profile is not clean; the occupied refusal route was taken" + exit 1 + fi + + # IS #2589: agent binaries resolve through the composed search path. The + # Phase 2 and integration tests are cfg(unix), so they run only here. + - name: "cargo test (IS #2589 agent PATH resolution, focused)" + working-directory: src-tauri + shell: bash + timeout-minutes: 15 + run: | + set -euo pipefail + # The prefix is the filter: a bare `agent_path` also matches the + # pre-existing `replica_creation_rejects_filesystem_agent_paths` + # (commands/entity_creation.rs:4941). + FILTER=agent_path_2589_ + # 8 platform-neutral + 11 cfg(unix) tests. Update in the same commit + # that adds or removes an agent_path_2589_ test. + EXPECTED=19 + cargo test --locked --lib "$FILTER" -- --test-threads=1 --nocapture 2>&1 | tee test-2589.log + + # Three guards, none redundant: + # 1. pipefail (LOAD-BEARING through the tee) catches a test that ran + # and FAILED; without it the step exits with tee's code. + # 2. The sentinel grep is the POSITIVE CONTROL: a filter matching + # nothing exits 0 and prints "0 passed". This sentinel is + # cfg(unix), so it also proves the unix half ran. + # 3. The anchored count is the MUTATION PROBE: gating a test out + # moves the number. + grep -qF 'agent_path_2589_install_probe_resolves_an_env_shebang_interpreter' test-2589.log || { + echo "::error::the sentinel test never ran; the filter matched nothing or it was renamed." + exit 1 + } + grep -qE "^test result: ok\. ${EXPECTED} passed; 0 failed" test-2589.log || { + echo "::error::expected exactly ${EXPECTED} passing tests in ${FILTER}; the count changed or the filter matched something else." + exit 1 + } + + # #2994 (E2992): the settings-owner tests in the lib crate. Windows already + # runs them through --lib --bins --tests; this leg lists the filter, refuses + # an empty list, then runs exactly that list serially. + - name: "cargo test (E2992 settings owner, focused)" + working-directory: src-tauri + shell: bash + timeout-minutes: 20 + run: | + set -euo pipefail + FILTER=e2992_ + cargo test --locked --lib "$FILTER" -- --list 2>&1 | tee test-e2992-list.log + COUNT="$(grep -cE ': test$' test-e2992-list.log || true)" + if [ "$COUNT" -eq 0 ]; then + echo "::error::the ${FILTER} filter lists no tests." + exit 1 + fi + echo "listed ${COUNT} ${FILTER} tests" + cargo test --locked --lib "$FILTER" -- --test-threads=1 2>&1 | tee test-e2992.log + grep -qE "^test result: ok\. ${COUNT} passed; 0 failed" test-e2992.log || { + echo "::error::expected exactly ${COUNT} passing ${FILTER} tests." + exit 1 + } + + # IS #1850 focused release tests (Linux), split out of rust-regression-linux + # so their release compile runs in parallel; steps and assertions unchanged. + # Floating stable, like every other release build in CI and release.yml. + issue-1850-release-linux: + name: issue-1850-release-linux + # Skip branch-deletion push events while still requiring pull request validation. + needs: ci-tier + if: needs.ci-tier.outputs.full == 'true' && (github.event_name != 'push' || (github.event.deleted != true && github.event.after != '0000000000000000000000000000000000000000')) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Tauri system dependencies + run: | + sudo apt-get update + # The four original packages are Tauri's own. The rest are the #1842 + # xcap native chain: bindgen needs libclang; system-deps needs the + # pkg-config metadata for libpipewire-0.3 AND libspa-0.2 (E1 proved + # libspa-0.2-dev is a SEPARATE package that only arrives + # transitively, so listing it is what stops a future dependency + # change breaking the build silently); libwayshot-xcap needs + # gbm/drm/EGL; xcb needs libxcb + randr. + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \ + build-essential pkg-config clang libclang-dev \ + libpipewire-0.3-dev libspa-0.2-dev \ + libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev \ + libgbm-dev libdrm-dev libegl1-mesa-dev libdbus-1-dev + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: 'npm' + + - name: Pin npm version + run: npm install -g npm@11.6.2 --ignore-scripts + + - name: Install frontend dependencies + run: npm ci + + - name: Build frontend assets for Tauri config validation + run: npm run build + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Rust cache + uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: . -> target + key: issue-1850-release-linux + # IS #1929: the release step below used to compile and run under a single # 20-minute bound, so on a cold cache the build alone could consume the # whole budget and the step could die after the tests had already passed # (#1917: the same tree was killed at 20m00s on a push run and went green # in 17m25s on a pull request run). Split it the way the dedicated - # `issue-1850-windows-profile` job already splits its build and proof: + # `issue-1850-profile-windows` job already splits its build and proof: # the build gets room, the run keeps the tight assertion bound. Launch the # exact Cargo-reported release harness directly, bypassing Cargo freshness # assessment and application compilation during assertions. @@ -1780,96 +2013,8 @@ jobs: compression-level: 6 include-hidden-files: false - # IS #1850: the debug acceptance target on a native Linux filesystem. The - # copied product binary must select the injected HOME, run all seven - # cases, and materialise app.log/.gitignore only under - # /.agentscommander. Release behaviour of this route differs only - # by the debug-only override being compiled out, which the Windows - # real-profile release leg and the release focused lib tests cover. - - name: "cargo test (IS #1850 default-root acceptance, debug)" - working-directory: src-tauri - shell: bash - timeout-minutes: 30 - run: | - set -euo pipefail - cargo test --locked --test issue_1850_default_root -- --test-threads=1 --nocapture 2>&1 | tee test-1850-default-root.log - for name in issue_1850_admission_gate_table issue_1850_default_root_acceptance; do - grep -qF "test ${name} ..." test-1850-default-root.log || { echo "::error::${name} never ran"; exit 1; } - done - grep -qE '^ISSUE1850_MECHANISM_OK' test-1850-default-root.log || { echo "::error::copied-binary mechanism control did not report success"; exit 1; } - for case in adjacent-legacy-alone home-new-alone home-new-dev-alone all-legacy-conflicting \ - canonical-valid-settings canonical-invalid-settings canonical-absent-without-marker; do - grep -qE "^ISSUE1850_CASE_OK case=${case}$" test-1850-default-root.log || { echo "::error::missing case record ${case}"; exit 1; } - done - test "$(grep -oE '^ISSUE1850_CASE_OK case=[^ ]+$' test-1850-default-root.log | sort -u | wc -l)" -eq 7 || { echo "::error::expected 7 unique case records"; exit 1; } - grep -qE '^ISSUE1850_DEFAULT_ROOT_PROOF_OK cases=7$' test-1850-default-root.log || { echo "::error::final proof record missing"; exit 1; } - grep -qE '^test result: ok\. 2 passed; 0 failed' test-1850-default-root.log || { echo "::error::expected exactly two passing tests"; exit 1; } - grep -qE '^ISSUE1850_REFUSAL_ONLY' test-1850-default-root.log && { echo "::error::refusal-only route taken on a non-Windows leg"; exit 1; } || true - grep -qE '^ISSUE1850_REFUSAL_SELFCONTROL_OK entries=3$' test-1850-default-root.log || { echo "::error::the refusal census self-control did not report success"; exit 1; } - if grep -qF 'ISSUE1850_REFUSAL_OCCUPIED' test-1850-default-root.log; then - echo "::error::the hosted runner profile is not clean; the occupied refusal route was taken" - exit 1 - fi - - # IS #2589: agent binaries resolve through the composed search path. The - # Phase 2 and integration tests are cfg(unix), so they run only here. - - name: "cargo test (IS #2589 agent PATH resolution, focused)" - working-directory: src-tauri - shell: bash - timeout-minutes: 15 - run: | - set -euo pipefail - # The prefix is the filter: a bare `agent_path` also matches the - # pre-existing `replica_creation_rejects_filesystem_agent_paths` - # (commands/entity_creation.rs:4941). - FILTER=agent_path_2589_ - # 8 platform-neutral + 11 cfg(unix) tests. Update in the same commit - # that adds or removes an agent_path_2589_ test. - EXPECTED=19 - cargo test --locked --lib "$FILTER" -- --test-threads=1 --nocapture 2>&1 | tee test-2589.log - - # Three guards, none redundant: - # 1. pipefail (LOAD-BEARING through the tee) catches a test that ran - # and FAILED; without it the step exits with tee's code. - # 2. The sentinel grep is the POSITIVE CONTROL: a filter matching - # nothing exits 0 and prints "0 passed". This sentinel is - # cfg(unix), so it also proves the unix half ran. - # 3. The anchored count is the MUTATION PROBE: gating a test out - # moves the number. - grep -qF 'agent_path_2589_install_probe_resolves_an_env_shebang_interpreter' test-2589.log || { - echo "::error::the sentinel test never ran; the filter matched nothing or it was renamed." - exit 1 - } - grep -qE "^test result: ok\. ${EXPECTED} passed; 0 failed" test-2589.log || { - echo "::error::expected exactly ${EXPECTED} passing tests in ${FILTER}; the count changed or the filter matched something else." - exit 1 - } - - # #2994 (E2992): the settings-owner tests in the lib crate. Windows already - # runs them through --lib --bins --tests; this leg lists the filter, refuses - # an empty list, then runs exactly that list serially. - - name: "cargo test (E2992 settings owner, focused)" - working-directory: src-tauri - shell: bash - timeout-minutes: 20 - run: | - set -euo pipefail - FILTER=e2992_ - cargo test --locked --lib "$FILTER" -- --list 2>&1 | tee test-e2992-list.log - COUNT="$(grep -cE ': test$' test-e2992-list.log || true)" - if [ "$COUNT" -eq 0 ]; then - echo "::error::the ${FILTER} filter lists no tests." - exit 1 - fi - echo "listed ${COUNT} ${FILTER} tests" - cargo test --locked --lib "$FILTER" -- --test-threads=1 2>&1 | tee test-e2992.log - grep -qE "^test result: ok\. ${COUNT} passed; 0 failed" test-e2992.log || { - echo "::error::expected exactly ${COUNT} passing ${FILTER} tests." - exit 1 - } - - rust-linux-release-parity: - name: rust-linux-release-parity + rust-release-parity-linux: + name: rust-release-parity-linux # #1842: the Linux PR gate runs ubuntu-latest (24.04) while the Linux # RELEASE build runs ubuntu-22.04 (release.yml). A green PR gate therefore # did not prove the release build. This job compiles AND LINKS src-tauri on @@ -1953,7 +2098,7 @@ jobs: # minimum glibc, which is the user's decision, and the second is # unnecessary (measured) and would put 1.0.5 headers in front of a # 0.3.48 .so at link time. - - name: Use noble libspa-0.2 headers on ubuntu-22.04 (IS #1915) + - name: "Use noble libspa-0.2 headers on ubuntu-22.04 (IS #1915)" shell: bash env: SPA_DEB: libspa-0.2-dev_1.0.5-1ubuntu3.3_amd64.deb @@ -2030,7 +2175,7 @@ jobs: uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: '. -> target' - key: rust-linux-release-parity + key: rust-release-parity-linux - name: cargo build --release (links the binary release.yml ships) working-directory: src-tauri @@ -2104,7 +2249,7 @@ jobs: # libxcb-randr.so.0 either: xcb 1.7.0 builds randr requests over the core # connection and emits no -lxcb-randr. Round 13 asserted both and both # are measurably absent. - - name: Prove the xcap chain reached the release binary (IS #1915) + - name: "Prove the xcap chain reached the release binary (IS #1915)" shell: bash run: | set -euo pipefail @@ -2155,7 +2300,7 @@ jobs: - name: Build frontend assets for Tauri config validation run: npm run build - - name: Install Rust stable + - name: Install Rust 1.98.1 uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # pinned for #2234 with: components: clippy @@ -2167,10 +2312,6 @@ jobs: workspaces: . -> target key: rust-regression-macos - - name: cargo check - working-directory: src-tauri - run: cargo check --locked --all-targets - # IS #2234: one run serves both purposes. --force-warn emits the cognitive # diagnostics without failing the build and without being silenceable by a lint # attribute, while -D warnings still denies every other lint. Asking cargo for the @@ -2221,7 +2362,7 @@ jobs: # IS #2079: compiling is not running. Without this step the macOS leg # never executes the screenshot module, so the CoreGraphics link proof # and the permission-gate assertions would be missing evidence. - - name: cargo test (IS #2079 screenshot module on macOS) + - name: "cargo test (IS #2079 screenshot module on macOS)" working-directory: src-tauri shell: bash timeout-minutes: 20 @@ -2266,7 +2407,7 @@ jobs: - name: "cargo test (IS #1938 config write locks, focused)" working-directory: src-tauri shell: bash - timeout-minutes: 10 + timeout-minutes: 20 run: | set -euo pipefail cargo test --locked --lib issue_1937_config_lock -- --test-threads=1 --nocapture 2>&1 | tee issue-1937-config-lock.log @@ -2310,12 +2451,132 @@ jobs: exit 1 } + # IS #1850: the debug acceptance target on macOS. Same contract as the + # Linux step; no real-profile case and no refusal-only route here. + - name: "cargo test (IS #1850 default-root acceptance, debug)" + working-directory: src-tauri + shell: bash + timeout-minutes: 30 + run: | + set -euo pipefail + cargo test --locked --test issue_1850_default_root -- --test-threads=1 --nocapture 2>&1 | tee test-1850-default-root.log + for name in issue_1850_admission_gate_table issue_1850_default_root_acceptance; do + grep -qF "test ${name} ..." test-1850-default-root.log || { echo "::error::${name} never ran"; exit 1; } + done + grep -qE '^ISSUE1850_MECHANISM_OK' test-1850-default-root.log || { echo "::error::copied-binary mechanism control did not report success"; exit 1; } + for case in adjacent-legacy-alone home-new-alone home-new-dev-alone all-legacy-conflicting \ + canonical-valid-settings canonical-invalid-settings canonical-absent-without-marker; do + grep -qE "^ISSUE1850_CASE_OK case=${case}$" test-1850-default-root.log || { echo "::error::missing case record ${case}"; exit 1; } + done + test "$(grep -oE '^ISSUE1850_CASE_OK case=[^ ]+$' test-1850-default-root.log | sort -u | wc -l)" -eq 7 || { echo "::error::expected 7 unique case records"; exit 1; } + grep -qE '^ISSUE1850_DEFAULT_ROOT_PROOF_OK cases=7$' test-1850-default-root.log || { echo "::error::final proof record missing"; exit 1; } + grep -qE '^test result: ok\. 2 passed; 0 failed' test-1850-default-root.log || { echo "::error::expected exactly two passing tests"; exit 1; } + grep -qE '^ISSUE1850_REFUSAL_ONLY' test-1850-default-root.log && { echo "::error::refusal-only route taken on a non-Windows leg"; exit 1; } || true + grep -qE '^ISSUE1850_REFUSAL_SELFCONTROL_OK entries=3$' test-1850-default-root.log || { echo "::error::the refusal census self-control did not report success"; exit 1; } + if grep -qF 'ISSUE1850_REFUSAL_OCCUPIED' test-1850-default-root.log; then + echo "::error::the hosted runner profile is not clean; the occupied refusal route was taken" + exit 1 + fi + + # IS #2589: agent binaries resolve through the composed search path. The + # Phase 2 and integration tests are cfg(unix), so they run only here. + - name: "cargo test (IS #2589 agent PATH resolution, focused)" + working-directory: src-tauri + shell: bash + timeout-minutes: 15 + run: | + set -euo pipefail + # The prefix is the filter: a bare `agent_path` also matches the + # pre-existing `replica_creation_rejects_filesystem_agent_paths` + # (commands/entity_creation.rs:4941). + FILTER=agent_path_2589_ + # 8 platform-neutral + 11 cfg(unix) tests. Update in the same commit + # that adds or removes an agent_path_2589_ test. + EXPECTED=19 + cargo test --locked --lib "$FILTER" -- --test-threads=1 --nocapture 2>&1 | tee test-2589.log + + # Three guards, none redundant: + # 1. pipefail (LOAD-BEARING through the tee) catches a test that ran + # and FAILED; without it the step exits with tee's code. + # 2. The sentinel grep is the POSITIVE CONTROL: a filter matching + # nothing exits 0 and prints "0 passed". This sentinel is + # cfg(unix), so it also proves the unix half ran. + # 3. The anchored count is the MUTATION PROBE: gating a test out + # moves the number. + grep -qF 'agent_path_2589_install_probe_resolves_an_env_shebang_interpreter' test-2589.log || { + echo "::error::the sentinel test never ran; the filter matched nothing or it was renamed." + exit 1 + } + grep -qE "^test result: ok\. ${EXPECTED} passed; 0 failed" test-2589.log || { + echo "::error::expected exactly ${EXPECTED} passing tests in ${FILTER}; the count changed or the filter matched something else." + exit 1 + } + + # #2994 (E2992): the settings-owner tests in the lib crate. Windows already + # runs them through --lib --bins --tests; this leg lists the filter, refuses + # an empty list, then runs exactly that list serially. + - name: "cargo test (E2992 settings owner, focused)" + working-directory: src-tauri + shell: bash + timeout-minutes: 20 + run: | + set -euo pipefail + FILTER=e2992_ + cargo test --locked --lib "$FILTER" -- --list 2>&1 | tee test-e2992-list.log + COUNT="$(grep -cE ': test$' test-e2992-list.log || true)" + if [ "$COUNT" -eq 0 ]; then + echo "::error::the ${FILTER} filter lists no tests." + exit 1 + fi + echo "listed ${COUNT} ${FILTER} tests" + cargo test --locked --lib "$FILTER" -- --test-threads=1 2>&1 | tee test-e2992.log + grep -qE "^test result: ok\. ${COUNT} passed; 0 failed" test-e2992.log || { + echo "::error::expected exactly ${COUNT} passing ${FILTER} tests." + exit 1 + } + + # IS #1850 focused release tests (macOS), split out of rust-regression-macos + # so their release compile runs in parallel; steps and assertions unchanged. + # Floating stable, like every other release build in CI and release.yml. + issue-1850-release-macos: + name: issue-1850-release-macos + # Skip branch-deletion push events while still requiring pull request validation. + needs: ci-tier + if: needs.ci-tier.outputs.full == 'true' && (github.event_name != 'push' || (github.event.deleted != true && github.event.after != '0000000000000000000000000000000000000000')) + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: 'npm' + + - name: Pin npm version + run: npm install -g npm@11.6.2 --ignore-scripts + + - name: Install frontend dependencies + run: npm ci --ignore-scripts + + - name: Build frontend assets for Tauri config validation + run: npm run build + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Rust cache + uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: . -> target + key: issue-1850-release-macos + # IS #1929: the release step below used to compile and run under a single # 20-minute bound, so on a cold cache the build alone could consume the # whole budget and the step could die after the tests had already passed # (#1917: the same tree was killed at 20m00s on a push run and went green # in 17m25s on a pull request run). Split it the way the dedicated - # `issue-1850-windows-profile` job already splits its build and proof: + # `issue-1850-profile-windows` job already splits its build and proof: # the build gets room, the run keeps the tight assertion bound. Launch the # exact Cargo-reported release harness directly, bypassing Cargo freshness # assessment and application compilation during assertions. @@ -2781,90 +3042,6 @@ jobs: compression-level: 6 include-hidden-files: false - # IS #1850: the debug acceptance target on macOS. Same contract as the - # Linux step; no real-profile case and no refusal-only route here. - - name: "cargo test (IS #1850 default-root acceptance, debug)" - working-directory: src-tauri - shell: bash - timeout-minutes: 30 - run: | - set -euo pipefail - cargo test --locked --test issue_1850_default_root -- --test-threads=1 --nocapture 2>&1 | tee test-1850-default-root.log - for name in issue_1850_admission_gate_table issue_1850_default_root_acceptance; do - grep -qF "test ${name} ..." test-1850-default-root.log || { echo "::error::${name} never ran"; exit 1; } - done - grep -qE '^ISSUE1850_MECHANISM_OK' test-1850-default-root.log || { echo "::error::copied-binary mechanism control did not report success"; exit 1; } - for case in adjacent-legacy-alone home-new-alone home-new-dev-alone all-legacy-conflicting \ - canonical-valid-settings canonical-invalid-settings canonical-absent-without-marker; do - grep -qE "^ISSUE1850_CASE_OK case=${case}$" test-1850-default-root.log || { echo "::error::missing case record ${case}"; exit 1; } - done - test "$(grep -oE '^ISSUE1850_CASE_OK case=[^ ]+$' test-1850-default-root.log | sort -u | wc -l)" -eq 7 || { echo "::error::expected 7 unique case records"; exit 1; } - grep -qE '^ISSUE1850_DEFAULT_ROOT_PROOF_OK cases=7$' test-1850-default-root.log || { echo "::error::final proof record missing"; exit 1; } - grep -qE '^test result: ok\. 2 passed; 0 failed' test-1850-default-root.log || { echo "::error::expected exactly two passing tests"; exit 1; } - grep -qE '^ISSUE1850_REFUSAL_ONLY' test-1850-default-root.log && { echo "::error::refusal-only route taken on a non-Windows leg"; exit 1; } || true - grep -qE '^ISSUE1850_REFUSAL_SELFCONTROL_OK entries=3$' test-1850-default-root.log || { echo "::error::the refusal census self-control did not report success"; exit 1; } - if grep -qF 'ISSUE1850_REFUSAL_OCCUPIED' test-1850-default-root.log; then - echo "::error::the hosted runner profile is not clean; the occupied refusal route was taken" - exit 1 - fi - - # IS #2589: agent binaries resolve through the composed search path. The - # Phase 2 and integration tests are cfg(unix), so they run only here. - - name: "cargo test (IS #2589 agent PATH resolution, focused)" - working-directory: src-tauri - shell: bash - timeout-minutes: 15 - run: | - set -euo pipefail - # The prefix is the filter: a bare `agent_path` also matches the - # pre-existing `replica_creation_rejects_filesystem_agent_paths` - # (commands/entity_creation.rs:4941). - FILTER=agent_path_2589_ - # 8 platform-neutral + 11 cfg(unix) tests. Update in the same commit - # that adds or removes an agent_path_2589_ test. - EXPECTED=19 - cargo test --locked --lib "$FILTER" -- --test-threads=1 --nocapture 2>&1 | tee test-2589.log - - # Three guards, none redundant: - # 1. pipefail (LOAD-BEARING through the tee) catches a test that ran - # and FAILED; without it the step exits with tee's code. - # 2. The sentinel grep is the POSITIVE CONTROL: a filter matching - # nothing exits 0 and prints "0 passed". This sentinel is - # cfg(unix), so it also proves the unix half ran. - # 3. The anchored count is the MUTATION PROBE: gating a test out - # moves the number. - grep -qF 'agent_path_2589_install_probe_resolves_an_env_shebang_interpreter' test-2589.log || { - echo "::error::the sentinel test never ran; the filter matched nothing or it was renamed." - exit 1 - } - grep -qE "^test result: ok\. ${EXPECTED} passed; 0 failed" test-2589.log || { - echo "::error::expected exactly ${EXPECTED} passing tests in ${FILTER}; the count changed or the filter matched something else." - exit 1 - } - - # #2994 (E2992): the settings-owner tests in the lib crate. Windows already - # runs them through --lib --bins --tests; this leg lists the filter, refuses - # an empty list, then runs exactly that list serially. - - name: "cargo test (E2992 settings owner, focused)" - working-directory: src-tauri - shell: bash - timeout-minutes: 20 - run: | - set -euo pipefail - FILTER=e2992_ - cargo test --locked --lib "$FILTER" -- --list 2>&1 | tee test-e2992-list.log - COUNT="$(grep -cE ': test$' test-e2992-list.log || true)" - if [ "$COUNT" -eq 0 ]; then - echo "::error::the ${FILTER} filter lists no tests." - exit 1 - fi - echo "listed ${COUNT} ${FILTER} tests" - cargo test --locked --lib "$FILTER" -- --test-threads=1 2>&1 | tee test-e2992.log - grep -qE "^test result: ok\. ${COUNT} passed; 0 failed" test-e2992.log || { - echo "::error::expected exactly ${COUNT} passing ${FILTER} tests." - exit 1 - } - rust-fmt: name: rust-fmt # Skip branch-deletion push events while still requiring pull request validation. @@ -2913,8 +3090,8 @@ jobs: - name: Test locked api-helper run: cargo test --locked -p session-bridge --bin agentscommander-api-helper - windows-release-cli-smoke: - name: windows-release-cli-smoke + release-cli-smoke-windows: + name: release-cli-smoke-windows # Skip branch-deletion push events while still requiring pull request validation. needs: ci-tier if: needs.ci-tier.outputs.full == 'true' && (github.event_name != 'push' || (github.event.deleted != true && github.event.after != '0000000000000000000000000000000000000000')) @@ -2957,11 +3134,11 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: windows-release-cli-smoke-logs + name: release-cli-smoke-windows-logs path: artifacts/cli-release-smoke if-no-files-found: ignore - # IS #1850: Windows default-route acceptance on the ACTUAL account profile of + # IS #1850: Windows default-root acceptance on the ACTUAL account profile of # a disposable GitHub-hosted VM. `dirs::home_dir()` resolves through the # Windows known-folder API, not environment relocation, so a spoofed # HOME/USERPROFILE would prove nothing; a fresh hosted VM is the only honest @@ -2971,9 +3148,9 @@ jobs: # known-folder HOME equal to the inherited USERPROFILE, refuses if any # `.agentscommander*` profile entry pre-exists, and runs all seven cases # serially with per-case inventories and cleanup. Ordinary Windows runs - # (rust-regression) take the refusal-only safety route instead. - issue-1850-windows-profile: - name: issue-1850-windows-profile (${{ matrix.mode }}) + # (rust-regression-windows) take the refusal-only safety route instead. + issue-1850-profile-windows: + name: issue-1850-profile-windows (${{ matrix.mode }}) # Skip branch-deletion push events while still requiring pull request validation. needs: ci-tier if: needs.ci-tier.outputs.full == 'true' && (github.event_name != 'push' || (github.event.deleted != true && github.event.after != '0000000000000000000000000000000000000000')) @@ -3099,7 +3276,7 @@ jobs: $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false $mode = '${{ matrix.mode }}' - $log = "issue-1850-windows-profile-$mode.log" + $log = "issue-1850-profile-windows-$mode.log" Write-Host "job=$env:GITHUB_JOB head=$env:GITHUB_SHA profile=$env:USERPROFILE runner=$env:RUNNER_ENVIRONMENT marker=$env:AC_ISSUE1850_DISPOSABLE_PROFILE" $cargoArgs = @('test', '--locked', '--manifest-path', 'src-tauri/Cargo.toml') @@ -3148,8 +3325,8 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: issue-1850-windows-profile-${{ matrix.mode }}-logs - path: issue-1850-windows-profile-${{ matrix.mode }}.log + name: issue-1850-profile-windows-${{ matrix.mode }}-logs + path: issue-1850-profile-windows-${{ matrix.mode }}.log if-no-files-found: ignore frontend-regression: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed64253db..c616f0303 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1067,7 +1067,7 @@ jobs: # minimum glibc, which is the user's decision, and the second is # unnecessary (measured) and would put 1.0.5 headers in front of a # 0.3.48 .so at link time. - - name: Use noble libspa-0.2 headers on ubuntu-22.04 (IS #1915) + - name: "Use noble libspa-0.2 headers on ubuntu-22.04 (IS #1915)" if: matrix.runner == 'ubuntu-22.04' shell: bash env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5bbffed5f..27c4c8acf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,6 +103,18 @@ The workflow publishes a status check named `validate-branch-name` on the branch git push -u origin ``` +## Check locally before pushing + +`npm run ci:local` runs the cheap pull-request checks on your machine: every check of the +`test-debt` job, `cargo fmt --check`, and clippy on the pinned Rust version. The +cognitive-complexity check only warns, as it does in CI. A red check here is a red pull request +avoided. + +- Run `npm ci` and `git fetch origin main` first, and commit your work: the ratchet checks compare + your last commit with `origin/main`, as CI does. +- `npm run ci:local -- --no-rust` skips cargo fmt and clippy. +- Clippy builds into `target/ci-local-`, so your normal `target/` is not rebuilt. + ## Log filter precedence diff --git a/docs/quality/cognitive-complexity-gate.md b/docs/quality/cognitive-complexity-gate.md index f71fb717a..8be8ff48a 100644 --- a/docs/quality/cognitive-complexity-gate.md +++ b/docs/quality/cognitive-complexity-gate.md @@ -10,7 +10,7 @@ The gate has two parts: - `test-debt` runs the suppression scan and the baseline ratchet through `scripts/check-cognitive-complexity.mjs`. -- each of the three Rust legs - `rust-regression` (Windows), `clippy-linux` (Linux) and +- each of the three Rust legs - `rust-regression-windows` (Windows), `clippy-linux` (Linux) and `rust-regression-macos` - runs ```bash @@ -292,7 +292,8 @@ Not every run has all three legs. `.github/workflows/pr-regression-gates.yml` sp two tiers, decided by its `ci-tier` job: - **Light tier, on every event:** `test-debt` (suppression scan and baseline ratchet), - `rust-regression` (Windows leg), `clippy-linux` (Linux leg) and `rust-fmt`. + `rust-regression-windows` (Windows leg), `clippy-linux` (Linux leg), `rust-fmt`, + `rust-slow-test-windows` and `issue-1850-release-windows` (no clippy, so no leg). - **Full tier, only when `ci-tier` sets `full=true`:** everything else, including `rust-regression-macos` (macOS leg). `rust-regression-linux` is also full-tier only, but it no longer carries the gate; the Linux leg is `clippy-linux`. @@ -300,9 +301,9 @@ two tiers, decided by its `ci-tier` job: `ci-tier` sets `full=true` when any of these holds: - the run was started by `workflow_dispatch`; -- the pull request carries the `ci:full` label. Adding the label starts a run by itself, because - the workflow also triggers on `labeled`, and while the label stays on, every later push runs the - full tier too; +- the run was started by adding the `ci:full` label (the workflow also triggers on `labeled`). + The label is one-shot: later pushes run the light tier even while it stays on, so to get a full + run on a newer head, remove the label and add it again; - the workflow's `github.run_number` is a multiple of 10. The counter is shared by every run of this workflow in the repository, so it is roughly one run in ten, not one pull request in ten, and nobody chooses which. @@ -335,9 +336,10 @@ If that happens to your pull request: (for `NEW`), or that removes the entry under the same rule (for `STALE`), labelled `ci:full` so its macOS leg runs. Once it merges, merge main into your pull request and rerun. -To avoid causing this, add the `ci:full` label **before merging** any pull request that changes -code under a macOS-only `cfg`, or that edits or removes baseline entries whose sites only macOS -observes. Its checks then run all three legs. +To avoid causing this, add the `ci:full` label **after the last push, before merging** any pull +request that changes code under a macOS-only `cfg`, or that edits or removes baseline entries whose +sites only macOS observes (if the label is already on, remove it and add it again). That run checks +the final head on all three legs. ### 6.3 One invocation on macOS @@ -351,18 +353,19 @@ excluded on all three platforms at once (section 2.3). ## 7. Bumping the pinned Rust version -The pin is local to six CI invocations and frozen nowhere else: +The pin is local to seven CI invocations and frozen nowhere else: -- four `toolchain:` inputs in `.github/workflows/pr-regression-gates.yml`, for `rust-regression`, - `clippy-linux`, `rust-regression-linux` and `rust-regression-macos`; -- two in `.github/workflows/cache-warm.yml`, for `warm-debug` and `verify-debug-cache`. +- five `toolchain:` inputs in `.github/workflows/pr-regression-gates.yml`, for + `rust-regression-windows`, `rust-slow-test-windows`, `clippy-linux`, `rust-regression-linux` and + `rust-regression-macos`; +- two in `.github/workflows/cache-warm.yml`, for `warm-debug` and `verify-warm-debug`. No `rust-toolchain.toml` exists: local development, `release.yml`, `bundle-validation.yml` and the other workflows stay on floating stable, and the pin must not be widened to them. To bump: -1. Change the version in the six places above, and in the three run-time assertions in +1. Change the version in the seven places above, and in the three run-time assertions in `pr-regression-gates.yml` that compare `rustc -vV` with the pin (one in each gate leg) - otherwise each leg fails with `toolchain pin did not take effect`. 2. Regenerate the baseline from the three platforms' emissions, merged by the script's `--merge` diff --git a/package.json b/package.json index d5b5a63dc..37b67cba2 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test": "vitest run", "test:debt": "node scripts/check-test-debt.mjs", "test:debt:self": "node scripts/check-test-debt.mjs --self-test", + "ci:local": "node scripts/ci-local.mjs", "check:skills": "node scripts/01-skills-checker.mjs", "check:skills:self": "node scripts/01-skills-checker.mjs --self-test", "record:arcs": "node scripts/02-module-arc-record.mjs", diff --git a/scripts/ci-local.mjs b/scripts/ci-local.mjs new file mode 100644 index 000000000..eb10b9c66 --- /dev/null +++ b/scripts/ci-local.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// Runs the cheap pull-request gates locally before a push: every command of the +// `test-debt` job, `cargo fmt --check` (the `rust-fmt` job) and the clippy run with +// its cognitive-complexity check (the clippy leg of rust-regression-windows, +// clippy-linux and rust-regression-macos). It runs every check, even after a +// failure, so one run lists every problem; it exits 1 if any check failed. The +// complexity check only warns, because CI runs it with continue-on-error. +// +// The test-debt commands are read from the workflow on each run, so this list +// cannot drift from CI. Clippy uses the toolchain pinned in +// cognitive-complexity.baseline.json, in its own target directory so switching +// toolchains does not rebuild your normal target/. +// +// The ratchet checks (`--base-ref`) compare the last commit with the base, as CI +// does, so commit your work before running this or they will not see it. +// +// Usage: npm run ci:local [-- --no-rust] [-- --base-ref ] + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const WORKFLOW = path.join(ROOT, '.github', 'workflows', 'pr-regression-gates.yml'); +const BASELINE = path.join(ROOT, 'cognitive-complexity.baseline.json'); +const PLATFORM = { win32: 'windows', linux: 'linux', darwin: 'macos' }[process.platform]; + +const USAGE = `Usage: node scripts/ci-local.mjs [--no-rust] [--base-ref ] + + --no-rust Skip cargo fmt and clippy. + --base-ref The ref the ratchet checks compare against; default + origin/main. Run \`git fetch origin main\` first so it is current. + --help Print this usage and exit 0.`; + +function parseArgs(argv) { + const opts = { rust: true, baseRef: 'origin/main' }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--no-rust') opts.rust = false; + else if (arg === '--base-ref') { + opts.baseRef = argv[i + 1]; + i += 1; + if (!opts.baseRef) throw new Error('--base-ref requires a value'); + } else if (arg === '--help') { + console.log(USAGE); + process.exit(0); + } else throw new Error(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`); + } + return opts; +} + +// Every `npm run` / `npm test` line inside the test-debt job, with the base-ref +// expression CI computes replaced by the local base ref. +export function testDebtCommands(workflowText, baseRef) { + const lines = workflowText.split(/\r?\n/); + const start = lines.indexOf(' test-debt:'); + if (start < 0) throw new Error('no test-debt job in pr-regression-gates.yml'); + const commands = []; + for (let i = start + 1; i < lines.length && !/^ {2}[a-z0-9-]+:\s*$/.test(lines[i]); i += 1) { + const match = /^\s+(?:run:\s+)?(npm (?:run|test) .*)$/.exec(lines[i]); + if (match) commands.push(match[1].replaceAll('"${GATE_BASE_REF:-origin/main}"', `"${baseRef}"`)); + } + if (commands.length === 0) throw new Error('found no npm commands in the test-debt job'); + const unresolved = commands.find((c) => c.includes('${')); + if (unresolved) throw new Error(`cannot run a CI expression locally: ${unresolved}`); + return commands; +} + +function run(command, options = {}) { + const started = Date.now(); + console.log(`\n=== ${command}`); + const result = spawnSync(command, { cwd: ROOT, shell: true, stdio: 'inherit', ...options }); + return { ok: result.status === 0, status: result.status ?? 1, seconds: (Date.now() - started) / 1000 }; +} + +function capture(command, cwd) { + const result = spawnSync(command, { cwd, shell: true, encoding: 'utf8' }); + return result.status === 0 ? result.stdout : null; +} + +function rustChecks(results) { + const srcTauri = path.join(ROOT, 'src-tauri'); + results.push(['cargo fmt --all -- --check', run('cargo fmt --all -- --check', { cwd: srcTauri })]); + + const pin = JSON.parse(fs.readFileSync(BASELINE, 'utf8')).toolchain; + const toolchains = capture('rustup toolchain list', ROOT) ?? ''; + const pinned = toolchains.split(/\r?\n/).some((line) => line.startsWith(`${pin}-`)); + if (!pinned) { + console.log(`\nRust ${pin} (the CI pin) is not installed; run \`rustup toolchain install ${pin} --component clippy\`.`); + results.push([`cargo +${pin} clippy`, { ok: false, seconds: 0 }]); + return; + } + const targetDir = path.join(ROOT, 'target', `ci-local-${pin}`); + const jsonl = path.join(os.tmpdir(), `ac-ci-local-cog-${process.pid}.jsonl`); + const clippy = `cargo +${pin} clippy --locked --workspace --all-targets --message-format=json ` + + `--target-dir "${targetDir}" -- -D warnings --force-warn clippy::cognitive_complexity > "${jsonl}"`; + const clippyResult = run(clippy, { cwd: srcTauri }); + // The JSON capture holds clippy's human text; the check reprints it and applies the baseline. + const cog = `node scripts/check-cognitive-complexity.mjs --capture "${jsonl}" ` + + `--clippy-exit ${clippyResult.status} --platform ${PLATFORM} --rustc-version ${pin}`; + const cogResult = run(cog); + fs.rmSync(jsonl, { force: true }); + results.push([`cargo +${pin} clippy`, clippyResult]); + // CI runs this step with continue-on-error, so a red result warns and does not fail. + results.push(['complexity check (advisory, as in CI)', { ...cogResult, advisory: true }]); +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (!fs.existsSync(path.join(ROOT, 'node_modules', '.bin'))) { + throw new Error('node_modules is missing; run `npm ci` first'); + } + const base = capture(`git rev-parse --short ${opts.baseRef}`, ROOT); + if (!base) throw new Error(`cannot resolve ${opts.baseRef}; run \`git fetch origin main\` or pass --base-ref`); + console.log(`Comparing against ${opts.baseRef} (${base.trim()}).`); + if (capture('git status --porcelain --untracked-files=no', ROOT)?.trim()) { + console.log('Note: you have uncommitted changes. The --base-ref checks read the last commit, not them.'); + } + + const results = []; + for (const command of testDebtCommands(fs.readFileSync(WORKFLOW, 'utf8'), opts.baseRef)) { + results.push([command, run(command)]); + } + if (opts.rust) rustChecks(results); + + console.log('\n=== Summary'); + for (const [name, { ok, advisory, seconds }] of results) { + const label = ok ? 'ok ' : advisory ? 'WARN' : 'FAIL'; + console.log(`${label} ${seconds.toFixed(0).padStart(4)}s ${name}`); + } + const failed = results.filter(([, r]) => !r.ok && !r.advisory).length; + console.log(failed === 0 ? 'All checks passed.' : `${failed} check(s) failed.`); + return failed === 0 ? 0 : 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + process.exitCode = main(); + } catch (err) { + console.error(`ci-local: ${err.message}`); + process.exitCode = 2; + } +} diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 1fe3bb903..6c44606ae 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -739,7 +739,7 @@ mod tests { /// class whose failure mode is omission, so this walks clap's own tree. /// /// It needs no built binary and no process spawn, so it runs under - /// `cargo test`. It runs on the `rust-regression` (windows) leg only, + /// `cargo test`. It runs on the `rust-regression-windows` leg only, /// because that is the only CI leg that runs the Rust test suite. #[test] fn no_clap_printed_help_carries_the_retired_token() { diff --git a/src-tauri/src/pty/job.rs b/src-tauri/src/pty/job.rs index 721708f78..99fbf4fec 100644 --- a/src-tauri/src/pty/job.rs +++ b/src-tauri/src/pty/job.rs @@ -629,7 +629,7 @@ mod stub_impl { // #632 LOW-2 - the ONE automated proof that the job kills the whole tree (child AND // grandchild). Deliberately NOT #[ignore] and gated #[cfg(windows)] so it runs by -// default in the `rust-regression` CI lane (runs-on: windows-latest, executes +// default in the `rust-regression-windows` CI lane (runs-on: windows-latest, executes // `cargo test --lib --bins --tests`). Real processes; written defensively (generous // polls) to avoid flake. Do NOT add #[ignore] - that removes all executed coverage // of Part A. From 3b7ff3f0df6a0282a4ee184ea67b28c000afd664 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Fri, 23 Oct 2026 17:40:13 -0300 Subject: [PATCH 2/2] ci(workflows): fix the first PR run of the split gates (refs #3184) - issue-1850-release-macos: release build timeout 40 -> 60 min; the crate alone compiles for ~33 min on macOS and the first run timed out at 40m03s. - issue-1850-release-linux: npm ci --ignore-scripts, like the other Linux jobs (SonarCloud security rating). - ci-local.mjs: merge two push calls, drop a nested ternary, stringify the summary name explicitly (SonarCloud code smells). --- .github/workflows/pr-regression-gates.yml | 5 +++-- scripts/ci-local.mjs | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-regression-gates.yml b/.github/workflows/pr-regression-gates.yml index 8475c4e67..8de7db98d 100644 --- a/.github/workflows/pr-regression-gates.yml +++ b/.github/workflows/pr-regression-gates.yml @@ -1528,7 +1528,7 @@ jobs: run: npm install -g npm@11.6.2 --ignore-scripts - name: Install frontend dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Build frontend assets for Tauri config validation run: npm run build @@ -2584,7 +2584,8 @@ jobs: id: issue1850_release_build working-directory: src-tauri shell: bash - timeout-minutes: 40 + # macOS compiles the crate alone for about 33 minutes in release; 40 timed out. + timeout-minutes: 60 env: ISSUE1850_PR_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} run: | diff --git a/scripts/ci-local.mjs b/scripts/ci-local.mjs index eb10b9c66..84a271066 100644 --- a/scripts/ci-local.mjs +++ b/scripts/ci-local.mjs @@ -102,9 +102,16 @@ function rustChecks(results) { + `--clippy-exit ${clippyResult.status} --platform ${PLATFORM} --rustc-version ${pin}`; const cogResult = run(cog); fs.rmSync(jsonl, { force: true }); - results.push([`cargo +${pin} clippy`, clippyResult]); - // CI runs this step with continue-on-error, so a red result warns and does not fail. - results.push(['complexity check (advisory, as in CI)', { ...cogResult, advisory: true }]); + // CI runs the complexity step with continue-on-error, so a red result warns and does not fail. + results.push( + [`cargo +${pin} clippy`, clippyResult], + ['complexity check (advisory, as in CI)', { ...cogResult, advisory: true }], + ); +} + +function summaryLabel({ ok, advisory }) { + if (ok) return 'ok '; + return advisory ? 'WARN' : 'FAIL'; } function main() { @@ -126,9 +133,8 @@ function main() { if (opts.rust) rustChecks(results); console.log('\n=== Summary'); - for (const [name, { ok, advisory, seconds }] of results) { - const label = ok ? 'ok ' : advisory ? 'WARN' : 'FAIL'; - console.log(`${label} ${seconds.toFixed(0).padStart(4)}s ${name}`); + for (const [name, result] of results) { + console.log(`${summaryLabel(result)} ${result.seconds.toFixed(0).padStart(4)}s ${String(name)}`); } const failed = results.filter(([, r]) => !r.ok && !r.advisory).length; console.log(failed === 0 ? 'All checks passed.' : `${failed} check(s) failed.`);