| 1 | name: CI |
| 2 | |
| 3 | on: |
| 4 | push: |
| 5 | branches: [master, main] |
| 6 | pull_request: |
| 7 | branches: [master, main] |
| 8 | schedule: |
| 9 | - cron: '31 6 * * 1' |
| 10 | workflow_dispatch: |
| 11 | inputs: |
| 12 | expected_sha: |
| 13 | description: Exact 40-character commit selected by --ref (manual runs force all CI gates) |
| 14 | required: true |
| 15 | type: string |
| 16 | workspace_test_mode: |
| 17 | description: Workspace runner (shared-process qualification uses Ubuntu only; other gates still run) |
| 18 | type: choice |
| 19 | default: nextest |
| 20 | options: |
| 21 | - nextest |
| 22 | - shared-process-twice |
| 23 | |
| 24 | permissions: |
| 25 | contents: read |
| 26 | |
| 27 | concurrency: |
| 28 | # PRs still share one group so a new push cancels the superseded head. |
| 29 | # Push/schedule/dispatch on main must be keyed by SHA: with cancel-in-progress |
| 30 | # false, GitHub still cancels a *pending* run in the same group when a new |
| 31 | # one queues. That is how 31 of the last 40 main CI runs vanished without a |
| 32 | # verdict (test bankruptcy, 2026-08-19). Each SHA gets its own group so |
| 33 | # every commit on main actually finishes. |
| 34 | group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-{0}-{1}', github.workflow, github.sha) }} |
| 35 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} |
| 36 | |
| 37 | env: |
| 38 | CARGO_TERM_COLOR: always |
| 39 | CARGO_INCREMENTAL: 0 |
| 40 | RUSTFLAGS: -Dwarnings |
| 41 | # Test threads share a process and tokio/async frames run deep; the default |
| 42 | # 2 MiB stack overflowed sporadically in runtime_api::tests::start_turn_* |
| 43 | # under load and aborted the whole lib suite (signal 6). 8 MiB is the |
| 44 | # measured-safe floor; nextest's per-process runs are unaffected either way. |
| 45 | RUST_MIN_STACK: 8388608 |
| 46 | |
| 47 | jobs: |
| 48 | changes: |
| 49 | name: Change detection |
| 50 | timeout-minutes: 10 |
| 51 | runs-on: ubuntu-latest |
| 52 | outputs: |
| 53 | heavy: ${{ steps.detect.outputs.heavy }} |
| 54 | workflow: ${{ steps.detect.outputs.workflow }} |
| 55 | mobile: ${{ steps.detect.outputs.mobile }} |
| 56 | actions: ${{ steps.detect.outputs.actions }} |
| 57 | trusted: ${{ steps.trust.outputs.trusted }} |
| 58 | steps: |
| 59 | - name: Classify event trust |
| 60 | id: trust |
| 61 | shell: bash |
| 62 | env: |
| 63 | EVENT_NAME: ${{ github.event_name }} |
| 64 | HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} |
| 65 | THIS_REPO: ${{ github.repository }} |
| 66 | run: | |
| 67 | set -euo pipefail |
| 68 | # "trusted" means the code came from this repository, not a fork. |
| 69 | # Only trusted events may run on the self-hosted macOS runner: this |
| 70 | # repo is public with thousands of forks, and a fork PR on a |
| 71 | # self-hosted runner is arbitrary code execution on that machine. |
| 72 | if [ "${EVENT_NAME}" != "pull_request" ] || [ "${HEAD_REPO}" = "${THIS_REPO}" ]; then |
| 73 | echo "trusted=true" >> "$GITHUB_OUTPUT" |
| 74 | else |
| 75 | echo "trusted=false" >> "$GITHUB_OUTPUT" |
| 76 | fi |
| 77 | - uses: actions/checkout@v7 |
| 78 | with: |
| 79 | fetch-depth: 0 |
| 80 | - name: Detect executable changes |
| 81 | id: detect |
| 82 | shell: bash |
| 83 | env: |
| 84 | EVENT_NAME: ${{ github.event_name }} |
| 85 | BASE_REF: ${{ github.base_ref }} |
| 86 | BEFORE_SHA: ${{ github.event.before }} |
| 87 | EXPECTED_SHA: ${{ inputs.expected_sha }} |
| 88 | run: | |
| 89 | set -euo pipefail |
| 90 | |
| 91 | if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then |
| 92 | if [[ "${#EXPECTED_SHA}" -ne 40 || "${EXPECTED_SHA}" =~ [^0-9a-fA-F] ]]; then |
| 93 | echo "::error::expected_sha must be a full 40-character commit SHA." >&2 |
| 94 | exit 1 |
| 95 | fi |
| 96 | actual="$(git rev-parse HEAD)" |
| 97 | expected_normalized="$(printf '%s' "${EXPECTED_SHA}" | tr '[:upper:]' '[:lower:]')" |
| 98 | if [[ "${actual}" != "${expected_normalized}" ]]; then |
| 99 | echo "::error::Dispatch resolved to ${actual}, not requested ${EXPECTED_SHA}." >&2 |
| 100 | exit 1 |
| 101 | fi |
| 102 | echo "Manual exact-head dispatch: forcing heavy, workflow, mobile, and action gates." |
| 103 | echo "heavy=true" >> "${GITHUB_OUTPUT}" |
| 104 | echo "workflow=true" >> "${GITHUB_OUTPUT}" |
| 105 | echo "mobile=true" >> "${GITHUB_OUTPUT}" |
| 106 | echo "actions=true" >> "${GITHUB_OUTPUT}" |
| 107 | exit 0 |
| 108 | fi |
| 109 | |
| 110 | if [[ "${EVENT_NAME}" == "schedule" ]]; then |
| 111 | echo "heavy=true" >> "${GITHUB_OUTPUT}" |
| 112 | echo "workflow=true" >> "${GITHUB_OUTPUT}" |
| 113 | echo "mobile=true" >> "${GITHUB_OUTPUT}" |
| 114 | echo "actions=true" >> "${GITHUB_OUTPUT}" |
| 115 | exit 0 |
| 116 | fi |
| 117 | |
| 118 | base="" |
| 119 | if [[ "${EVENT_NAME}" == "pull_request" && -n "${BASE_REF}" ]]; then |
| 120 | git fetch --no-tags origin "+${BASE_REF}:refs/remotes/origin/${BASE_REF}" --depth=1 |
| 121 | base="origin/${BASE_REF}" |
| 122 | elif [[ -n "${BEFORE_SHA}" && "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then |
| 123 | base="${BEFORE_SHA}" |
| 124 | fi |
| 125 | |
| 126 | if [[ -z "${base}" ]]; then |
| 127 | echo "heavy=true" >> "${GITHUB_OUTPUT}" |
| 128 | echo "workflow=true" >> "${GITHUB_OUTPUT}" |
| 129 | echo "mobile=true" >> "${GITHUB_OUTPUT}" |
| 130 | echo "actions=true" >> "${GITHUB_OUTPUT}" |
| 131 | exit 0 |
| 132 | fi |
| 133 | |
| 134 | mapfile -t changed < <(git diff --name-only "${base}" "${GITHUB_SHA}" | sort) |
| 135 | heavy=false |
| 136 | workflow=false |
| 137 | mobile=false |
| 138 | actions=false |
| 139 | for path in "${changed[@]}"; do |
| 140 | # Heavy classification. ORDER MATTERS: must-stay-heavy inputs are |
| 141 | # matched BEFORE any light entry so a script that only a |
| 142 | # heavy-gated job exercises can never be misclassified as light. |
| 143 | # Anything unrecognized falls through to the default-heavy `*)` |
| 144 | # arm (fail-safe default-heavy). Light-classified scripts below |
| 145 | # are exercised by ALWAYS-on jobs/steps that run regardless of |
| 146 | # `heavy` (check-versions.sh / check-ohos-deps.sh via Version |
| 147 | # drift, dev-cache/dev-test |
| 148 | # self-checks via Version drift), so no coverage is lost. |
| 149 | # |
| 150 | # Rust reads non-.rs files too, so "docs-only" is decided by what |
| 151 | # the binary and its tests consume, not by file extension. Every |
| 152 | # path under crates/ is heavy (about 70 include_str! calls embed |
| 153 | # crate markdown: skill bodies, crates/tui/CHANGELOG.md, |
| 154 | # SURVIVAL_CONTRACT.md, export fixtures). The docs/ files below |
| 155 | # are embedded with include_str!/include_bytes! or read by Rust |
| 156 | # tests, so they must match before the docs/*|*.md light arm. |
| 157 | # Everything else Rust reads (config.example.toml, workflows/, |
| 158 | # fleets/, scripts/*.json, .codewhale/, .env.example) is already |
| 159 | # heavy by default. .github/scripts/release-workflows.test.js |
| 160 | # fails if an include_str!/include_bytes! target classifies light. |
| 161 | case "${path}" in |
| 162 | scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py|scripts/check-config-example.py) |
| 163 | heavy=true |
| 164 | ;; |
| 165 | crates/*|docs/HOOKS.md|docs/KEYBINDINGS.md|docs/TELEMETRY.md|docs/FLEET.md|docs/FLEET_WORKFLOW_TUTORIAL.md|docs/zh_hans/FLEET.md|docs/2512.24601v2.pdf|docs/cloud-facts/*|docs/examples/*) |
| 166 | heavy=true |
| 167 | ;; |
| 168 | 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) |
| 169 | ;; |
| 170 | *) |
| 171 | heavy=true |
| 172 | ;; |
| 173 | esac |
| 174 | case "${path}" in |
| 175 | crates/workflow/*|.github/workflows/ci.yml) |
| 176 | workflow=true |
| 177 | ;; |
| 178 | esac |
| 179 | # Mobile runtime surface: the `codewhale serve --mobile` |
| 180 | # HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull |
| 181 | # requests run the smoke only when one of these changes; every |
| 182 | # push to main still runs it unconditionally as the pre-release |
| 183 | # safety net for anything this filter misses. |
| 184 | case "${path}" in |
| 185 | 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) |
| 186 | mobile=true |
| 187 | ;; |
| 188 | esac |
| 189 | case "${path}" in |
| 190 | .github/workflows/*|.github/actionlint.yml|action.yml|scripts/github-action/*) |
| 191 | actions=true |
| 192 | ;; |
| 193 | esac |
| 194 | done |
| 195 | |
| 196 | echo "heavy=${heavy}" >> "${GITHUB_OUTPUT}" |
| 197 | echo "workflow=${workflow}" >> "${GITHUB_OUTPUT}" |
| 198 | echo "mobile=${mobile}" >> "${GITHUB_OUTPUT}" |
| 199 | echo "actions=${actions}" >> "${GITHUB_OUTPUT}" |
| 200 | |
| 201 | echo "Heavy Rust CI required: ${heavy}" |
| 202 | echo "Workflow RLM cache CI required: ${workflow}" |
| 203 | echo "Mobile runtime smoke required (PRs): ${mobile}" |
| 204 | echo "Workflow lint required: ${actions}" |
| 205 | printf 'Changed files:\n' |
| 206 | printf ' %s\n' "${changed[@]}" |
| 207 | |
| 208 | versions: |
| 209 | name: Version drift |
| 210 | timeout-minutes: 15 |
| 211 | runs-on: ubuntu-latest |
| 212 | steps: |
| 213 | - uses: actions/checkout@v7 |
| 214 | with: |
| 215 | fetch-depth: 0 |
| 216 | - uses: dtolnay/rust-toolchain@stable |
| 217 | - uses: actions/setup-node@v7 |
| 218 | with: |
| 219 | node-version: 22 |
| 220 | - name: Check version drift |
| 221 | # Checks 7 and 12 audit the previous-tag..HEAD commit range, not this |
| 222 | # tree, so a receipt another merge forgot reddens every open PR. They |
| 223 | # report here and block on every release path (release-candidate.yml, |
| 224 | # auto-tag.yml, release.yml, prepare-release.sh), which is where a |
| 225 | # missing receipt actually matters. |
| 226 | run: ./scripts/release/check-versions.sh --range-audit-advisory |
| 227 | - name: Check this PR's feature release-note receipts |
| 228 | # The range audit above is advisory because previous-tag..HEAD blames |
| 229 | # every open PR for receipts other merges forgot. This is the same |
| 230 | # check scoped to the PR's own commits, so it blocks: a `feat:` commit |
| 231 | # that references #N must add #N to CHANGELOG.md in the same PR. |
| 232 | # Locally: scripts/preflight.sh. |
| 233 | if: github.event_name == 'pull_request' |
| 234 | env: |
| 235 | PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 236 | run: ./scripts/release/check-feature-release-notes.sh "${PR_BASE_SHA}" HEAD |
| 237 | - name: Check contributor credit |
| 238 | # The three credit surfaces were only ever cross-checked against |
| 239 | # `requiredCandidateCredits`, a hand-maintained list -- so they proved |
| 240 | # each other consistent while a contributor nobody remembered stayed |
| 241 | # invisible. Nine were missing from 0.10.0. This derives the expected |
| 242 | # set from the commit range instead (authors, co-author trailers, and |
| 243 | # `Harvested from PR #N by @handle`), so forgetting someone is red. |
| 244 | run: python3 scripts/check-contributor-credit.py |
| 245 | - name: Check bundled plugin claims |
| 246 | # The 0.10.0 section claimed Computer Use 0.4.0 at revision ca6be22 |
| 247 | # while the tree shipped 0.11.2 at d8640b17f275 -- three upstream |
| 248 | # releases apart, with nothing comparing the prose to the assets. |
| 249 | run: python3 scripts/check-bundled-plugin-claims.py |
| 250 | - name: Check OHOS dependency graph |
| 251 | run: ./scripts/release/check-ohos-deps.sh |
| 252 | - name: Check release helper contracts |
| 253 | run: | |
| 254 | bash .github/scripts/agent-task-metadata.test.sh |
| 255 | bash scripts/release/check-feature-release-notes.test.sh |
| 256 | bash scripts/release/generate-release-body.test.sh |
| 257 | bash scripts/release/install-dogfood.test.sh |
| 258 | bash scripts/release/prepare-release.test.sh |
| 259 | bash scripts/release/prune-actions-caches.test.sh |
| 260 | bash scripts/release/require-rc-receipt.test.sh |
| 261 | bash scripts/release/require-release-tag-checkout.test.sh |
| 262 | bash scripts/release/validate-crate-publish-order.test.sh |
| 263 | python3 scripts/release/publish-crates.test.py |
| 264 | bash scripts/release/verify-remote-tag.test.sh |
| 265 | bash packaging/aur/render.test.sh |
| 266 | sh scripts/dev-cache.test.sh |
| 267 | sh scripts/with-hermetic-test-home.test.sh |
| 268 | bash .github/scripts/update-homebrew-tap.test.sh |
| 269 | node .github/scripts/release-workflows.test.js |
| 270 | node --test .github/scripts/d8-executable.test.js |
| 271 | node --test scripts/release/assemble-release-assets.test.js |
| 272 | node --test scripts/release/ensure-release-assets-absent.test.js |
| 273 | node --test scripts/release/verify-release-inventory.test.js |
| 274 | - name: Run runtime web client tests |
| 275 | # crates/tui/tests/runtime_web_client.test.mjs exercises the embedded |
| 276 | # web client's event/snapshot state machine; it ran nowhere before. |
| 277 | run: node --test crates/tui/tests/runtime_web_client.test.mjs |
| 278 | |
| 279 | plugin-conversion: |
| 280 | name: Plugin conversion |
| 281 | timeout-minutes: 5 |
| 282 | runs-on: ubuntu-latest |
| 283 | steps: |
| 284 | - uses: actions/checkout@v7 |
| 285 | - uses: actions/setup-python@v7 |
| 286 | with: |
| 287 | python-version: '3.12' |
| 288 | - uses: actions/setup-node@v7 |
| 289 | with: |
| 290 | node-version: 22 |
| 291 | - name: Install data parser |
| 292 | run: python3 -m pip install --disable-pip-version-check PyYAML==6.0.2 |
| 293 | - name: Check offline plugin conversion |
| 294 | # Pinned upstream YAML is data only; only our synthetic Node fixtures run. |
| 295 | # Always on: scripts and fixture changes must not depend on Rust CI filters. |
| 296 | run: python3 -B scripts/test_convert_plugin.py -v |
| 297 | |
| 298 | integrations: |
| 299 | name: Integrations |
| 300 | timeout-minutes: 15 |
| 301 | runs-on: ubuntu-latest |
| 302 | steps: |
| 303 | - uses: actions/checkout@v7 |
| 304 | - name: Check Lighthouse SSH policy |
| 305 | run: python3 scripts/tencent-lighthouse/test_bootstrap_ssh.py |
| 306 | - uses: actions/setup-node@v7 |
| 307 | with: |
| 308 | node-version: 22 |
| 309 | - name: Test reusable GitHub review action |
| 310 | run: node --test scripts/github-action/*.test.mjs |
| 311 | - name: Run Runtime SDK runtime and type tests |
| 312 | run: | |
| 313 | npm ci --ignore-scripts --workspace @codewhale/runtime-sdk |
| 314 | npm test --workspace @codewhale/runtime-sdk |
| 315 | - name: Run chat-bridge suites |
| 316 | # All four bridges + bridge-core ship dependency-free node --test |
| 317 | # suites that no workflow ran. weixin has no lockfile by design |
| 318 | # (zero deps); npm test works without npm ci everywhere here. |
| 319 | run: | |
| 320 | set -euo pipefail |
| 321 | for bridge in bridge-core feishu-bridge telegram-bridge wecom-bridge weixin-bridge; do |
| 322 | echo "== ${bridge}" |
| 323 | (cd "integrations/${bridge}" && npm test) |
| 324 | done |
| 325 | |
| 326 | - name: Build computer-use test desktop |
| 327 | # Spawn assertions have short request deadlines; keep the cold image |
| 328 | # build outside those deadlines and fail image preparation explicitly. |
| 329 | timeout-minutes: 10 |
| 330 | run: >- |
| 331 | docker build --tag codewhale-cu-linux |
| 332 | --file crates/tui/plugins/computer-use/docker/Dockerfile |
| 333 | crates/tui/plugins/computer-use |
| 334 | |
| 335 | - name: Run computer-use plugin suites |
| 336 | # The bundled plugin is dependency-free too; its suites cover the |
| 337 | # manifest contract, the registry, the exec/ssh transport, the four |
| 338 | # platform backends, and the MCP stdio protocol. No GUI input runs. |
| 339 | run: (cd crates/tui/plugins/computer-use && npm test) |
| 340 | |
| 341 | - name: Run extension host suites and check the committed bundle |
| 342 | # The TypeScript extension host (experimental, [features] |
| 343 | # extension_host). Tests exercise both the committed bundle and |
| 344 | # source services backed by locked Cordis dependencies. Install before |
| 345 | # testing; then reject any bundle drift. Node 22 matches the host floor. |
| 346 | run: | |
| 347 | set -euo pipefail |
| 348 | cd crates/tui/extension-host |
| 349 | npm ci --ignore-scripts |
| 350 | npm test |
| 351 | npm run typecheck |
| 352 | npm run build |
| 353 | git diff --exit-code -- dist |
| 354 | - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 |
| 355 | with: |
| 356 | # The validated floor (`BUN_MIN_VERSION_FOR_EXTENSION_HOST`). |
| 357 | bun-version: 1.4.0 |
| 358 | - name: Run extension host suites on Bun |
| 359 | # Bun is an opt-in host runtime (`runtime = "bun"` or `"auto"`); |
| 360 | # Node stays the default. The same suites spawn the committed bundle |
| 361 | # under `bun test`, with Bun's launch flags. This leg runs the JS |
| 362 | # suites only; the Rust host tests run on Node. |
| 363 | run: | |
| 364 | set -euo pipefail |
| 365 | cd crates/tui/extension-host |
| 366 | npm run test:bun |
| 367 | |
| 368 | extension-host-runtimes: |
| 369 | name: Extension host runtimes (${{ matrix.os }}) |
| 370 | needs: changes |
| 371 | if: needs.changes.outputs.heavy == 'true' |
| 372 | timeout-minutes: 15 |
| 373 | strategy: |
| 374 | fail-fast: false |
| 375 | matrix: |
| 376 | os: [ubuntu-latest, macos-latest, windows-latest] |
| 377 | runs-on: ${{ matrix.os }} |
| 378 | defaults: |
| 379 | run: |
| 380 | working-directory: crates/tui/extension-host |
| 381 | shell: bash |
| 382 | steps: |
| 383 | - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 384 | - uses: actions/setup-node@v7 |
| 385 | with: |
| 386 | node-version: 22 |
| 387 | - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2 |
| 388 | with: |
| 389 | bun-version: 1.4.0 |
| 390 | - run: npm ci |
| 391 | - run: npm run typecheck && npm run build |
| 392 | - name: Required Node and Bun source-host receipts |
| 393 | run: npm test && npm run test:bun |
| 394 | - name: Compile the same entry with the installed runtime and test it |
| 395 | run: | |
| 396 | bun_binary="$(bun -p 'process.execPath')" |
| 397 | host_binary="$(node -p "require('path').join(process.env.RUNNER_TEMP, 'codewhale-extension-host' + (process.platform === 'win32' ? '.exe' : ''))")" |
| 398 | node compile-host.mjs --bun "$bun_binary" --output "$host_binary" |
| 399 | CODEWHALE_COMPILED_HOST_TEST_BINARY="$host_binary" CODEWHALE_BUN_TEST_BINARY="$bun_binary" node --test test/compiled-host.test.mjs |
| 400 | - name: Keep runtime receipts on failure |
| 401 | if: failure() |
| 402 | uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 403 | with: |
| 404 | name: extension-host-runtime-${{ matrix.os }} |
| 405 | path: crates/tui/extension-host/dist/builtin-modules.json |
| 406 | if-no-files-found: error |
| 407 | |
| 408 | vscode-extension: |
| 409 | name: VS Code extension |
| 410 | timeout-minutes: 15 |
| 411 | runs-on: ubuntu-latest |
| 412 | defaults: |
| 413 | run: |
| 414 | working-directory: extensions/vscode |
| 415 | steps: |
| 416 | - uses: actions/checkout@v7 |
| 417 | - uses: actions/setup-node@v7 |
| 418 | with: |
| 419 | # The extension targets VS Code >=1.90, whose extension host is |
| 420 | # Node 20, and its @types/node pin is ^20. Build and test on the |
| 421 | # runtime the extension actually ships against. |
| 422 | node-version: 20 |
| 423 | - name: Install extension dependencies |
| 424 | run: npm ci |
| 425 | - name: Run VS Code extension suites |
| 426 | # extensions/vscode ships node --test suites (api, markdown, sse) that |
| 427 | # NO workflow ran: release.yml only reads package.json for a version |
| 428 | # string, so the whole client compiled and shipped without its tests or |
| 429 | # `tsc` ever running in CI. `npm test` compiles first (tsc -p ./), so |
| 430 | # this is the type-check gate for the extension too. |
| 431 | run: npm test |
| 432 | - name: Package VS Code extension |
| 433 | run: npm run package |
| 434 | |
| 435 | safety-gate: |
| 436 | name: Safety gate |
| 437 | needs: changes |
| 438 | if: needs.changes.outputs.heavy == 'true' |
| 439 | timeout-minutes: 30 |
| 440 | runs-on: ubuntu-latest |
| 441 | steps: |
| 442 | - uses: actions/checkout@v7 |
| 443 | - uses: dtolnay/rust-toolchain@master |
| 444 | with: |
| 445 | toolchain: stable |
| 446 | - uses: mozilla-actions/sccache-action@v0.0.11 |
| 447 | id: sccache |
| 448 | # The GitHub Actions cache backend is main-only, mirroring |
| 449 | # rust-cache's save-if: PR runs wrote thousands of refs/pull/N |
| 450 | # entries that pushed the repo past its 10 GiB cache cap. |
| 451 | if: github.ref == 'refs/heads/main' |
| 452 | continue-on-error: true |
| 453 | - name: Enable sccache |
| 454 | if: steps.sccache.outcome == 'success' |
| 455 | shell: bash |
| 456 | run: | |
| 457 | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" |
| 458 | echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" |
| 459 | echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" |
| 460 | - name: Install Linux system dependencies |
| 461 | run: | |
| 462 | for i in 1 2 3 4 5; do |
| 463 | sudo apt-get update && break |
| 464 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 465 | sleep 15 |
| 466 | done |
| 467 | sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles |
| 468 | sh scripts/prepare-linux-test-sandbox.sh |
| 469 | - name: Ubuntu build headroom |
| 470 | shell: bash |
| 471 | run: bash scripts/prepare-ubuntu-build-headroom.sh |
| 472 | - uses: Swatinem/rust-cache@v2 |
| 473 | with: |
| 474 | cache-bin: false |
| 475 | save-if: ${{ github.ref == 'refs/heads/main' }} |
| 476 | - uses: taiki-e/install-action@nextest |
| 477 | - uses: actions/setup-node@v7 |
| 478 | with: |
| 479 | node-version: 24 |
| 480 | - name: Hermetic safety and authorization tests |
| 481 | env: |
| 482 | RUST_MIN_STACK: "8388608" |
| 483 | CODEWHALE_EXT_HOST_TESTS: '1' |
| 484 | # nextest fails when the filter selects no test (`--no-tests=fail`), |
| 485 | # so moving these modules to another crate cannot turn this step into |
| 486 | # a silent 0-test pass. It does not catch a step that merely shrinks, |
| 487 | # so name every package that owns safety tests explicitly: |
| 488 | # codewhale-runtime owns `safe_label` (its hostile-authority test) |
| 489 | # since RS-2; add the next package when another safety module moves. |
| 490 | run: | |
| 491 | sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-tui -p codewhale-runtime --lib --locked --no-tests=fail -E 'test(auto_review) | test(authority) | test(sandbox)' |
| 492 | sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-execpolicy --locked |
| 493 | |
| 494 | lint: |
| 495 | name: Lint |
| 496 | needs: changes |
| 497 | # Measured on 89ee629e with runner headroom: clippy 10 min, runtime-contract |
| 498 | # budget 16 min, and persistence-backlog still running at the old 45-minute |
| 499 | # cancellation. Commands, filters and budgets are unchanged. |
| 500 | timeout-minutes: 75 |
| 501 | runs-on: ubuntu-latest |
| 502 | steps: |
| 503 | - uses: actions/checkout@v7 |
| 504 | with: |
| 505 | fetch-depth: 0 |
| 506 | - uses: dtolnay/rust-toolchain@master |
| 507 | if: needs.changes.outputs.heavy == 'true' |
| 508 | with: |
| 509 | toolchain: stable |
| 510 | components: rustfmt, clippy |
| 511 | - uses: mozilla-actions/sccache-action@v0.0.11 |
| 512 | id: sccache |
| 513 | # Cache bootstrap failures (e.g. GitHub 504s fetching the sccache |
| 514 | # binary) degrade to an uncached build instead of failing product CI. |
| 515 | continue-on-error: true |
| 516 | # Main-only GitHub Actions cache backend; see the Safety gate job. |
| 517 | if: needs.changes.outputs.heavy == 'true' && github.ref == 'refs/heads/main' |
| 518 | - name: Enable sccache |
| 519 | if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success' |
| 520 | shell: bash |
| 521 | run: | |
| 522 | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" |
| 523 | echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" |
| 524 | echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" |
| 525 | - name: Install Linux system dependencies |
| 526 | if: needs.changes.outputs.heavy == 'true' |
| 527 | run: | |
| 528 | for i in 1 2 3 4 5; do |
| 529 | sudo apt-get update && break |
| 530 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 531 | sleep 15 |
| 532 | done |
| 533 | sudo apt-get install -y libdbus-1-dev pkg-config |
| 534 | - name: Ubuntu build headroom |
| 535 | if: needs.changes.outputs.heavy == 'true' |
| 536 | shell: bash |
| 537 | run: bash scripts/prepare-ubuntu-build-headroom.sh |
| 538 | - uses: Swatinem/rust-cache@v2 |
| 539 | if: needs.changes.outputs.heavy == 'true' |
| 540 | with: |
| 541 | cache-bin: false |
| 542 | # PRs restore the cache seeded by main but skip the expensive |
| 543 | # post-job save; sccache covers PR-specific compilation deltas. |
| 544 | save-if: ${{ github.ref == 'refs/heads/main' }} |
| 545 | - name: Check formatting |
| 546 | if: needs.changes.outputs.heavy == 'true' |
| 547 | run: cargo fmt --all -- --check |
| 548 | - name: Run clippy |
| 549 | # --all-targets, because without it CI never lints test code at all. |
| 550 | # That gap is not theoretical: the v0.9.10 release gate opened with |
| 551 | # four clippy failures sitting on a green main, and every one of them |
| 552 | # was in a test target. crates/tui/AGENTS.md already documents the |
| 553 | # all-targets command as the release gate; this makes CI run the gate |
| 554 | # it points contributors at instead of a weaker subset. |
| 555 | # |
| 556 | # collapsible_if and assertions_on_constants are no longer allowed for |
| 557 | # the same reason — they were three of those four, so the allowances |
| 558 | # were hiding exactly the class of problem that reached the gate. The |
| 559 | # three that remain are deliberate project style, not oversights. |
| 560 | if: needs.changes.outputs.heavy == 'true' |
| 561 | run: | |
| 562 | cargo clippy --workspace --all-targets --all-features --locked -- \ |
| 563 | -D warnings \ |
| 564 | -A clippy::uninlined_format_args \ |
| 565 | -A clippy::too_many_arguments \ |
| 566 | -A clippy::unnecessary_map_or |
| 567 | - name: sccache stats |
| 568 | if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success' |
| 569 | continue-on-error: true |
| 570 | shell: bash |
| 571 | run: sccache --show-stats |
| 572 | - name: Check provider registry drift |
| 573 | if: needs.changes.outputs.heavy == 'true' |
| 574 | run: | |
| 575 | python3 scripts/check-provider-registry.py |
| 576 | python3 scripts/check-config-example.py |
| 577 | - name: Check the offline model seed is generated |
| 578 | if: needs.changes.outputs.heavy == 'true' |
| 579 | # crates/config/assets/models_dev.bundled.json is rendered from |
| 580 | # scripts/catalog/models_dev_seed.toml and its lock (#6396). A hand edit |
| 581 | # fails here; docs/CATALOG_REFRESH.md has the regenerate steps. |
| 582 | run: | |
| 583 | python3 scripts/catalog_models_dev.py seed render --check |
| 584 | python3 -m unittest scripts/catalog_models_dev_test.py |
| 585 | - name: Check command-contract prototype boundary |
| 586 | if: needs.changes.outputs.heavy == 'true' |
| 587 | # The runtime -> UI ratchet baseline is compared with the one at the |
| 588 | # PR base too, so a hand-edited JSON cannot raise it. |
| 589 | shell: bash |
| 590 | env: |
| 591 | PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 592 | PUSH_BEFORE_SHA: ${{ github.event.before }} |
| 593 | run: | |
| 594 | python3 scripts/test_check_command_crate_boundaries.py |
| 595 | baseline="${PR_BASE_SHA:-${PUSH_BEFORE_SHA:-}}" |
| 596 | if [[ -n "${baseline}" && ! "${baseline}" =~ ^0+$ ]]; then |
| 597 | git fetch --no-tags origin "${baseline}" |
| 598 | python3 scripts/check-command-crate-boundaries.py --baseline-ref "${baseline}" |
| 599 | else |
| 600 | python3 scripts/check-command-crate-boundaries.py |
| 601 | fi |
| 602 | - name: Check command migration manifest |
| 603 | if: needs.changes.outputs.heavy == 'true' |
| 604 | env: |
| 605 | PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 606 | PUSH_BEFORE_SHA: ${{ github.event.before }} |
| 607 | run: | |
| 608 | python3 scripts/test_check_command_migration_manifest.py |
| 609 | baseline="${PR_BASE_SHA:-${PUSH_BEFORE_SHA:-}}" |
| 610 | if [[ -n "${baseline}" && ! "${baseline}" =~ ^0+$ ]]; then |
| 611 | git fetch --no-tags origin "${baseline}" |
| 612 | python3 scripts/check-command-migration-manifest.py --baseline-ref "${baseline}" |
| 613 | else |
| 614 | python3 scripts/check-command-migration-manifest.py |
| 615 | fi |
| 616 | - name: Check reqwest client constructors |
| 617 | if: needs.changes.outputs.heavy == 'true' |
| 618 | run: | |
| 619 | python3 scripts/test_check_reqwest_builders.py |
| 620 | python3 scripts/check-reqwest-builders.py |
| 621 | # Clippy above runs with `--all-targets` (see the clippy step), so the |
| 622 | # gap this ratchet covers is not "tests keep it alive" but suppression |
| 623 | # itself: it refuses to let dead-code suppression rise (#4785), counting |
| 624 | # both `#[allow(dead_code)]` and `#[expect(dead_code)]`, because counting |
| 625 | # one spelling let a sweep rewrite allows as expects and book it as |
| 626 | # progress (#6241). |
| 627 | - name: Test dead-code and blocking-calls budget scripts |
| 628 | if: needs.changes.outputs.heavy == 'true' |
| 629 | run: | |
| 630 | python3 scripts/test_check_dead_code_budget.py |
| 631 | python3 scripts/test_check_blocking_calls_budget.py |
| 632 | # The four budget ratchets below (dead-code, blocking-calls, |
| 633 | # runtime-contract, persistence-backlog) assert whole-repo properties. |
| 634 | # They used to be advisory on every pull request and fatal on push, so |
| 635 | # every PR looked green and main went red after merge (38 of 154 |
| 636 | # main-push runs green, 2026-09-16..22). Now scripts/ratchet-gate.sh |
| 637 | # blocks a same-repo PR that adds debt and prints the `--update` receipt |
| 638 | # command that lands the fix in that PR. It stays advisory in exactly |
| 639 | # two cases: the PR's merge base fails the same check (inherited debt, |
| 640 | # re-checked on a throwaway checkout of the base), or the PR comes from |
| 641 | # a fork. Pushes to main, schedule and dispatch have no base and block. |
| 642 | - name: Resolve ratchet merge base |
| 643 | if: needs.changes.outputs.heavy == 'true' && github.event_name == 'pull_request' |
| 644 | shell: bash |
| 645 | env: |
| 646 | PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 647 | run: | |
| 648 | set -euo pipefail |
| 649 | # The PR checkout is the synthetic merge commit; its first parent is |
| 650 | # the base tip this PR actually merges into. |
| 651 | if git rev-parse -q --verify HEAD^2 >/dev/null; then |
| 652 | base="$(git rev-parse HEAD^1)" |
| 653 | else |
| 654 | base="${PR_BASE_SHA}" |
| 655 | fi |
| 656 | echo "Ratchet merge base: ${base}" |
| 657 | echo "RATCHET_BASE_SHA=${base}" >> "${GITHUB_ENV}" |
| 658 | - name: Check dead-code budget |
| 659 | if: needs.changes.outputs.heavy == 'true' |
| 660 | continue-on-error: ${{ github.event_name == 'pull_request' && needs.changes.outputs.trusted != 'true' }} |
| 661 | run: >- |
| 662 | bash scripts/ratchet-gate.sh --name dead-code |
| 663 | --update "python3 scripts/check-dead-code-budget.py --update" |
| 664 | -- python3 scripts/check-dead-code-budget.py |
| 665 | # Ratchet for blocking calls that could park Tokio workers: any new |
| 666 | # thread::sleep/std::fs site outside spawn_blocking, dedicated-thread, |
| 667 | # or test scopes must be isolated or budgeted (#6149). |
| 668 | - name: Check blocking-calls budget |
| 669 | if: needs.changes.outputs.heavy == 'true' |
| 670 | continue-on-error: ${{ github.event_name == 'pull_request' && needs.changes.outputs.trusted != 'true' }} |
| 671 | run: >- |
| 672 | bash scripts/ratchet-gate.sh --name blocking-calls |
| 673 | --update "python3 scripts/check-blocking-calls-budget.py --update" |
| 674 | -- python3 scripts/check-blocking-calls-budget.py |
| 675 | - name: Test runtime-contract measurement harness |
| 676 | if: needs.changes.outputs.heavy == 'true' |
| 677 | run: | |
| 678 | python3 scripts/test_measure_runtime_contract.py |
| 679 | python3 scripts/test_check_runtime_contract_budget.py |
| 680 | # The offline runtime-contract measurement needs the full locked graph, |
| 681 | # dev-dependencies included (e.g. wiremock -> assert-json-diff), but |
| 682 | # clippy above builds no test targets and the rust-cache registry key |
| 683 | # derives from Cargo.lock, so any lock-changing PR (every dependabot |
| 684 | # bump) restores an empty cache and the hermetic `cargo test --offline` |
| 685 | # dies with "failed to download ... --offline was specified" before a |
| 686 | # single budget is measured. Fetch the locked graph once here so the |
| 687 | # measurement below is deterministic on every branch. |
| 688 | - name: Fetch locked dependency graph for offline measurement |
| 689 | if: needs.changes.outputs.heavy == 'true' |
| 690 | run: cargo fetch --locked |
| 691 | # Provider-free local measurement. The checker forces Cargo offline and |
| 692 | # the measurement script runs only locked, ignored Rust metric tests. |
| 693 | - name: Check runtime-contract budget |
| 694 | if: needs.changes.outputs.heavy == 'true' |
| 695 | # Blocking for same-repo PRs; see the ratchet note above. |
| 696 | continue-on-error: ${{ github.event_name == 'pull_request' && needs.changes.outputs.trusted != 'true' }} |
| 697 | run: >- |
| 698 | bash scripts/ratchet-gate.sh --name runtime-contract |
| 699 | --update "python3 scripts/check-runtime-contract-budget.py --update --allow-increase" |
| 700 | -- python3 scripts/check-runtime-contract-budget.py |
| 701 | # Provider-free paused-consumer measurement of the production |
| 702 | # persistence request channel. RSS is sampled only on macOS; every host |
| 703 | # enforces the accepted/retained request and payload contract. |
| 704 | - name: Test persistence-backlog measurement and checker harnesses |
| 705 | if: needs.changes.outputs.heavy == 'true' |
| 706 | run: | |
| 707 | python3 scripts/test_measure_persistence_backlog.py |
| 708 | python3 scripts/test_check_persistence_backlog_budget.py |
| 709 | - name: Check persistence-backlog budget |
| 710 | if: needs.changes.outputs.heavy == 'true' |
| 711 | # Blocking for same-repo PRs; see the ratchet note above. |
| 712 | continue-on-error: ${{ github.event_name == 'pull_request' && needs.changes.outputs.trusted != 'true' }} |
| 713 | run: >- |
| 714 | bash scripts/ratchet-gate.sh --name persistence-backlog |
| 715 | --update "python3 scripts/check-persistence-backlog-budget.py --update" |
| 716 | -- python3 scripts/check-persistence-backlog-budget.py |
| 717 | - name: Check README translations stay in sync |
| 718 | if: github.event_name != 'schedule' |
| 719 | run: python3 scripts/check-readme-translations.py |
| 720 | - name: Check README locale link symmetry |
| 721 | if: github.event_name != 'schedule' |
| 722 | run: bash scripts/check-readme-locales.sh |
| 723 | - name: Check TUI locale pack parity |
| 724 | if: github.event_name != 'schedule' |
| 725 | run: python3 scripts/check-tui-locale-parity.py |
| 726 | - name: Check TUI product vocabulary |
| 727 | if: github.event_name != 'schedule' |
| 728 | run: sh scripts/check-tui-product-vocabulary.sh |
| 729 | - name: Check website locale dictionary parity |
| 730 | if: github.event_name != 'schedule' |
| 731 | run: node web/scripts/check-locales.mjs |
| 732 | - name: Skip Rust lint for light change |
| 733 | if: needs.changes.outputs.heavy != 'true' |
| 734 | run: echo "No executable Rust changes detected; preserving required Lint context." |
| 735 | |
| 736 | msrv: |
| 737 | # The workspace must build on its minimum supported toolchain. A lint |
| 738 | # expectation that newer rustc fulfils can be unfulfilled on 1.89 and |
| 739 | # fail the build there while stable stays green (#6543). |
| 740 | name: MSRV check (1.89) |
| 741 | needs: changes |
| 742 | if: needs.changes.outputs.heavy == 'true' |
| 743 | timeout-minutes: 45 |
| 744 | runs-on: ubuntu-latest |
| 745 | steps: |
| 746 | - uses: actions/checkout@v7 |
| 747 | - uses: dtolnay/rust-toolchain@master |
| 748 | with: |
| 749 | toolchain: "1.89" |
| 750 | - name: Install Linux system dependencies |
| 751 | run: | |
| 752 | for i in 1 2 3 4 5; do |
| 753 | sudo apt-get update && break |
| 754 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 755 | sleep 15 |
| 756 | done |
| 757 | sudo apt-get install -y libdbus-1-dev pkg-config |
| 758 | - uses: Swatinem/rust-cache@v2 |
| 759 | with: |
| 760 | cache-bin: false |
| 761 | save-if: ${{ github.ref == 'refs/heads/main' }} |
| 762 | - name: cargo +1.89 check |
| 763 | run: cargo +1.89 check --workspace --locked |
| 764 | |
| 765 | workflow-rlm-cache: |
| 766 | name: Workflow RLM cache |
| 767 | needs: changes |
| 768 | if: needs.changes.outputs.workflow == 'true' |
| 769 | timeout-minutes: 30 |
| 770 | runs-on: ubuntu-latest |
| 771 | steps: |
| 772 | - uses: actions/checkout@v7 |
| 773 | - uses: dtolnay/rust-toolchain@stable |
| 774 | - uses: mozilla-actions/sccache-action@v0.0.11 |
| 775 | id: sccache |
| 776 | # Main-only GitHub Actions cache backend; see the Safety gate job. |
| 777 | if: github.ref == 'refs/heads/main' |
| 778 | continue-on-error: true |
| 779 | - name: Enable sccache |
| 780 | if: steps.sccache.outcome == 'success' |
| 781 | shell: bash |
| 782 | run: | |
| 783 | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" |
| 784 | echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" |
| 785 | echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" |
| 786 | - uses: Swatinem/rust-cache@v2 |
| 787 | with: |
| 788 | cache-bin: false |
| 789 | save-if: ${{ github.ref == 'refs/heads/main' }} |
| 790 | - name: Run workflow crate tests |
| 791 | run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-workflow --locked |
| 792 | |
| 793 | test: |
| 794 | name: Test |
| 795 | needs: changes |
| 796 | # Required contexts "Test (ubuntu-latest)" / "Test (macos-latest)" / |
| 797 | # "Test (windows-latest)" derive from job name + matrix.os and are |
| 798 | # independent of runs-on. For light changes the macOS/Windows legs only |
| 799 | # echo a skip line, so run them on ubuntu instead of queueing for scarce |
| 800 | # macOS/Windows runners. Heavy pull requests run the Linux lane directly; |
| 801 | # non-PR release/main pushes use CNB for Linux. |
| 802 | # The ternary is safe: matrix.os is always a non-empty literal, so |
| 803 | # runs-on can never evaluate to empty. |
| 804 | # A cold GitHub-hosted Mac spent 77-80 minutes in Test on 2026-09-23 |
| 805 | # (fork PRs #6431, #6417), too close to the old 90-minute limit. |
| 806 | # On 2026-10-04 a PR leg spent 111 minutes compiling with no cache and |
| 807 | # was cancelled at 120 with 12,356 tests passed and none failed. The |
| 808 | # limit stays a literal (release-workflows.test.js, #5496); 165 bounds |
| 809 | # hangs for every leg until the CI speed redesign lands. |
| 810 | timeout-minutes: 165 |
| 811 | # macOS legs go to the self-hosted Mac ONLY when all three hold: the |
| 812 | # change is heavy, the event is trusted (not a fork PR), and the |
| 813 | # CW_SELF_HOSTED_MAC repo variable is 'true'. That variable is the kill |
| 814 | # switch: unset it and every leg falls back to GitHub-hosted runners |
| 815 | # immediately, with no commit — important because an offline |
| 816 | # self-hosted runner queues jobs forever, which is worse than a slow one. |
| 817 | 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 }} |
| 818 | strategy: |
| 819 | # A failure on one desktop platform must not erase evidence from the |
| 820 | # other one. We need both conclusions to diagnose and release safely. |
| 821 | fail-fast: false |
| 822 | matrix: |
| 823 | # Linux workspace tests run directly for pull requests. CNB remains |
| 824 | # the Linux lane for non-PR release/main pushes. The explicit manual |
| 825 | # libtest qualification uses one hosted Ubuntu job, never a fork PR or |
| 826 | # self-hosted runner; normal dispatches and PRs keep all three legs. |
| 827 | os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }} |
| 828 | steps: |
| 829 | - name: Skip tests for light change |
| 830 | if: needs.changes.outputs.heavy != 'true' |
| 831 | run: echo "No executable Rust changes detected; preserving required Test context." |
| 832 | - uses: actions/checkout@v7 |
| 833 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 834 | - name: Test Windows installer PATH helper |
| 835 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest' |
| 836 | shell: pwsh |
| 837 | run: ./scripts/installer/update-user-path.tests.ps1 |
| 838 | - name: Install NSIS for Windows installer regression |
| 839 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest' |
| 840 | shell: pwsh |
| 841 | # Bounded retry, not a weaker check (#5403). Every observed failure here |
| 842 | # was Chocolatey's feed, not the code: a 504 from the V2 API, and |
| 843 | # "package was not found with the source(s) listed". A single attempt |
| 844 | # made `Test (windows-latest)` — a required check on every PR — report |
| 845 | # on community.chocolatey.org's availability instead of on the tree. |
| 846 | # NSIS must still install for the regression below to run; this only |
| 847 | # survives a transient outage. |
| 848 | # |
| 849 | # A feed failure also makes `choco install` exit 0 without installing |
| 850 | # anything ("Chocolatey installed 0/0 packages" after a V2 feed 504), |
| 851 | # so an attempt only counts when `makensis.exe` exists where the installer |
| 852 | # regression looks for it (Program Files; choco does not put it on PATH). |
| 853 | # Without that check the retry reported success on an unprovisioned |
| 854 | # runner and the regression below failed as an unattributable exit |
| 855 | # code instead of this step naming the outage (2026-09-24). |
| 856 | run: | |
| 857 | $ErrorActionPreference = 'Continue' |
| 858 | $delays = @(0, 20, 45) |
| 859 | for ($attempt = 0; $attempt -lt $delays.Count; $attempt++) { |
| 860 | if ($delays[$attempt] -gt 0) { |
| 861 | Write-Host "NSIS install attempt $($attempt + 1) after $($delays[$attempt])s backoff" |
| 862 | Start-Sleep -Seconds $delays[$attempt] |
| 863 | } |
| 864 | choco install nsis -y --no-progress |
| 865 | $makensis = @("${env:ProgramFiles(x86)}\NSIS\makensis.exe", "$env:ProgramFiles\NSIS\makensis.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1 |
| 866 | if ($LASTEXITCODE -eq 0 -and $makensis) { |
| 867 | Write-Host "NSIS installed on attempt $($attempt + 1)" |
| 868 | exit 0 |
| 869 | } |
| 870 | Write-Host "::warning::NSIS is still unavailable after attempt $($attempt + 1) (choco exit $LASTEXITCODE)" |
| 871 | } |
| 872 | Write-Host "::error::NSIS could not be provisioned from Chocolatey after $($delays.Count) attempts" |
| 873 | exit 1 |
| 874 | - name: Test Windows installer PATH regression |
| 875 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest' |
| 876 | shell: pwsh |
| 877 | run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation |
| 878 | - uses: dtolnay/rust-toolchain@stable |
| 879 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 880 | - uses: mozilla-actions/sccache-action@v0.0.11 |
| 881 | id: sccache |
| 882 | continue-on-error: true |
| 883 | # Main-only GitHub Actions cache backend; see the Safety gate job. |
| 884 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main' |
| 885 | - name: Enable sccache |
| 886 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' |
| 887 | shell: bash |
| 888 | run: | |
| 889 | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" |
| 890 | echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" |
| 891 | echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" |
| 892 | - name: Install Linux system dependencies |
| 893 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 894 | run: | |
| 895 | for i in 1 2 3 4 5; do |
| 896 | sudo apt-get update && break |
| 897 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 898 | sleep 15 |
| 899 | done |
| 900 | sudo apt-get install -y libdbus-1-dev pkg-config bubblewrap apparmor-profiles |
| 901 | sh scripts/prepare-linux-test-sandbox.sh |
| 902 | - name: Ubuntu build headroom |
| 903 | # Reuse the same fixed hosted-runner provisioning as Safety and Lint. |
| 904 | # Exit 143 is a shutdown observation, not proof of OOM or timeout. |
| 905 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 906 | shell: bash |
| 907 | run: bash scripts/prepare-ubuntu-build-headroom.sh |
| 908 | - uses: Swatinem/rust-cache@v2 |
| 909 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 910 | with: |
| 911 | cache-bin: false |
| 912 | save-if: ${{ github.ref == 'refs/heads/main' }} |
| 913 | - uses: taiki-e/install-action@nextest |
| 914 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 915 | - uses: actions/setup-node@v7 |
| 916 | # The extension-host integration tests spawn the real bundle under |
| 917 | # Node >= 22.19; CODEWHALE_EXT_HOST_TESTS below makes a missing Node a |
| 918 | # failure instead of a silent skip. |
| 919 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 920 | with: |
| 921 | node-version: 22 |
| 922 | - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2 |
| 923 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 924 | with: |
| 925 | bun-version: 1.4.0 |
| 926 | - name: Build matching compiled-host input for required Native containment receipts |
| 927 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 928 | shell: bash |
| 929 | run: | |
| 930 | set -euo pipefail |
| 931 | bun_binary="$(bun -p 'process.execPath')" |
| 932 | host_binary="$(node -p "require('path').join(process.env.RUNNER_TEMP, 'codewhale-extension-host' + (process.platform === 'win32' ? '.exe' : ''))")" |
| 933 | compiled_info="$(node crates/tui/extension-host/compile-host.mjs --bun "$bun_binary" --output "$host_binary")" |
| 934 | printf 'CODEWHALE_COMPILED_HOST_TEST_BINARY=%s\n' "$host_binary" >> "$GITHUB_ENV" |
| 935 | node - "$host_binary" "$bun_binary" "$compiled_info" >> "$GITHUB_ENV" <<'JS' |
| 936 | const fs = require('node:fs'), crypto = require('node:crypto'); |
| 937 | const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); |
| 938 | const info = JSON.parse(process.argv[4]); |
| 939 | if (!info.platform || !info.arch || info.platform !== info.compiler?.platform || info.arch !== info.compiler?.arch) throw new Error('image/compiler identity mismatch'); |
| 940 | console.log('CODEWHALE_COMPILED_HOST_TEST_PLATFORM=' + info.platform); |
| 941 | console.log('CODEWHALE_COMPILED_HOST_TEST_ARCH=' + info.arch); |
| 942 | console.log('CODEWHALE_COMPILED_HOST_TEST_SHA256=' + digest(process.argv[2])); |
| 943 | console.log('CODEWHALE_COMPILED_HOST_TEST_BUN=' + process.argv[3]); |
| 944 | console.log('CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256=' + digest(process.argv[3])); |
| 945 | console.log('CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256=' + digest('crates/tui/extension-host/dist/codewhale-extension-host.mjs')); |
| 946 | JS |
| 947 | # The required hermetic full invocation executes the shared actual |
| 948 | # compiled Native secret/history/write scenario on each OS; Windows |
| 949 | # additionally requires Node/Bun/compiled LPAC owner/reparse probes. |
| 950 | # Missing requested compiled input or a failed sandbox is red. |
| 951 | - name: Hold this machine's build lock (self-hosted) |
| 952 | # The self-hosted Mac is also a developer machine: local agents build |
| 953 | # through scripts/dev-cargo.sh, which holds this lock, and two Cargo |
| 954 | # builds at once exhaust its memory. Opt-in: the runner's `.env` names |
| 955 | # the same file as CODEWHALE_BUILD_LOCK_FILE. The holder is a |
| 956 | # background process; the runner kills orphans when the job ends, so a |
| 957 | # cancelled or crashed job cannot leave the lock held. |
| 958 | # (`env.*` in `if:` cannot see the runner's `.env`, so the opt-in is |
| 959 | # checked in the script.) |
| 960 | if: needs.changes.outputs.heavy == 'true' && runner.environment == 'self-hosted' |
| 961 | shell: bash |
| 962 | run: | |
| 963 | if [ -z "${CODEWHALE_BUILD_LOCK_FILE:-}" ]; then |
| 964 | echo "CODEWHALE_BUILD_LOCK_FILE is not set on this runner; building without the machine lock." |
| 965 | exit 0 |
| 966 | fi |
| 967 | hold="$RUNNER_TEMP/cw-build-lock.hold" |
| 968 | ready="$RUNNER_TEMP/cw-build-lock.ready" |
| 969 | log="$RUNNER_TEMP/cw-build-lock.log" |
| 970 | touch "$hold" |
| 971 | rm -f "$ready" |
| 972 | # The single quotes are deliberate: the inner sh expands $1/$2. |
| 973 | # shellcheck disable=SC2016 |
| 974 | nohup python3 scripts/build-lock.py "$CODEWHALE_BUILD_LOCK_FILE" -- \ |
| 975 | sh -c 'touch "$1"; while [ -e "$2" ]; do sleep 2; done' _ "$ready" "$hold" \ |
| 976 | >"$log" 2>&1 & |
| 977 | shown=0 |
| 978 | until [ -e "$ready" ]; do |
| 979 | if [ "$shown" -eq 0 ] && [ -s "$log" ]; then cat "$log"; shown=1; fi |
| 980 | sleep 2 |
| 981 | done |
| 982 | cat "$log" |
| 983 | # Scripts inside this job already run under the lock. |
| 984 | echo "CODEWHALE_BUILD_LOCK_HELD=1" >> "$GITHUB_ENV" |
| 985 | - name: Build canonical executable for acceptance tests |
| 986 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 987 | shell: bash |
| 988 | run: cargo build -p codewhale-cli --bin codewhale --all-features --locked |
| 989 | env: |
| 990 | RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }} |
| 991 | - name: Run tests |
| 992 | # Same test binaries as `cargo test`, run by cargo-nextest: one |
| 993 | # process per test, all runner cores busy, slow tests named instead |
| 994 | # of stalling the binary. `.config/nextest.toml` serializes the PTY |
| 995 | # binary and bounds the integration binary that spawns the real |
| 996 | # executable; retries are off, so a flake is a red run, not a hidden |
| 997 | # one. nextest does not run doctests — the next step keeps them. |
| 998 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') |
| 999 | shell: bash |
| 1000 | run: | |
| 1001 | monitor= |
| 1002 | if [ "$RUNNER_OS" = Linux ]; then |
| 1003 | # Headroom trace: if the runner is terminated again, the last lines |
| 1004 | # say whether memory or disk ran out. |
| 1005 | ( while sleep 30; do |
| 1006 | echo "[headroom] $(free -m | awk '/^Mem:/{print "mem used " $3 "/" $2 " MiB"} /^Swap:/{print "swap used " $3 "/" $2 " MiB"}' | paste -sd ' ') disk free $(df -h / | awk 'NR==2{print $4}')" |
| 1007 | done ) & |
| 1008 | monitor=$! |
| 1009 | fi |
| 1010 | status=0 |
| 1011 | sh scripts/with-hermetic-test-home.sh cargo nextest run --workspace --all-features --locked --profile ci || status=$? |
| 1012 | if [ -n "$monitor" ]; then kill "$monitor" 2>/dev/null || true; fi |
| 1013 | exit "$status" |
| 1014 | env: |
| 1015 | CODEWHALE_EXT_HOST_TESTS: '1' |
| 1016 | CODEWHALE_EXT_HOST_BUN_TESTS: '1' |
| 1017 | # sccache 0.17 panics resolving its config directory under the |
| 1018 | # isolated Windows home before Cargo can compile or run any test. |
| 1019 | # Bypass only that optional cache; keep the full suite and isolation. |
| 1020 | RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }} |
| 1021 | # Give test threads the stack the product gives itself. main.rs runs |
| 1022 | # the owner thread and every tokio worker at |
| 1023 | # CODEWHALE_MAIN_STACK_BYTES (32 MiB) because the engine and |
| 1024 | # runtime-thread futures are genuinely deep. `#[tokio::test]` builds |
| 1025 | # its own runtime and never sees that, so tests ran the same code on |
| 1026 | # ~2 MiB (~1 MiB on Windows) — a configuration that never ships. |
| 1027 | # That gap is what aborted the whole Windows test binary with |
| 1028 | # STATUS_STACK_OVERFLOW in start_turn_accepts_dynamic_tools_and_ |
| 1029 | # environment_id, masking every other Windows result (78afd8d3d4 |
| 1030 | # Box::pin'd that one frame; the mismatch itself remained). std reads |
| 1031 | # this for any thread spawned without an explicit size, which covers |
| 1032 | # both libtest's per-test threads and tokio's workers. Test threads |
| 1033 | # hold 16 MiB: the engine-only chains they run measured a ~2.5 MiB |
| 1034 | # debug high-water, while the full ~16.5 MiB UI-loop chain that |
| 1035 | # forced 32 MiB lives in spawned binaries, which size their own |
| 1036 | # stacks explicitly and never read this variable. |
| 1037 | RUST_MIN_STACK: '16777216' |
| 1038 | - name: Shared-process workspace qualification |
| 1039 | if: github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice' && matrix.os == 'ubuntu-latest' |
| 1040 | shell: bash |
| 1041 | env: |
| 1042 | EXPECTED_SHA: ${{ inputs.expected_sha }} |
| 1043 | CODEWHALE_EXT_HOST_TESTS: '1' |
| 1044 | CODEWHALE_EXT_HOST_BUN_TESTS: '1' |
| 1045 | RUST_MIN_STACK: '16777216' |
| 1046 | RUSTFLAGS: '' |
| 1047 | CARGO_ENCODED_RUSTFLAGS: '' |
| 1048 | RUSTC_WRAPPER: '' |
| 1049 | CARGO_TERM_COLOR: never |
| 1050 | run: | |
| 1051 | set -euo pipefail |
| 1052 | umask 077 |
| 1053 | evidence="$RUNNER_TEMP/shared-process-workspace" |
| 1054 | mkdir -p "$evidence" |
| 1055 | unset RUST_TEST_THREADS |
| 1056 | if [[ ! "$EXPECTED_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then |
| 1057 | echo "::error::expected_sha must be a full 40-character commit SHA." | tee "$evidence/preflight-error.txt" |
| 1058 | exit 1 |
| 1059 | fi |
| 1060 | expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" |
| 1061 | if [[ "$(git rev-parse HEAD)" != "$expected" || "$GITHUB_SHA" != "$expected" ]]; then |
| 1062 | echo "::error::Qualification checkout does not match the exact dispatch SHA." | tee "$evidence/preflight-error.txt" |
| 1063 | exit 1 |
| 1064 | fi |
| 1065 | { |
| 1066 | printf 'commit=%s\ntree=%s\n' "$expected" "$(git rev-parse 'HEAD^{tree}')" |
| 1067 | rustc -Vv |
| 1068 | cargo -V |
| 1069 | uname -a |
| 1070 | printf 'available_cpus=%s\n' "$(getconf _NPROCESSORS_ONLN)" |
| 1071 | printf 'runner_os=%s\nrunner_arch=%s\n' "$RUNNER_OS" "$RUNNER_ARCH" |
| 1072 | printf 'image_os=%s\nimage_version=%s\n' "${ImageOS:-unknown}" "${ImageVersion:-unknown}" |
| 1073 | printf 'RUST_MIN_STACK=%s\nCARGO_INCREMENTAL=%s\nCARGO_BUILD_JOBS=%s\n' \ |
| 1074 | "$RUST_MIN_STACK" "${CARGO_INCREMENTAL:-default}" "${CARGO_BUILD_JOBS:-default}" |
| 1075 | printf '%s\n' 'RUST_TEST_THREADS=unset (default libtest concurrency)' \ |
| 1076 | 'RUSTFLAGS=empty; CARGO_ENCODED_RUSTFLAGS=empty; RUSTC_WRAPPER=empty' \ |
| 1077 | 'command=sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked -- --format=pretty' |
| 1078 | } > "$evidence/environment.txt" |
| 1079 | cat > "$evidence/required-tests.txt" <<'EOF' |
| 1080 | remote_control::tests::classic_recovery_uses_persisted_seq_floor_and_ignores_older_terminal |
| 1081 | remote_control::tests::actual_start_reclaims_runtime_chat_writer_before_worker_spawn |
| 1082 | remote_control::tests::separate_predispatch_crashes_on_one_run_get_distinct_recovery_turn_ids |
| 1083 | runtime_api::tests::events_endpoint_respects_since_seq_cursor |
| 1084 | runtime_threads::tests::approval_required_awaits_external_decision_allow |
| 1085 | runtime_threads::tests::approval_remember_grants_tool_class_without_changing_posture |
| 1086 | tools::subagent::budget_handback_tests::budget_handback_inflight_wall_timeout_persists_unreported_usage |
| 1087 | tools::subagent::tests::child_permission_gate::wall_deadline_ends_pending_wait_with_receipt |
| 1088 | tools::subagent::tests::resume_keeps_recorded_reasoning_in_manifest_and_request_after_parent_changes |
| 1089 | EOF |
| 1090 | failed=0 |
| 1091 | # Two fixed attempts, not retry-until-green. Preserve both native |
| 1092 | # outcomes even when the first run fails; missing identities fail too. |
| 1093 | for run in 1 2; do |
| 1094 | if [[ "$(git rev-parse HEAD)" != "$expected" ]] || \ |
| 1095 | ! git diff --quiet || ! git diff --cached --quiet; then |
| 1096 | printf 'run=%s not_run=source_changed\n' "$run" >> "$evidence/results.txt" |
| 1097 | failed=1 |
| 1098 | continue |
| 1099 | fi |
| 1100 | qualification_tmp=$(mktemp -d /tmp/cw69.XXXXXX) |
| 1101 | printf 'run=%s start=%s tmpdir=%s\n' "$run" "$(date -u +%FT%TZ)" "$qualification_tmp" >> "$evidence/results.txt" |
| 1102 | set +e |
| 1103 | TMPDIR="$qualification_tmp" sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked -- --format=pretty \ |
| 1104 | 2>&1 | tee "$evidence/run-$run.log" |
| 1105 | pipeline_status=("${PIPESTATUS[@]}") |
| 1106 | set -e |
| 1107 | printf 'run=%s cargo_exit=%s log_exit=%s end=%s\n' \ |
| 1108 | "$run" "${pipeline_status[0]}" "${pipeline_status[1]}" "$(date -u +%FT%TZ)" >> "$evidence/results.txt" |
| 1109 | if [[ "${pipeline_status[0]}" != 0 || "${pipeline_status[1]}" != 0 ]]; then |
| 1110 | failed=1 |
| 1111 | fi |
| 1112 | while IFS= read -r identity; do |
| 1113 | if grep -Fxq "test $identity ... ok" "$evidence/run-$run.log"; then |
| 1114 | printf 'run=%s test=%s result=ok\n' "$run" "$identity" >> "$evidence/results.txt" |
| 1115 | else |
| 1116 | printf 'run=%s test=%s result=missing_pass\n' "$run" "$identity" >> "$evidence/results.txt" |
| 1117 | failed=1 |
| 1118 | fi |
| 1119 | done < "$evidence/required-tests.txt" |
| 1120 | if [[ "$(git rev-parse HEAD)" != "$expected" ]] || \ |
| 1121 | ! git diff --quiet || ! git diff --cached --quiet; then |
| 1122 | printf 'run=%s source_changed=true\n' "$run" >> "$evidence/results.txt" |
| 1123 | failed=1 |
| 1124 | fi |
| 1125 | # Plugin fixtures can leave read-only directories outside HOME. |
| 1126 | # Change only owned directories; never follow links or chmod files. |
| 1127 | permission_exit=0 |
| 1128 | find -P "$qualification_tmp" -type d -exec chmod u+w {} + || permission_exit=$? |
| 1129 | cleanup_exit=0 |
| 1130 | rm -rf -- "$qualification_tmp" || cleanup_exit=$? |
| 1131 | printf 'run=%s permission_exit=%s cleanup_exit=%s\n' \ |
| 1132 | "$run" "$permission_exit" "$cleanup_exit" >> "$evidence/results.txt" |
| 1133 | if [[ "$permission_exit" != 0 || "$cleanup_exit" != 0 ]]; then |
| 1134 | failed=1 |
| 1135 | fi |
| 1136 | done |
| 1137 | cat "$evidence/results.txt" |
| 1138 | exit "$failed" |
| 1139 | - name: Retain shared-process qualification evidence |
| 1140 | if: always() && github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice' && matrix.os == 'ubuntu-latest' |
| 1141 | uses: actions/upload-artifact@v7 |
| 1142 | with: |
| 1143 | name: shared-process-workspace-${{ github.sha }}-${{ github.run_attempt }} |
| 1144 | path: ${{ runner.temp }}/shared-process-workspace/ |
| 1145 | if-no-files-found: error |
| 1146 | retention-days: 30 |
| 1147 | - name: Export exact compiled-image Native containment receipt |
| 1148 | # JUnit is from the same full invocation: no second compile/test run. |
| 1149 | # The official entire CI run must be green before release can consume |
| 1150 | # this artifact. Missing/changed inputs or silent test skips are red. |
| 1151 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') |
| 1152 | shell: bash |
| 1153 | run: | |
| 1154 | set -euo pipefail |
| 1155 | test "$(git rev-parse HEAD)" = "$GITHUB_SHA" |
| 1156 | git diff --exit-code |
| 1157 | git diff --cached --exit-code |
| 1158 | python3 scripts/compiled-host-native-receipt.py \ |
| 1159 | --junit target/nextest/ci/junit.xml \ |
| 1160 | --compiled-image "$CODEWHALE_COMPILED_HOST_TEST_BINARY" \ |
| 1161 | --bundle crates/tui/extension-host/dist/codewhale-extension-host.mjs \ |
| 1162 | --bun "$CODEWHALE_COMPILED_HOST_TEST_BUN" --source-sha "$GITHUB_SHA" \ |
| 1163 | --image-platform "$CODEWHALE_COMPILED_HOST_TEST_PLATFORM" --image-arch "$CODEWHALE_COMPILED_HOST_TEST_ARCH" \ |
| 1164 | --host-sha256 "$CODEWHALE_COMPILED_HOST_TEST_SHA256" \ |
| 1165 | --runtime-sha256 "$CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256" \ |
| 1166 | --bundle-sha256 "$CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256" \ |
| 1167 | --output "$RUNNER_TEMP/native-compiled-host" |
| 1168 | - name: Retain exact compiled image and Native receipt |
| 1169 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') |
| 1170 | uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 |
| 1171 | with: |
| 1172 | name: native-compiled-host-${{ runner.os }}-${{ runner.arch }} |
| 1173 | path: ${{ runner.temp }}/native-compiled-host/ |
| 1174 | if-no-files-found: error |
| 1175 | retention-days: 30 |
| 1176 | - name: Run doctests |
| 1177 | # The shared-process command already includes every workspace doctest. |
| 1178 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') |
| 1179 | shell: bash |
| 1180 | run: sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked --doc |
| 1181 | env: |
| 1182 | RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }} |
| 1183 | RUST_MIN_STACK: '16777216' |
| 1184 | # The Ubuntu lint lane validates non-RSS backlog fields. Run the same |
| 1185 | # source-bound measurement on macOS so loss or growth of RSS evidence |
| 1186 | # fails closed instead of becoming an unsupported-field skip. |
| 1187 | # Only on the self-hosted Mac: there it reuses the warm build. Every |
| 1188 | # hosted Mac leg (fork PRs, or any PR with CW_SELF_HOSTED_MAC off) runs |
| 1189 | # it in the separate `macos-budget` job with its own timeout, because |
| 1190 | # on a cold hosted Mac it pushed Test past 90 minutes. |
| 1191 | - name: Check persistence-backlog RSS budget |
| 1192 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true' |
| 1193 | run: python3 scripts/check-persistence-backlog-budget.py |
| 1194 | - name: Lockfile drift guard |
| 1195 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1196 | run: git diff --exit-code -- Cargo.lock |
| 1197 | - name: Run Offline Eval Harness |
| 1198 | # The eval harness is OS-independent prompt/composition checking; |
| 1199 | # running it once (on the faster macOS leg, warm from the test build) |
| 1200 | # instead of once per desktop OS keeps the coverage while taking |
| 1201 | # ~2min off the Windows critical path. Self-hosted Mac only; hosted |
| 1202 | # Mac legs run it in `macos-budget` (see the RSS step above). |
| 1203 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true' |
| 1204 | run: cargo run -p codewhale-cli --bin codewhale --all-features -- eval |
| 1205 | - name: sccache stats |
| 1206 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' |
| 1207 | continue-on-error: true |
| 1208 | shell: bash |
| 1209 | run: sccache --show-stats |
| 1210 | - name: Linux test location (CNB) |
| 1211 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request' |
| 1212 | run: echo "Linux workspace tests run on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu." |
| 1213 | - name: Release this machine's build lock (self-hosted) |
| 1214 | if: always() && runner.environment == 'self-hosted' |
| 1215 | shell: bash |
| 1216 | run: rm -f "$RUNNER_TEMP/cw-build-lock.hold" |
| 1217 | |
| 1218 | macos-budget: |
| 1219 | # Fork PRs build cold on a GitHub-hosted Mac, and the RSS budget and the |
| 1220 | # offline eval each rebuild codewhale-tui there. Inside Test that cost |
| 1221 | # cancelled fork PRs at the 90-minute limit (jobs 106749104684 and |
| 1222 | # 106235312282) before Test could report. Running both here, in parallel |
| 1223 | # with Test and under their own timeout, keeps the coverage without |
| 1224 | # holding the required Test (macos-latest) context hostage. When Test's |
| 1225 | # macOS leg runs on the self-hosted Mac (trusted event and |
| 1226 | # CW_SELF_HOSTED_MAC == 'true'), it runs these two steps on the warm |
| 1227 | # build instead. The name keeps "(fork PR)" so check contexts stay stable. |
| 1228 | # Hosted macOS allows only five concurrent jobs per account, and this job |
| 1229 | # doubled every pull request's claim on them, queueing the required Test |
| 1230 | # (macos-latest) leg for hours. So pull requests skip it and every push to |
| 1231 | # main still runs it; a regression shows up on the merge that caused it. |
| 1232 | name: macOS budget and eval (fork PR) |
| 1233 | needs: changes |
| 1234 | # The explicit Ubuntu-only qualification has no warm Test macOS leg, so |
| 1235 | # retain these gates on the hosted fallback even when self-hosting is on. |
| 1236 | if: needs.changes.outputs.heavy == 'true' && github.event_name != 'pull_request' && ((github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') || !(needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true')) |
| 1237 | timeout-minutes: 75 |
| 1238 | runs-on: macos-latest |
| 1239 | steps: |
| 1240 | - uses: actions/checkout@v7 |
| 1241 | - uses: dtolnay/rust-toolchain@stable |
| 1242 | - uses: Swatinem/rust-cache@v2 |
| 1243 | with: |
| 1244 | cache-bin: false |
| 1245 | save-if: false |
| 1246 | - name: Fetch dependencies before offline measurement |
| 1247 | run: cargo fetch --locked |
| 1248 | - name: Check persistence-backlog RSS budget |
| 1249 | run: python3 scripts/check-persistence-backlog-budget.py |
| 1250 | - name: Run Offline Eval Harness |
| 1251 | run: cargo run -p codewhale-cli --bin codewhale --all-features -- eval |
| 1252 | |
| 1253 | npm-wrapper-smoke: |
| 1254 | name: npm wrapper smoke |
| 1255 | needs: changes |
| 1256 | if: github.event_name != 'schedule' |
| 1257 | # Same ternary rationale as the Test job: light legs only echo, so keep |
| 1258 | # them off macOS/Windows runners. On pull_request the matrix is |
| 1259 | # ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)" |
| 1260 | # context is unaffected. Heavy pull requests execute the Ubuntu smoke |
| 1261 | # here; their branches may not be mirrored to CNB. |
| 1262 | # Pushes to main run Ubuntu (a CNB pointer) and Windows only. The macOS |
| 1263 | # leg was a ~28-minute hosted-Mac build on every main push while hosted |
| 1264 | # macOS is capped at 5 concurrent jobs; it stays in the manual |
| 1265 | # workflow_dispatch (full CI) matrix and in the release pipeline. |
| 1266 | # A cold Windows build can take 29 minutes before the smoke even starts. |
| 1267 | # Leave room for installation; bound the smoke itself independently below. |
| 1268 | timeout-minutes: 45 |
| 1269 | runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }} |
| 1270 | strategy: |
| 1271 | matrix: |
| 1272 | os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || github.event_name == 'workflow_dispatch' && '["ubuntu-latest","macos-latest","windows-latest"]' || '["ubuntu-latest","windows-latest"]') }} |
| 1273 | steps: |
| 1274 | - name: Skip npm wrapper smoke for light change |
| 1275 | if: needs.changes.outputs.heavy != 'true' |
| 1276 | run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context." |
| 1277 | - uses: actions/checkout@v7 |
| 1278 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1279 | - uses: dtolnay/rust-toolchain@stable |
| 1280 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1281 | - uses: mozilla-actions/sccache-action@v0.0.11 |
| 1282 | id: sccache |
| 1283 | continue-on-error: true |
| 1284 | # Main-only GitHub Actions cache backend; see the Safety gate job. |
| 1285 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main' |
| 1286 | - name: Enable sccache |
| 1287 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' |
| 1288 | shell: bash |
| 1289 | run: | |
| 1290 | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" |
| 1291 | echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" |
| 1292 | echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" |
| 1293 | - uses: actions/setup-node@v7 |
| 1294 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1295 | with: |
| 1296 | node-version: 22 |
| 1297 | - name: Install Linux system dependencies |
| 1298 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1299 | run: | |
| 1300 | for i in 1 2 3 4 5; do |
| 1301 | sudo apt-get update && break |
| 1302 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 1303 | sleep 15 |
| 1304 | done |
| 1305 | sudo apt-get install -y libdbus-1-dev pkg-config |
| 1306 | - uses: Swatinem/rust-cache@v2 |
| 1307 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1308 | with: |
| 1309 | cache-bin: false |
| 1310 | save-if: ${{ github.ref == 'refs/heads/main' }} |
| 1311 | - name: Build wrapper binaries |
| 1312 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1313 | # The smoke validates wrapper install/delegation plumbing, not |
| 1314 | # codegen quality, so skip fat LTO + codegen-units=1 for a much |
| 1315 | # cheaper release build. Shipped binaries keep the real profile via |
| 1316 | # the Release workflow. |
| 1317 | env: |
| 1318 | CARGO_PROFILE_RELEASE_LTO: 'off' |
| 1319 | CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' |
| 1320 | run: cargo build --release --locked -p codewhale-cli --bin codewhale |
| 1321 | - name: Smoke wrapper install and delegated entrypoints |
| 1322 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') |
| 1323 | timeout-minutes: 5 |
| 1324 | run: node scripts/release/npm-wrapper-smoke.js |
| 1325 | - name: sccache stats |
| 1326 | if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' |
| 1327 | continue-on-error: true |
| 1328 | shell: bash |
| 1329 | run: sccache --show-stats |
| 1330 | - name: Linux smoke location |
| 1331 | if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request' |
| 1332 | run: echo "Linux npm wrapper smoke runs on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu." |
| 1333 | |
| 1334 | mobile-smoke: |
| 1335 | name: Mobile runtime smoke |
| 1336 | needs: changes |
| 1337 | # Not a required PR context. Pull requests run it only when the mobile |
| 1338 | # runtime surface changed (see the `mobile` filter above); every push to |
| 1339 | # main runs it unconditionally as the pre-release safety net. |
| 1340 | if: >- |
| 1341 | github.event_name != 'schedule' && |
| 1342 | needs.changes.outputs.heavy == 'true' && |
| 1343 | (github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true') |
| 1344 | timeout-minutes: 30 |
| 1345 | runs-on: ubuntu-latest |
| 1346 | steps: |
| 1347 | - uses: actions/checkout@v7 |
| 1348 | - uses: dtolnay/rust-toolchain@stable |
| 1349 | - uses: mozilla-actions/sccache-action@v0.0.11 |
| 1350 | id: sccache |
| 1351 | # Main-only GitHub Actions cache backend; see the Safety gate job. |
| 1352 | if: github.ref == 'refs/heads/main' |
| 1353 | continue-on-error: true |
| 1354 | - name: Enable sccache |
| 1355 | if: steps.sccache.outcome == 'success' |
| 1356 | shell: bash |
| 1357 | run: | |
| 1358 | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" |
| 1359 | echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" |
| 1360 | echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" |
| 1361 | - name: Install Linux system dependencies |
| 1362 | run: | |
| 1363 | for i in 1 2 3 4 5; do |
| 1364 | sudo apt-get update && break |
| 1365 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 1366 | sleep 15 |
| 1367 | done |
| 1368 | sudo apt-get install -y libdbus-1-dev pkg-config |
| 1369 | - uses: Swatinem/rust-cache@v2 |
| 1370 | with: |
| 1371 | cache-bin: false |
| 1372 | save-if: ${{ github.ref == 'refs/heads/main' }} |
| 1373 | - name: Run mobile smoke tests |
| 1374 | # The smoke exercises HTTP/SSE runtime behaviour, not codegen |
| 1375 | # quality; skipping fat LTO + codegen-units=1 cuts the in-script |
| 1376 | # release build from ~12min to a fraction of that. |
| 1377 | env: |
| 1378 | CARGO_PROFILE_RELEASE_LTO: 'off' |
| 1379 | CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' |
| 1380 | run: ./scripts/mobile-smoke.sh |
| 1381 | - name: sccache stats |
| 1382 | if: steps.sccache.outcome == 'success' |
| 1383 | continue-on-error: true |
| 1384 | shell: bash |
| 1385 | run: sccache --show-stats |
| 1386 | |
| 1387 | actionlint: |
| 1388 | name: Workflow lint |
| 1389 | needs: changes |
| 1390 | if: needs.changes.outputs.actions == 'true' |
| 1391 | timeout-minutes: 15 |
| 1392 | runs-on: ubuntu-latest |
| 1393 | steps: |
| 1394 | - uses: actions/checkout@v7 |
| 1395 | - name: Run actionlint |
| 1396 | uses: docker://rhysd/actionlint:1.7.12 |
| 1397 | with: |
| 1398 | # SC2129 (grouped redirects) is style-only and endemic to the |
| 1399 | # existing GITHUB_ENV/GITHUB_OUTPUT append pattern; SC2221/SC2222 |
| 1400 | # flag the long-standing `*.md` glob shadowing the PR-template |
| 1401 | # entry in change detection, which is intentional. |
| 1402 | args: -color -ignore SC2129 -ignore SC2221 -ignore SC2222 |
| 1403 | |
| 1404 | # Check documentation builds without warnings |
| 1405 | docs: |
| 1406 | name: Documentation |
| 1407 | if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' |
| 1408 | timeout-minutes: 60 |
| 1409 | runs-on: ubuntu-latest |
| 1410 | steps: |
| 1411 | - uses: actions/checkout@v7 |
| 1412 | - uses: dtolnay/rust-toolchain@stable |
| 1413 | - name: Install Linux system dependencies |
| 1414 | if: runner.os == 'Linux' |
| 1415 | run: | |
| 1416 | for i in 1 2 3 4 5; do |
| 1417 | sudo apt-get update && break |
| 1418 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 1419 | sleep 15 |
| 1420 | done |
| 1421 | sudo apt-get install -y libdbus-1-dev pkg-config |
| 1422 | - uses: Swatinem/rust-cache@v2 |
| 1423 | with: |
| 1424 | cache-bin: false |
| 1425 | - name: Build docs |
| 1426 | run: cargo doc --workspace --no-deps |
| 1427 | env: |
| 1428 | RUSTDOCFLAGS: -Dwarnings |
| 1429 |