name: CI

on:
  push:
    branches: [master, main]
  pull_request:
    branches: [master, main]
  schedule:
    - cron: '31 6 * * 1'
  workflow_dispatch:
    inputs:
      expected_sha:
        description: Exact 40-character commit selected by --ref (manual runs always force full CI)
        required: true
        type: string

permissions:
  contents: read

concurrency:
  # PRs still share one group so a new push cancels the superseded head.
  # Push/schedule/dispatch on main must be keyed by SHA: with cancel-in-progress
  # false, GitHub still cancels a *pending* run in the same group when a new
  # one queues. That is how 31 of the last 40 main CI runs vanished without a
  # verdict (test bankruptcy, 2026-08-19). Each SHA gets its own group so
  # every commit on main actually finishes.
  group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-{0}-{1}', github.workflow, github.sha) }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  CARGO_TERM_COLOR: always
  CARGO_INCREMENTAL: 0
  RUSTFLAGS: -Dwarnings
  # Test threads share a process and tokio/async frames run deep; the default
  # 2 MiB stack overflowed sporadically in runtime_api::tests::start_turn_*
  # under load and aborted the whole lib suite (signal 6). 8 MiB is the
  # measured-safe floor; nextest's per-process runs are unaffected either way.
  RUST_MIN_STACK: 8388608

jobs:
  changes:
    name: Change detection
    timeout-minutes: 10
    runs-on: ubuntu-latest
    outputs:
      heavy: ${{ steps.detect.outputs.heavy }}
      workflow: ${{ steps.detect.outputs.workflow }}
      mobile: ${{ steps.detect.outputs.mobile }}
      actions: ${{ steps.detect.outputs.actions }}
      trusted: ${{ steps.trust.outputs.trusted }}
    steps:
      - name: Classify event trust
        id: trust
        shell: bash
        env:
          EVENT_NAME: ${{ github.event_name }}
          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
          THIS_REPO: ${{ github.repository }}
        run: |
          set -euo pipefail
          # "trusted" means the code came from this repository, not a fork.
          # Only trusted events may run on the self-hosted macOS runner: this
          # repo is public with thousands of forks, and a fork PR on a
          # self-hosted runner is arbitrary code execution on that machine.
          if [ "${EVENT_NAME}" != "pull_request" ] || [ "${HEAD_REPO}" = "${THIS_REPO}" ]; then
            echo "trusted=true" >> "$GITHUB_OUTPUT"
          else
            echo "trusted=false" >> "$GITHUB_OUTPUT"
          fi
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - name: Detect executable changes
        id: detect
        shell: bash
        env:
          EVENT_NAME: ${{ github.event_name }}
          BASE_REF: ${{ github.base_ref }}
          BEFORE_SHA: ${{ github.event.before }}
          EXPECTED_SHA: ${{ inputs.expected_sha }}
        run: |
          set -euo pipefail

          if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
            if [[ "${#EXPECTED_SHA}" -ne 40 || "${EXPECTED_SHA}" =~ [^0-9a-fA-F] ]]; then
              echo "::error::expected_sha must be a full 40-character commit SHA." >&2
              exit 1
            fi
            actual="$(git rev-parse HEAD)"
            expected_normalized="$(printf '%s' "${EXPECTED_SHA}" | tr '[:upper:]' '[:lower:]')"
            if [[ "${actual}" != "${expected_normalized}" ]]; then
              echo "::error::Dispatch resolved to ${actual}, not requested ${EXPECTED_SHA}." >&2
              exit 1
            fi
            echo "Manual exact-head dispatch: forcing heavy, workflow, mobile, and action gates."
            echo "heavy=true" >> "${GITHUB_OUTPUT}"
            echo "workflow=true" >> "${GITHUB_OUTPUT}"
            echo "mobile=true" >> "${GITHUB_OUTPUT}"
            echo "actions=true" >> "${GITHUB_OUTPUT}"
            exit 0
          fi

          if [[ "${EVENT_NAME}" == "schedule" ]]; then
            echo "heavy=true" >> "${GITHUB_OUTPUT}"
            echo "workflow=true" >> "${GITHUB_OUTPUT}"
            echo "mobile=true" >> "${GITHUB_OUTPUT}"
            echo "actions=true" >> "${GITHUB_OUTPUT}"
            exit 0
          fi

          base=""
          if [[ "${EVENT_NAME}" == "pull_request" && -n "${BASE_REF}" ]]; then
            git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}" --depth=1
            base="origin/${BASE_REF}"
          elif [[ -n "${BEFORE_SHA}" && "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then
            base="${BEFORE_SHA}"
          fi

          if [[ -z "${base}" ]]; then
            echo "heavy=true" >> "${GITHUB_OUTPUT}"
            echo "workflow=true" >> "${GITHUB_OUTPUT}"
            echo "mobile=true" >> "${GITHUB_OUTPUT}"
            echo "actions=true" >> "${GITHUB_OUTPUT}"
            exit 0
          fi

          mapfile -t changed < <(git diff --name-only "${base}" "${GITHUB_SHA}" | sort)
          heavy=false
          workflow=false
          mobile=false
          actions=false
          for path in "${changed[@]}"; do
            # Heavy classification. ORDER MATTERS: must-stay-heavy inputs are
            # matched BEFORE any light entry so a script that only a
            # heavy-gated job exercises can never be misclassified as light.
            # Anything unrecognized falls through to the default-heavy `*)`
            # arm (fail-safe default-heavy). Light-classified scripts below
            # are exercised by ALWAYS-on jobs/steps that run regardless of
            # `heavy` (check-versions.sh / check-ohos-deps.sh via Version
            # drift, dev-cache/dev-test
            # self-checks via Version drift), so no coverage is lost.
            case "${path}" in
              scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py)
                heavy=true
                ;;
              docs/*|*.md|packaging/aur/*|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/scripts/agent-task-metadata.test.sh|.github/workflows/agent-task-labels.yml|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/release/install-dogfood.sh|scripts/release/install-dogfood.test.sh|scripts/release/prepare-release.sh|scripts/release/prepare-release.test.sh|scripts/dev-cache.sh|scripts/dev-cache.test.sh|scripts/dev-cargo.sh|scripts/dev-test.sh)
                ;;
              *)
                heavy=true
                ;;
            esac
            case "${path}" in
              crates/workflow/*|.github/workflows/ci.yml)
                workflow=true
                ;;
            esac
            # Mobile runtime surface: the `codewhale serve --mobile`
            # HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull
            # requests run the smoke only when one of these changes; every
            # push to main still runs it unconditionally as the pre-release
            # safety net for anything this filter misses.
            case "${path}" in
              crates/app-server/*|crates/tui/src/runtime_api*|crates/tui/src/runtime_mobile.html|crates/tui/src/runtime_threads*|crates/tui/src/main.rs|scripts/mobile-smoke.sh|.github/workflows/ci.yml|Cargo.lock|Cargo.toml)
                mobile=true
                ;;
            esac
            case "${path}" in
              .github/workflows/*|.github/actionlint.yml)
                actions=true
                ;;
            esac
          done

          echo "heavy=${heavy}" >> "${GITHUB_OUTPUT}"
          echo "workflow=${workflow}" >> "${GITHUB_OUTPUT}"
          echo "mobile=${mobile}" >> "${GITHUB_OUTPUT}"
          echo "actions=${actions}" >> "${GITHUB_OUTPUT}"

          echo "Heavy Rust CI required: ${heavy}"
          echo "Workflow RLM cache CI required: ${workflow}"
          echo "Mobile runtime smoke required (PRs): ${mobile}"
          echo "Workflow lint required: ${actions}"
          printf 'Changed files:\n'
          printf '  %s\n' "${changed[@]}"

  versions:
    name: Version drift
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: dtolnay/rust-toolchain@stable
      - uses: actions/setup-node@v7
        with:
          node-version: 20
      - name: Check version drift
        # Checks 7 and 12 audit the previous-tag..HEAD commit range, not this
        # tree, so a receipt another merge forgot reddens every open PR. They
        # report here and block on every release path (release-candidate.yml,
        # auto-tag.yml, release.yml, prepare-release.sh), which is where a
        # missing receipt actually matters.
        run: ./scripts/release/check-versions.sh --range-audit-advisory
      - name: Check OHOS dependency graph
        run: ./scripts/release/check-ohos-deps.sh
      - name: Check release helper contracts
        run: |
          bash .github/scripts/agent-task-metadata.test.sh
          bash scripts/release/check-feature-release-notes.test.sh
          bash scripts/release/generate-release-body.test.sh
          bash scripts/release/install-dogfood.test.sh
          bash scripts/release/prepare-release.test.sh
          bash scripts/release/require-release-tag-checkout.test.sh
          bash scripts/release/validate-crate-publish-order.test.sh
          python3 scripts/release/publish-crates.test.py
          bash scripts/release/verify-remote-tag.test.sh
          bash packaging/aur/render.test.sh
          sh scripts/dev-cache.test.sh
          bash .github/scripts/update-homebrew-tap.test.sh
          node .github/scripts/release-workflows.test.js
          node --test scripts/release/assemble-release-assets.test.js
          node --test scripts/release/ensure-release-assets-absent.test.js
      - name: Run runtime web client tests
        # crates/tui/tests/runtime_web_client.test.mjs exercises the embedded
        # web client's event/snapshot state machine; it ran nowhere before.
        run: node --test crates/tui/tests/runtime_web_client.test.mjs

  integrations:
    name: Integrations
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
      - name: Run chat-bridge suites
        # All four bridges + bridge-core ship dependency-free node --test
        # suites that no workflow ran. weixin has no lockfile by design
        # (zero deps); npm test works without npm ci everywhere here.
        run: |
          set -euo pipefail
          for bridge in bridge-core feishu-bridge telegram-bridge wecom-bridge weixin-bridge; do
            echo "== ${bridge}"
            (cd "integrations/${bridge}" && npm test)
          done

      - name: Run computer-use plugin suites
        # The bundled plugin is dependency-free too; its suites cover the
        # manifest contract, the registry, the exec/ssh transport, the four
        # platform backends, and the MCP stdio protocol. No GUI input runs.
        run: (cd crates/tui/plugins/computer-use && npm test)

  vscode-extension:
    name: VS Code extension
    timeout-minutes: 15
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: extensions/vscode
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          # The extension targets VS Code >=1.90, whose extension host is
          # Node 20, and its @types/node pin is ^20. Build and test on the
          # runtime the extension actually ships against.
          node-version: 20
      - name: Install extension dependencies
        run: npm ci
      - name: Run VS Code extension suites
        # extensions/vscode ships node --test suites (api, markdown, sse) that
        # NO workflow ran: release.yml only reads package.json for a version
        # string, so the whole client compiled and shipped without its tests or
        # `tsc` ever running in CI. `npm test` compiles first (tsc -p ./), so
        # this is the type-check gate for the extension too.
        run: npm test

  safety-gate:
    name: Safety gate
    needs: changes
    if: needs.changes.outputs.heavy == 'true'
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@master
        with:
          toolchain: stable
      - uses: mozilla-actions/sccache-action@v0.0.11
        id: sccache
        continue-on-error: true
      - name: Enable sccache
        if: steps.sccache.outcome == 'success'
        shell: bash
        run: |
          echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
          echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
          echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
      - name: Install Linux system dependencies
        run: |
          for i in 1 2 3 4 5; do
            sudo apt-get update && break
            echo "apt-get update failed (attempt $i); retrying in 15s"
            sleep 15
          done
          sudo apt-get install -y libdbus-1-dev pkg-config
      - uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false
          save-if: ${{ github.ref == 'refs/heads/main' }}
      - name: Hermetic safety and authorization tests
        env:
          HOME: ${{ runner.temp }}/cw-hermetic-home
          USERPROFILE: ${{ runner.temp }}/cw-hermetic-home
          CODEWHALE_HOME: ${{ runner.temp }}/cw-hermetic-home/.codewhale
          RUST_MIN_STACK: "8388608"
        run: |
          mkdir -p "${HOME}" "${CODEWHALE_HOME}"
          unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME || true
          cargo test -p codewhale-tui --lib --locked -- command_safety auto_review authority sandbox
          cargo test -p codewhale-execpolicy --locked

  lint:
    name: Lint
    needs: changes
    timeout-minutes: 45
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: dtolnay/rust-toolchain@master
        if: needs.changes.outputs.heavy == 'true'
        with:
          toolchain: stable
          components: rustfmt, clippy
      - uses: mozilla-actions/sccache-action@v0.0.11
        id: sccache
        # Cache bootstrap failures (e.g. GitHub 504s fetching the sccache
        # binary) degrade to an uncached build instead of failing product CI.
        continue-on-error: true
        if: needs.changes.outputs.heavy == 'true'
      - name: Enable sccache
        if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
        shell: bash
        run: |
          echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
          echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
          echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
      - name: Install Linux system dependencies
        if: needs.changes.outputs.heavy == 'true'
        run: |
          for i in 1 2 3 4 5; do
            sudo apt-get update && break
            echo "apt-get update failed (attempt $i); retrying in 15s"
            sleep 15
          done
          sudo apt-get install -y libdbus-1-dev pkg-config
      - uses: Swatinem/rust-cache@v2
        if: needs.changes.outputs.heavy == 'true'
        with:
          cache-bin: false
          # PRs restore the cache seeded by main but skip the expensive
          # post-job save; sccache covers PR-specific compilation deltas.
          save-if: ${{ github.ref == 'refs/heads/main' }}
      - name: Check formatting
        if: needs.changes.outputs.heavy == 'true'
        run: cargo fmt --all -- --check
      - name: Run clippy
        # --all-targets, because without it CI never lints test code at all.
        # That gap is not theoretical: the v0.9.10 release gate opened with
        # four clippy failures sitting on a green main, and every one of them
        # was in a test target. crates/tui/AGENTS.md already documents the
        # all-targets command as the release gate; this makes CI run the gate
        # it points contributors at instead of a weaker subset.
        #
        # collapsible_if and assertions_on_constants are no longer allowed for
        # the same reason — they were three of those four, so the allowances
        # were hiding exactly the class of problem that reached the gate. The
        # three that remain are deliberate project style, not oversights.
        if: needs.changes.outputs.heavy == 'true'
        run: |
          cargo clippy --workspace --all-targets --all-features --locked -- \
            -D warnings \
            -A clippy::uninlined_format_args \
            -A clippy::too_many_arguments \
            -A clippy::unnecessary_map_or
      - name: sccache stats
        if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
        continue-on-error: true
        shell: bash
        run: sccache --show-stats
      - name: Check provider registry drift
        if: needs.changes.outputs.heavy == 'true'
        run: python3 scripts/check-provider-registry.py
      - name: Check command-contract prototype boundary
        if: needs.changes.outputs.heavy == 'true'
        run: |
          python3 scripts/test_check_command_crate_boundaries.py
          python3 scripts/check-command-crate-boundaries.py
      - name: Check command migration manifest
        if: needs.changes.outputs.heavy == 'true'
        env:
          PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
          PUSH_BEFORE_SHA: ${{ github.event.before }}
        run: |
          python3 scripts/test_check_command_migration_manifest.py
          baseline="${PR_BASE_SHA:-${PUSH_BEFORE_SHA:-}}"
          if [[ -n "${baseline}" && ! "${baseline}" =~ ^0+$ ]]; then
            git fetch --no-tags origin "${baseline}"
            python3 scripts/check-command-migration-manifest.py --baseline-ref "${baseline}"
          else
            python3 scripts/check-command-migration-manifest.py
          fi
      # Clippy above runs without `--all-targets`, so it cannot see dead code
      # that only tests keep alive. This ratchet covers that blind spot by
      # refusing to let the `#[allow(dead_code)]` total rise (#4785).
      - name: Check dead-code budget
        if: needs.changes.outputs.heavy == 'true'
        # Advisory on pull requests: this asserts a whole-repo property, so a
        # branch can fail it for debt it inherited rather than added, and the
        # fix would be rebasing instead of editing code. It stays blocking on
        # pushes to main, where the number is actually actionable.
        continue-on-error: ${{ github.event_name == 'pull_request' }}
        run: python3 scripts/check-dead-code-budget.py
      - name: Test runtime-contract measurement harness
        if: needs.changes.outputs.heavy == 'true'
        run: |
          python3 scripts/test_measure_runtime_contract.py
          python3 scripts/test_check_runtime_contract_budget.py
      # The offline runtime-contract measurement needs the full locked graph,
      # dev-dependencies included (e.g. wiremock -> assert-json-diff), but
      # clippy above builds no test targets and the rust-cache registry key
      # derives from Cargo.lock, so any lock-changing PR (every dependabot
      # bump) restores an empty cache and the hermetic `cargo test --offline`
      # dies with "failed to download ... --offline was specified" before a
      # single budget is measured. Fetch the locked graph once here so the
      # measurement below is deterministic on every branch.
      - name: Fetch locked dependency graph for offline measurement
        if: needs.changes.outputs.heavy == 'true'
        run: cargo fetch --locked
      # Provider-free local measurement. The checker forces Cargo offline and
      # the measurement script runs only locked, ignored Rust metric tests.
      - name: Check runtime-contract budget
        if: needs.changes.outputs.heavy == 'true'
        # Advisory on pull requests: this asserts a whole-repo property, so a
        # branch can fail it for debt it inherited rather than added, and the
        # fix would be rebasing instead of editing code. It stays blocking on
        # pushes to main, where the number is actually actionable.
        continue-on-error: ${{ github.event_name == 'pull_request' }}
        run: python3 scripts/check-runtime-contract-budget.py
      # Provider-free paused-consumer measurement of the production
      # persistence request channel. RSS is sampled only on macOS; every host
      # enforces the accepted/retained request and payload contract.
      - name: Test persistence-backlog measurement and checker harnesses
        if: needs.changes.outputs.heavy == 'true'
        run: |
          python3 scripts/test_measure_persistence_backlog.py
          python3 scripts/test_check_persistence_backlog_budget.py
      - name: Check persistence-backlog budget
        if: needs.changes.outputs.heavy == 'true'
        # Advisory on pull requests: this asserts a whole-repo property, so a
        # branch can fail it for debt it inherited rather than added, and the
        # fix would be rebasing instead of editing code. It stays blocking on
        # pushes to main, where the number is actually actionable.
        continue-on-error: ${{ github.event_name == 'pull_request' }}
        run: python3 scripts/check-persistence-backlog-budget.py
      - name: Check README translations stay in sync
        if: github.event_name != 'schedule'
        run: python3 scripts/check-readme-translations.py
      - name: Check README locale link symmetry
        if: github.event_name != 'schedule'
        run: bash scripts/check-readme-locales.sh
      - name: Check TUI locale pack parity
        if: github.event_name != 'schedule'
        run: python3 scripts/check-tui-locale-parity.py
      - name: Check TUI product vocabulary
        if: github.event_name != 'schedule'
        run: sh scripts/check-tui-product-vocabulary.sh
      - name: Check website locale dictionary parity
        if: github.event_name != 'schedule'
        run: node web/scripts/check-locales.mjs
      - name: Skip Rust lint for light change
        if: needs.changes.outputs.heavy != 'true'
        run: echo "No executable Rust changes detected; preserving required Lint context."
      - name: Linux clippy location
        if: needs.changes.outputs.heavy == 'true'
        run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches."

  workflow-rlm-cache:
    name: Workflow RLM cache
    needs: changes
    if: needs.changes.outputs.workflow == 'true'
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: mozilla-actions/sccache-action@v0.0.11
        id: sccache
        continue-on-error: true
      - name: Enable sccache
        if: steps.sccache.outcome == 'success'
        shell: bash
        run: |
          echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
          echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
          echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
      - uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false
          save-if: ${{ github.ref == 'refs/heads/main' }}
      - name: Run workflow crate tests
        run: cargo test -p codewhale-workflow --locked

  test:
    name: Test
    needs: changes
    # Required contexts "Test (ubuntu-latest)" / "Test (macos-latest)" /
    # "Test (windows-latest)" derive from job name + matrix.os and are
    # independent of runs-on. For light changes the macOS/Windows legs only
    # echo a skip line, so run them on ubuntu instead of queueing for scarce
    # macOS/Windows runners. Heavy pull requests run the Linux lane directly;
    # non-PR release/main pushes use CNB for Linux.
    # The ternary is safe: matrix.os is always a non-empty literal, so
    # runs-on can never evaluate to empty.
    timeout-minutes: 90
    # macOS legs go to the self-hosted Mac ONLY when all three hold: the
    # change is heavy, the event is trusted (not a fork PR), and the
    # CW_SELF_HOSTED_MAC repo variable is 'true'. That variable is the kill
    # switch: unset it and every leg falls back to GitHub-hosted runners
    # immediately, with no commit — important because an offline
    # self-hosted runner queues jobs forever, which is worse than a slow one.
    runs-on: ${{ needs.changes.outputs.heavy != 'true' && 'ubuntu-latest' || (matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true' && fromJSON('["self-hosted","macOS","ARM64","codewhale-mac"]')) || matrix.os }}
    strategy:
      # A failure on one desktop platform must not erase evidence from the
      # other one. We need both conclusions to diagnose and release safely.
      fail-fast: false
      matrix:
        # Linux workspace tests run directly for pull requests. CNB remains
        # the Linux lane for non-PR release/main pushes.
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - name: Skip tests for light change
        if: needs.changes.outputs.heavy != 'true'
        run: echo "No executable Rust changes detected; preserving required Test context."
      - uses: actions/checkout@v7
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
      - name: Test Windows installer PATH helper
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
        shell: pwsh
        run: ./scripts/installer/update-user-path.tests.ps1
      - name: Install NSIS for Windows installer regression
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
        shell: pwsh
        # Bounded retry, not a weaker check (#5403). Every observed failure here
        # was Chocolatey's feed, not the code: a 504 from the V2 API, and
        # "package was not found with the source(s) listed". A single attempt
        # made `Test (windows-latest)` — a required check on every PR — report
        # on community.chocolatey.org's availability instead of on the tree.
        # NSIS must still install for the regression below to run; this only
        # survives a transient outage.
        run: |
          $ErrorActionPreference = 'Continue'
          $delays = @(0, 20, 45)
          for ($attempt = 0; $attempt -lt $delays.Count; $attempt++) {
            if ($delays[$attempt] -gt 0) {
              Write-Host "NSIS install attempt $($attempt + 1) after $($delays[$attempt])s backoff"
              Start-Sleep -Seconds $delays[$attempt]
            }
            choco install nsis -y --no-progress
            if ($LASTEXITCODE -eq 0) {
              Write-Host "NSIS installed on attempt $($attempt + 1)"
              exit 0
            }
            Write-Host "::warning::choco install nsis failed (exit $LASTEXITCODE)"
          }
          Write-Host "::error::NSIS could not be provisioned from Chocolatey after $($delays.Count) attempts"
          exit 1
      - name: Test Windows installer PATH regression
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
        shell: pwsh
        run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
      - uses: dtolnay/rust-toolchain@stable
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
      - uses: mozilla-actions/sccache-action@v0.0.11
        id: sccache
        continue-on-error: true
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
      - name: Enable sccache
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
        shell: bash
        run: |
          echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
          echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
          echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
      - name: Install Linux system dependencies
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        run: |
          for i in 1 2 3 4 5; do
            sudo apt-get update && break
            echo "apt-get update failed (attempt $i); retrying in 15s"
            sleep 15
          done
          sudo apt-get install -y libdbus-1-dev pkg-config
      - uses: Swatinem/rust-cache@v2
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        with:
          cache-bin: false
          save-if: ${{ github.ref == 'refs/heads/main' }}
      - uses: taiki-e/install-action@nextest
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
      - name: Run tests
        # Same test binaries as `cargo test`, run by cargo-nextest: one
        # process per test, all runner cores busy, slow tests named instead
        # of stalling the binary. `.config/nextest.toml` serializes the PTY
        # binary and bounds the integration binary that spawns the real
        # executable; retries are off, so a flake is a red run, not a hidden
        # one. nextest does not run doctests — the next step keeps them.
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        run: cargo nextest run --workspace --all-features --locked --profile ci
        env:
          # Give test threads the stack the product gives itself. main.rs runs
          # the owner thread and every tokio worker at
          # CODEWHALE_MAIN_STACK_BYTES (16 MiB) because the engine and
          # runtime-thread futures are genuinely deep. `#[tokio::test]` builds
          # its own runtime and never sees that, so tests ran the same code on
          # ~2 MiB (~1 MiB on Windows) — a configuration that never ships.
          # That gap is what aborted the whole Windows test binary with
          # STATUS_STACK_OVERFLOW in start_turn_accepts_dynamic_tools_and_
          # environment_id, masking every other Windows result (78afd8d3d4
          # Box::pin'd that one frame; the mismatch itself remained). std reads
          # this for any thread spawned without an explicit size, which covers
          # both libtest's per-test threads and tokio's workers.
          RUST_MIN_STACK: '16777216'
      - name: Run doctests
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        run: cargo test --workspace --all-features --locked --doc
        env:
          RUST_MIN_STACK: '16777216'
      # The Ubuntu lint lane validates non-RSS backlog fields. Run the same
      # source-bound measurement on macOS so loss or growth of RSS evidence
      # fails closed instead of becoming an unsupported-field skip.
      - name: Check persistence-backlog RSS budget
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
        run: python3 scripts/check-persistence-backlog-budget.py
      - name: Lockfile drift guard
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        run: git diff --exit-code -- Cargo.lock
      - name: Run Offline Eval Harness
        # The eval harness is OS-independent prompt/composition checking;
        # running it once (on the faster macOS leg, warm from the test build)
        # instead of once per desktop OS keeps the coverage while taking
        # ~2min off the Windows critical path.
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
        run: cargo run -p codewhale-tui --all-features -- eval
      - name: sccache stats
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
        continue-on-error: true
        shell: bash
        run: sccache --show-stats
      - name: Linux test location (CNB)
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
        run: echo "Linux workspace tests run on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu."

  npm-wrapper-smoke:
    name: npm wrapper smoke
    needs: changes
    if: github.event_name != 'schedule'
    # Same ternary rationale as the Test job: light legs only echo, so keep
    # them off macOS/Windows runners. On pull_request the matrix is
    # ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)"
    # context is unaffected. Heavy pull requests execute the Ubuntu smoke
    # here; their branches may not be mirrored to CNB.
    timeout-minutes: 30
    runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
    strategy:
      matrix:
        os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }}
    steps:
      - name: Skip npm wrapper smoke for light change
        if: needs.changes.outputs.heavy != 'true'
        run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
      - uses: actions/checkout@v7
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
      - uses: dtolnay/rust-toolchain@stable
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
      - uses: mozilla-actions/sccache-action@v0.0.11
        id: sccache
        continue-on-error: true
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
      - name: Enable sccache
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
        shell: bash
        run: |
          echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
          echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
          echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
      - uses: actions/setup-node@v7
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        with:
          node-version: 20
      - name: Install Linux system dependencies
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        run: |
          for i in 1 2 3 4 5; do
            sudo apt-get update && break
            echo "apt-get update failed (attempt $i); retrying in 15s"
            sleep 15
          done
          sudo apt-get install -y libdbus-1-dev pkg-config
      - uses: Swatinem/rust-cache@v2
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        with:
          cache-bin: false
          save-if: ${{ github.ref == 'refs/heads/main' }}
      - name: Build wrapper binaries
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        # The smoke validates wrapper install/delegation plumbing, not
        # codegen quality, so skip fat LTO + codegen-units=1 for a much
        # cheaper release build. Shipped binaries keep the real profile via
        # the Release workflow.
        env:
          CARGO_PROFILE_RELEASE_LTO: 'off'
          CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
        run: cargo build --release --locked -p codewhale-cli -p codewhale-tui
      - name: Smoke wrapper install and delegated entrypoints
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
        run: node scripts/release/npm-wrapper-smoke.js
      - name: sccache stats
        if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
        continue-on-error: true
        shell: bash
        run: sccache --show-stats
      - name: Linux smoke location
        if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
        run: echo "Linux npm wrapper smoke runs on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu."

  mobile-smoke:
    name: Mobile runtime smoke
    needs: changes
    # Not a required PR context. Pull requests run it only when the mobile
    # runtime surface changed (see the `mobile` filter above); every push to
    # main runs it unconditionally as the pre-release safety net.
    if: >-
      github.event_name != 'schedule' &&
      needs.changes.outputs.heavy == 'true' &&
      (github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true')
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: mozilla-actions/sccache-action@v0.0.11
        id: sccache
        continue-on-error: true
      - name: Enable sccache
        if: steps.sccache.outcome == 'success'
        shell: bash
        run: |
          echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
          echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
          echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
      - name: Install Linux system dependencies
        run: |
          for i in 1 2 3 4 5; do
            sudo apt-get update && break
            echo "apt-get update failed (attempt $i); retrying in 15s"
            sleep 15
          done
          sudo apt-get install -y libdbus-1-dev pkg-config
      - uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false
          save-if: ${{ github.ref == 'refs/heads/main' }}
      - name: Run mobile smoke tests
        # The smoke exercises HTTP/SSE runtime behaviour, not codegen
        # quality; skipping fat LTO + codegen-units=1 cuts the in-script
        # release build from ~12min to a fraction of that.
        env:
          CARGO_PROFILE_RELEASE_LTO: 'off'
          CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
        run: ./scripts/mobile-smoke.sh
      - name: sccache stats
        if: steps.sccache.outcome == 'success'
        continue-on-error: true
        shell: bash
        run: sccache --show-stats

  actionlint:
    name: Workflow lint
    needs: changes
    if: needs.changes.outputs.actions == 'true'
    timeout-minutes: 15
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Run actionlint
        uses: docker://rhysd/actionlint:1.7.12
        with:
          # SC2129 (grouped redirects) is style-only and endemic to the
          # existing GITHUB_ENV/GITHUB_OUTPUT append pattern; SC2221/SC2222
          # flag the long-standing `*.md` glob shadowing the PR-template
          # entry in change detection, which is intentional.
          args: -color -ignore SC2129 -ignore SC2221 -ignore SC2222

  # Check documentation builds without warnings
  docs:
    name: Documentation
    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - name: Install Linux system dependencies
        if: runner.os == 'Linux'
        run: |
          for i in 1 2 3 4 5; do
            sudo apt-get update && break
            echo "apt-get update failed (attempt $i); retrying in 15s"
            sleep 15
          done
          sudo apt-get install -y libdbus-1-dev pkg-config
      - uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false
      - name: Build docs
        run: cargo doc --workspace --no-deps
        env:
          RUSTDOCFLAGS: -Dwarnings