CI #8230
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, check-coauthor-trailers.py via Lint, 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/check-coauthor-trailers.py|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 | |
| run: ./scripts/release/check-versions.sh | |
| - 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 | |
| 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 | |
| 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' | |
| 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' | |
| 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' | |
| 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 website locale dictionary parity | |
| if: github.event_name != 'schedule' | |
| run: node web/scripts/check-locales.mjs | |
| - name: Check harvested contributor credit | |
| if: github.event_name != 'schedule' | |
| shell: bash | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| PR_MERGE_SHA: ${{ github.sha }} | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/test_check_coauthor_trailers.py | |
| if [[ "${EVENT_NAME}" == "pull_request" ]]; then | |
| # Pull-request base.sha can lag the merge ref's actual base parent. | |
| # Comparing it to the synthetic merge checkout therefore | |
| # rechecks unrelated commits that landed on the base after the PR | |
| # opened. Use the merge ref's immutable parents: the base snapshot | |
| # used to synthesize it and the exact PR head, while excluding the | |
| # synthetic merge itself. | |
| if [[ "$(git rev-parse HEAD)" != "${PR_MERGE_SHA}" ]]; then | |
| echo "::error::credit check is not running at the event merge SHA" >&2 | |
| exit 1 | |
| fi | |
| read -r BASE_PARENT HEAD_PARENT EXTRA_PARENT < <( | |
| git show -s --format='%P' "${PR_MERGE_SHA}" | |
| ) | |
| if [[ -z "${BASE_PARENT}" || -z "${HEAD_PARENT}" || -n "${EXTRA_PARENT}" ]]; then | |
| echo "::error::pull-request merge ref must have exactly two parents" >&2 | |
| exit 1 | |
| fi | |
| if [[ "${HEAD_PARENT}" != "${PR_HEAD_SHA}" ]]; then | |
| echo "::error::merge ref head parent does not match the event PR head" >&2 | |
| exit 1 | |
| fi | |
| RANGE="${BASE_PARENT}..${HEAD_PARENT}" | |
| if [[ -z "$(git rev-list -1 "${RANGE}")" ]]; then | |
| echo "::error::pull-request credit range is empty" >&2 | |
| exit 1 | |
| fi | |
| elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then | |
| RANGE="${{ github.event.before }}..${{ github.sha }}" | |
| else | |
| RANGE="HEAD~1..HEAD" | |
| fi | |
| python3 scripts/check-coauthor-trailers.py \ | |
| --author-map .github/AUTHOR_MAP \ | |
| --range "$RANGE" \ | |
| --check-authors | |
| - 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. | |
| 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') | |
| - uses: dtolnay/rust-toolchain@stable | |
| if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch') | |
| - 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') | |
| - name: Enable sccache | |
| if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch') && 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') | |
| with: | |
| node-version: 20 | |
| - name: Install Linux system dependencies | |
| if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name == 'workflow_dispatch' | |
| 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') | |
| 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') | |
| # 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') | |
| 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') && 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' | |
| run: echo "Linux npm wrapper smoke runs on CNB for mirrored first-party branches." | |
| 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 |