返回 CodeWhale
ci.yml
根目录 / .github / workflows / ci.yml
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 always force full CI)
14 required: true
15 type: string
16
17 permissions:
18 contents: read
19
20 concurrency:
21 # PRs still share one group so a new push cancels the superseded head.
22 # Push/schedule/dispatch on main must be keyed by SHA: with cancel-in-progress
23 # false, GitHub still cancels a *pending* run in the same group when a new
24 # one queues. That is how 31 of the last 40 main CI runs vanished without a
25 # verdict (test bankruptcy, 2026-08-19). Each SHA gets its own group so
26 # every commit on main actually finishes.
27 group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-{0}-{1}', github.workflow, github.sha) }}
28 cancel-in-progress: ${{ github.event_name == 'pull_request' }}
29
30 env:
31 CARGO_TERM_COLOR: always
32 CARGO_INCREMENTAL: 0
33 RUSTFLAGS: -Dwarnings
34 # Test threads share a process and tokio/async frames run deep; the default
35 # 2 MiB stack overflowed sporadically in runtime_api::tests::start_turn_*
36 # under load and aborted the whole lib suite (signal 6). 8 MiB is the
37 # measured-safe floor; nextest's per-process runs are unaffected either way.
38 RUST_MIN_STACK: 8388608
39
40 jobs:
41 changes:
42 name: Change detection
43 timeout-minutes: 10
44 runs-on: ubuntu-latest
45 outputs:
46 heavy: ${{ steps.detect.outputs.heavy }}
47 workflow: ${{ steps.detect.outputs.workflow }}
48 mobile: ${{ steps.detect.outputs.mobile }}
49 actions: ${{ steps.detect.outputs.actions }}
50 trusted: ${{ steps.trust.outputs.trusted }}
51 steps:
52 - name: Classify event trust
53 id: trust
54 shell: bash
55 env:
56 EVENT_NAME: ${{ github.event_name }}
57 HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
58 THIS_REPO: ${{ github.repository }}
59 run: |
60 set -euo pipefail
61 # "trusted" means the code came from this repository, not a fork.
62 # Only trusted events may run on the self-hosted macOS runner: this
63 # repo is public with thousands of forks, and a fork PR on a
64 # self-hosted runner is arbitrary code execution on that machine.
65 if [ "${EVENT_NAME}" != "pull_request" ] || [ "${HEAD_REPO}" = "${THIS_REPO}" ]; then
66 echo "trusted=true" >> "$GITHUB_OUTPUT"
67 else
68 echo "trusted=false" >> "$GITHUB_OUTPUT"
69 fi
70 - uses: actions/checkout@v7
71 with:
72 fetch-depth: 0
73 - name: Detect executable changes
74 id: detect
75 shell: bash
76 env:
77 EVENT_NAME: ${{ github.event_name }}
78 BASE_REF: ${{ github.base_ref }}
79 BEFORE_SHA: ${{ github.event.before }}
80 EXPECTED_SHA: ${{ inputs.expected_sha }}
81 run: |
82 set -euo pipefail
83
84 if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
85 if [[ "${#EXPECTED_SHA}" -ne 40 || "${EXPECTED_SHA}" =~ [^0-9a-fA-F] ]]; then
86 echo "::error::expected_sha must be a full 40-character commit SHA." >&2
87 exit 1
88 fi
89 actual="$(git rev-parse HEAD)"
90 expected_normalized="$(printf '%s' "${EXPECTED_SHA}" | tr '[:upper:]' '[:lower:]')"
91 if [[ "${actual}" != "${expected_normalized}" ]]; then
92 echo "::error::Dispatch resolved to ${actual}, not requested ${EXPECTED_SHA}." >&2
93 exit 1
94 fi
95 echo "Manual exact-head dispatch: forcing heavy, workflow, mobile, and action gates."
96 echo "heavy=true" >> "${GITHUB_OUTPUT}"
97 echo "workflow=true" >> "${GITHUB_OUTPUT}"
98 echo "mobile=true" >> "${GITHUB_OUTPUT}"
99 echo "actions=true" >> "${GITHUB_OUTPUT}"
100 exit 0
101 fi
102
103 if [[ "${EVENT_NAME}" == "schedule" ]]; then
104 echo "heavy=true" >> "${GITHUB_OUTPUT}"
105 echo "workflow=true" >> "${GITHUB_OUTPUT}"
106 echo "mobile=true" >> "${GITHUB_OUTPUT}"
107 echo "actions=true" >> "${GITHUB_OUTPUT}"
108 exit 0
109 fi
110
111 base=""
112 if [[ "${EVENT_NAME}" == "pull_request" && -n "${BASE_REF}" ]]; then
113 git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}" --depth=1
114 base="origin/${BASE_REF}"
115 elif [[ -n "${BEFORE_SHA}" && "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then
116 base="${BEFORE_SHA}"
117 fi
118
119 if [[ -z "${base}" ]]; then
120 echo "heavy=true" >> "${GITHUB_OUTPUT}"
121 echo "workflow=true" >> "${GITHUB_OUTPUT}"
122 echo "mobile=true" >> "${GITHUB_OUTPUT}"
123 echo "actions=true" >> "${GITHUB_OUTPUT}"
124 exit 0
125 fi
126
127 mapfile -t changed < <(git diff --name-only "${base}" "${GITHUB_SHA}" | sort)
128 heavy=false
129 workflow=false
130 mobile=false
131 actions=false
132 for path in "${changed[@]}"; do
133 # Heavy classification. ORDER MATTERS: must-stay-heavy inputs are
134 # matched BEFORE any light entry so a script that only a
135 # heavy-gated job exercises can never be misclassified as light.
136 # Anything unrecognized falls through to the default-heavy `*)`
137 # arm (fail-safe default-heavy). Light-classified scripts below
138 # are exercised by ALWAYS-on jobs/steps that run regardless of
139 # `heavy` (check-versions.sh / check-ohos-deps.sh via Version
140 # drift, dev-cache/dev-test
141 # self-checks via Version drift), so no coverage is lost.
142 case "${path}" in
143 scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py)
144 heavy=true
145 ;;
146 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)
147 ;;
148 *)
149 heavy=true
150 ;;
151 esac
152 case "${path}" in
153 crates/workflow/*|.github/workflows/ci.yml)
154 workflow=true
155 ;;
156 esac
157 # Mobile runtime surface: the `codewhale serve --mobile`
158 # HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull
159 # requests run the smoke only when one of these changes; every
160 # push to main still runs it unconditionally as the pre-release
161 # safety net for anything this filter misses.
162 case "${path}" in
163 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)
164 mobile=true
165 ;;
166 esac
167 case "${path}" in
168 .github/workflows/*|.github/actionlint.yml)
169 actions=true
170 ;;
171 esac
172 done
173
174 echo "heavy=${heavy}" >> "${GITHUB_OUTPUT}"
175 echo "workflow=${workflow}" >> "${GITHUB_OUTPUT}"
176 echo "mobile=${mobile}" >> "${GITHUB_OUTPUT}"
177 echo "actions=${actions}" >> "${GITHUB_OUTPUT}"
178
179 echo "Heavy Rust CI required: ${heavy}"
180 echo "Workflow RLM cache CI required: ${workflow}"
181 echo "Mobile runtime smoke required (PRs): ${mobile}"
182 echo "Workflow lint required: ${actions}"
183 printf 'Changed files:\n'
184 printf ' %s\n' "${changed[@]}"
185
186 versions:
187 name: Version drift
188 timeout-minutes: 15
189 runs-on: ubuntu-latest
190 steps:
191 - uses: actions/checkout@v7
192 with:
193 fetch-depth: 0
194 - uses: dtolnay/rust-toolchain@stable
195 - uses: actions/setup-node@v7
196 with:
197 node-version: 20
198 - name: Check version drift
199 # Checks 7 and 12 audit the previous-tag..HEAD commit range, not this
200 # tree, so a receipt another merge forgot reddens every open PR. They
201 # report here and block on every release path (release-candidate.yml,
202 # auto-tag.yml, release.yml, prepare-release.sh), which is where a
203 # missing receipt actually matters.
204 run: ./scripts/release/check-versions.sh --range-audit-advisory
205 - name: Check OHOS dependency graph
206 run: ./scripts/release/check-ohos-deps.sh
207 - name: Check release helper contracts
208 run: |
209 bash .github/scripts/agent-task-metadata.test.sh
210 bash scripts/release/check-feature-release-notes.test.sh
211 bash scripts/release/generate-release-body.test.sh
212 bash scripts/release/install-dogfood.test.sh
213 bash scripts/release/prepare-release.test.sh
214 bash scripts/release/require-release-tag-checkout.test.sh
215 bash scripts/release/validate-crate-publish-order.test.sh
216 python3 scripts/release/publish-crates.test.py
217 bash scripts/release/verify-remote-tag.test.sh
218 bash packaging/aur/render.test.sh
219 sh scripts/dev-cache.test.sh
220 sh scripts/with-hermetic-test-home.test.sh
221 bash .github/scripts/update-homebrew-tap.test.sh
222 node .github/scripts/release-workflows.test.js
223 node --test scripts/release/assemble-release-assets.test.js
224 node --test scripts/release/ensure-release-assets-absent.test.js
225 - name: Run runtime web client tests
226 # crates/tui/tests/runtime_web_client.test.mjs exercises the embedded
227 # web client's event/snapshot state machine; it ran nowhere before.
228 run: node --test crates/tui/tests/runtime_web_client.test.mjs
229
230 integrations:
231 name: Integrations
232 timeout-minutes: 15
233 runs-on: ubuntu-latest
234 steps:
235 - uses: actions/checkout@v7
236 - uses: actions/setup-node@v7
237 with:
238 node-version: 22
239 - name: Run chat-bridge suites
240 # All four bridges + bridge-core ship dependency-free node --test
241 # suites that no workflow ran. weixin has no lockfile by design
242 # (zero deps); npm test works without npm ci everywhere here.
243 run: |
244 set -euo pipefail
245 for bridge in bridge-core feishu-bridge telegram-bridge wecom-bridge weixin-bridge; do
246 echo "== ${bridge}"
247 (cd "integrations/${bridge}" && npm test)
248 done
249
250 - name: Build computer-use test desktop
251 # Spawn assertions have short request deadlines; keep the cold image
252 # build outside those deadlines and fail image preparation explicitly.
253 timeout-minutes: 10
254 run: >-
255 docker build --tag codewhale-cu-linux
256 --file crates/tui/plugins/computer-use/docker/Dockerfile
257 crates/tui/plugins/computer-use
258
259 - name: Run computer-use plugin suites
260 # The bundled plugin is dependency-free too; its suites cover the
261 # manifest contract, the registry, the exec/ssh transport, the four
262 # platform backends, and the MCP stdio protocol. No GUI input runs.
263 run: (cd crates/tui/plugins/computer-use && npm test)
264
265 vscode-extension:
266 name: VS Code extension
267 timeout-minutes: 15
268 runs-on: ubuntu-latest
269 defaults:
270 run:
271 working-directory: extensions/vscode
272 steps:
273 - uses: actions/checkout@v7
274 - uses: actions/setup-node@v7
275 with:
276 # The extension targets VS Code >=1.90, whose extension host is
277 # Node 20, and its @types/node pin is ^20. Build and test on the
278 # runtime the extension actually ships against.
279 node-version: 20
280 - name: Install extension dependencies
281 run: npm ci
282 - name: Run VS Code extension suites
283 # extensions/vscode ships node --test suites (api, markdown, sse) that
284 # NO workflow ran: release.yml only reads package.json for a version
285 # string, so the whole client compiled and shipped without its tests or
286 # `tsc` ever running in CI. `npm test` compiles first (tsc -p ./), so
287 # this is the type-check gate for the extension too.
288 run: npm test
289 - name: Package VS Code extension
290 run: npm run package
291
292 safety-gate:
293 name: Safety gate
294 needs: changes
295 if: needs.changes.outputs.heavy == 'true'
296 timeout-minutes: 15
297 runs-on: ubuntu-latest
298 steps:
299 - uses: actions/checkout@v7
300 - uses: dtolnay/rust-toolchain@master
301 with:
302 toolchain: stable
303 - uses: mozilla-actions/sccache-action@v0.0.11
304 id: sccache
305 continue-on-error: true
306 - name: Enable sccache
307 if: steps.sccache.outcome == 'success'
308 shell: bash
309 run: |
310 echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
311 echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
312 echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
313 - name: Install Linux system dependencies
314 run: |
315 for i in 1 2 3 4 5; do
316 sudo apt-get update && break
317 echo "apt-get update failed (attempt $i); retrying in 15s"
318 sleep 15
319 done
320 sudo apt-get install -y libdbus-1-dev pkg-config
321 - uses: Swatinem/rust-cache@v2
322 with:
323 cache-bin: false
324 save-if: ${{ github.ref == 'refs/heads/main' }}
325 - name: Hermetic safety and authorization tests
326 env:
327 RUST_MIN_STACK: "8388608"
328 run: |
329 sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-tui --lib --locked -- auto_review authority sandbox
330 sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-execpolicy --locked
331
332 lint:
333 name: Lint
334 needs: changes
335 timeout-minutes: 45
336 runs-on: ubuntu-latest
337 steps:
338 - uses: actions/checkout@v7
339 with:
340 fetch-depth: 0
341 - uses: dtolnay/rust-toolchain@master
342 if: needs.changes.outputs.heavy == 'true'
343 with:
344 toolchain: stable
345 components: rustfmt, clippy
346 - uses: mozilla-actions/sccache-action@v0.0.11
347 id: sccache
348 # Cache bootstrap failures (e.g. GitHub 504s fetching the sccache
349 # binary) degrade to an uncached build instead of failing product CI.
350 continue-on-error: true
351 if: needs.changes.outputs.heavy == 'true'
352 - name: Enable sccache
353 if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
354 shell: bash
355 run: |
356 echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
357 echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
358 echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
359 - name: Install Linux system dependencies
360 if: needs.changes.outputs.heavy == 'true'
361 run: |
362 for i in 1 2 3 4 5; do
363 sudo apt-get update && break
364 echo "apt-get update failed (attempt $i); retrying in 15s"
365 sleep 15
366 done
367 sudo apt-get install -y libdbus-1-dev pkg-config
368 - uses: Swatinem/rust-cache@v2
369 if: needs.changes.outputs.heavy == 'true'
370 with:
371 cache-bin: false
372 # PRs restore the cache seeded by main but skip the expensive
373 # post-job save; sccache covers PR-specific compilation deltas.
374 save-if: ${{ github.ref == 'refs/heads/main' }}
375 - name: Check formatting
376 if: needs.changes.outputs.heavy == 'true'
377 run: cargo fmt --all -- --check
378 - name: Run clippy
379 # --all-targets, because without it CI never lints test code at all.
380 # That gap is not theoretical: the v0.9.10 release gate opened with
381 # four clippy failures sitting on a green main, and every one of them
382 # was in a test target. crates/tui/AGENTS.md already documents the
383 # all-targets command as the release gate; this makes CI run the gate
384 # it points contributors at instead of a weaker subset.
385 #
386 # collapsible_if and assertions_on_constants are no longer allowed for
387 # the same reason — they were three of those four, so the allowances
388 # were hiding exactly the class of problem that reached the gate. The
389 # three that remain are deliberate project style, not oversights.
390 if: needs.changes.outputs.heavy == 'true'
391 run: |
392 cargo clippy --workspace --all-targets --all-features --locked -- \
393 -D warnings \
394 -A clippy::uninlined_format_args \
395 -A clippy::too_many_arguments \
396 -A clippy::unnecessary_map_or
397 - name: sccache stats
398 if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
399 continue-on-error: true
400 shell: bash
401 run: sccache --show-stats
402 - name: Check provider registry drift
403 if: needs.changes.outputs.heavy == 'true'
404 run: python3 scripts/check-provider-registry.py
405 - name: Check command-contract prototype boundary
406 if: needs.changes.outputs.heavy == 'true'
407 run: |
408 python3 scripts/test_check_command_crate_boundaries.py
409 python3 scripts/check-command-crate-boundaries.py
410 - name: Check command migration manifest
411 if: needs.changes.outputs.heavy == 'true'
412 env:
413 PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
414 PUSH_BEFORE_SHA: ${{ github.event.before }}
415 run: |
416 python3 scripts/test_check_command_migration_manifest.py
417 baseline="${PR_BASE_SHA:-${PUSH_BEFORE_SHA:-}}"
418 if [[ -n "${baseline}" && ! "${baseline}" =~ ^0+$ ]]; then
419 git fetch --no-tags origin "${baseline}"
420 python3 scripts/check-command-migration-manifest.py --baseline-ref "${baseline}"
421 else
422 python3 scripts/check-command-migration-manifest.py
423 fi
424 - name: Check reqwest client constructors
425 if: needs.changes.outputs.heavy == 'true'
426 run: |
427 python3 scripts/test_check_reqwest_builders.py
428 python3 scripts/check-reqwest-builders.py
429 # Clippy above runs without `--all-targets`, so it cannot see dead code
430 # that only tests keep alive. This ratchet covers that blind spot by
431 # refusing to let the `#[allow(dead_code)]` total rise (#4785).
432 - name: Check dead-code budget
433 if: needs.changes.outputs.heavy == 'true'
434 # Advisory on pull requests: this asserts a whole-repo property, so a
435 # branch can fail it for debt it inherited rather than added, and the
436 # fix would be rebasing instead of editing code. It stays blocking on
437 # pushes to main, where the number is actually actionable.
438 continue-on-error: ${{ github.event_name == 'pull_request' }}
439 run: python3 scripts/check-dead-code-budget.py
440 # Ratchet for blocking calls that could park Tokio workers: any new
441 # thread::sleep/std::fs site outside spawn_blocking, dedicated-thread,
442 # or test scopes must be isolated or budgeted (#6149).
443 - name: Check blocking-calls budget
444 if: needs.changes.outputs.heavy == 'true'
445 continue-on-error: ${{ github.event_name == 'pull_request' }}
446 run: python3 scripts/check-blocking-calls-budget.py
447 - name: Test runtime-contract measurement harness
448 if: needs.changes.outputs.heavy == 'true'
449 run: |
450 python3 scripts/test_measure_runtime_contract.py
451 python3 scripts/test_check_runtime_contract_budget.py
452 # The offline runtime-contract measurement needs the full locked graph,
453 # dev-dependencies included (e.g. wiremock -> assert-json-diff), but
454 # clippy above builds no test targets and the rust-cache registry key
455 # derives from Cargo.lock, so any lock-changing PR (every dependabot
456 # bump) restores an empty cache and the hermetic `cargo test --offline`
457 # dies with "failed to download ... --offline was specified" before a
458 # single budget is measured. Fetch the locked graph once here so the
459 # measurement below is deterministic on every branch.
460 - name: Fetch locked dependency graph for offline measurement
461 if: needs.changes.outputs.heavy == 'true'
462 run: cargo fetch --locked
463 # Provider-free local measurement. The checker forces Cargo offline and
464 # the measurement script runs only locked, ignored Rust metric tests.
465 - name: Check runtime-contract budget
466 if: needs.changes.outputs.heavy == 'true'
467 # Advisory on pull requests: this asserts a whole-repo property, so a
468 # branch can fail it for debt it inherited rather than added, and the
469 # fix would be rebasing instead of editing code. It stays blocking on
470 # pushes to main, where the number is actually actionable.
471 continue-on-error: ${{ github.event_name == 'pull_request' }}
472 run: python3 scripts/check-runtime-contract-budget.py
473 # Provider-free paused-consumer measurement of the production
474 # persistence request channel. RSS is sampled only on macOS; every host
475 # enforces the accepted/retained request and payload contract.
476 - name: Test persistence-backlog measurement and checker harnesses
477 if: needs.changes.outputs.heavy == 'true'
478 run: |
479 python3 scripts/test_measure_persistence_backlog.py
480 python3 scripts/test_check_persistence_backlog_budget.py
481 - name: Check persistence-backlog budget
482 if: needs.changes.outputs.heavy == 'true'
483 # Advisory on pull requests: this asserts a whole-repo property, so a
484 # branch can fail it for debt it inherited rather than added, and the
485 # fix would be rebasing instead of editing code. It stays blocking on
486 # pushes to main, where the number is actually actionable.
487 continue-on-error: ${{ github.event_name == 'pull_request' }}
488 run: python3 scripts/check-persistence-backlog-budget.py
489 - name: Check README translations stay in sync
490 if: github.event_name != 'schedule'
491 run: python3 scripts/check-readme-translations.py
492 - name: Check README locale link symmetry
493 if: github.event_name != 'schedule'
494 run: bash scripts/check-readme-locales.sh
495 - name: Check TUI locale pack parity
496 if: github.event_name != 'schedule'
497 run: python3 scripts/check-tui-locale-parity.py
498 - name: Check TUI product vocabulary
499 if: github.event_name != 'schedule'
500 run: sh scripts/check-tui-product-vocabulary.sh
501 - name: Check website locale dictionary parity
502 if: github.event_name != 'schedule'
503 run: node web/scripts/check-locales.mjs
504 - name: Skip Rust lint for light change
505 if: needs.changes.outputs.heavy != 'true'
506 run: echo "No executable Rust changes detected; preserving required Lint context."
507 - name: Linux clippy location
508 if: needs.changes.outputs.heavy == 'true'
509 run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches."
510
511 workflow-rlm-cache:
512 name: Workflow RLM cache
513 needs: changes
514 if: needs.changes.outputs.workflow == 'true'
515 timeout-minutes: 30
516 runs-on: ubuntu-latest
517 steps:
518 - uses: actions/checkout@v7
519 - uses: dtolnay/rust-toolchain@stable
520 - uses: mozilla-actions/sccache-action@v0.0.11
521 id: sccache
522 continue-on-error: true
523 - name: Enable sccache
524 if: steps.sccache.outcome == 'success'
525 shell: bash
526 run: |
527 echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
528 echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
529 echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
530 - uses: Swatinem/rust-cache@v2
531 with:
532 cache-bin: false
533 save-if: ${{ github.ref == 'refs/heads/main' }}
534 - name: Run workflow crate tests
535 run: sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-workflow --locked
536
537 test:
538 name: Test
539 needs: changes
540 # Required contexts "Test (ubuntu-latest)" / "Test (macos-latest)" /
541 # "Test (windows-latest)" derive from job name + matrix.os and are
542 # independent of runs-on. For light changes the macOS/Windows legs only
543 # echo a skip line, so run them on ubuntu instead of queueing for scarce
544 # macOS/Windows runners. Heavy pull requests run the Linux lane directly;
545 # non-PR release/main pushes use CNB for Linux.
546 # The ternary is safe: matrix.os is always a non-empty literal, so
547 # runs-on can never evaluate to empty.
548 timeout-minutes: 90
549 # macOS legs go to the self-hosted Mac ONLY when all three hold: the
550 # change is heavy, the event is trusted (not a fork PR), and the
551 # CW_SELF_HOSTED_MAC repo variable is 'true'. That variable is the kill
552 # switch: unset it and every leg falls back to GitHub-hosted runners
553 # immediately, with no commit — important because an offline
554 # self-hosted runner queues jobs forever, which is worse than a slow one.
555 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 }}
556 strategy:
557 # A failure on one desktop platform must not erase evidence from the
558 # other one. We need both conclusions to diagnose and release safely.
559 fail-fast: false
560 matrix:
561 # Linux workspace tests run directly for pull requests. CNB remains
562 # the Linux lane for non-PR release/main pushes.
563 os: [ubuntu-latest, macos-latest, windows-latest]
564 steps:
565 - name: Skip tests for light change
566 if: needs.changes.outputs.heavy != 'true'
567 run: echo "No executable Rust changes detected; preserving required Test context."
568 - uses: actions/checkout@v7
569 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
570 - name: Test Windows installer PATH helper
571 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
572 shell: pwsh
573 run: ./scripts/installer/update-user-path.tests.ps1
574 - name: Install NSIS for Windows installer regression
575 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
576 shell: pwsh
577 # Bounded retry, not a weaker check (#5403). Every observed failure here
578 # was Chocolatey's feed, not the code: a 504 from the V2 API, and
579 # "package was not found with the source(s) listed". A single attempt
580 # made `Test (windows-latest)` — a required check on every PR — report
581 # on community.chocolatey.org's availability instead of on the tree.
582 # NSIS must still install for the regression below to run; this only
583 # survives a transient outage.
584 run: |
585 $ErrorActionPreference = 'Continue'
586 $delays = @(0, 20, 45)
587 for ($attempt = 0; $attempt -lt $delays.Count; $attempt++) {
588 if ($delays[$attempt] -gt 0) {
589 Write-Host "NSIS install attempt $($attempt + 1) after $($delays[$attempt])s backoff"
590 Start-Sleep -Seconds $delays[$attempt]
591 }
592 choco install nsis -y --no-progress
593 if ($LASTEXITCODE -eq 0) {
594 Write-Host "NSIS installed on attempt $($attempt + 1)"
595 exit 0
596 }
597 Write-Host "::warning::choco install nsis failed (exit $LASTEXITCODE)"
598 }
599 Write-Host "::error::NSIS could not be provisioned from Chocolatey after $($delays.Count) attempts"
600 exit 1
601 - name: Test Windows installer PATH regression
602 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
603 shell: pwsh
604 run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
605 - uses: dtolnay/rust-toolchain@stable
606 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
607 - uses: mozilla-actions/sccache-action@v0.0.11
608 id: sccache
609 continue-on-error: true
610 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
611 - name: Enable sccache
612 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
613 shell: bash
614 run: |
615 echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
616 echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
617 echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
618 - name: Install Linux system dependencies
619 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
620 run: |
621 for i in 1 2 3 4 5; do
622 sudo apt-get update && break
623 echo "apt-get update failed (attempt $i); retrying in 15s"
624 sleep 15
625 done
626 sudo apt-get install -y libdbus-1-dev pkg-config
627 - uses: Swatinem/rust-cache@v2
628 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
629 with:
630 cache-bin: false
631 save-if: ${{ github.ref == 'refs/heads/main' }}
632 - uses: taiki-e/install-action@nextest
633 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
634 - name: Run tests
635 # Same test binaries as `cargo test`, run by cargo-nextest: one
636 # process per test, all runner cores busy, slow tests named instead
637 # of stalling the binary. `.config/nextest.toml` serializes the PTY
638 # binary and bounds the integration binary that spawns the real
639 # executable; retries are off, so a flake is a red run, not a hidden
640 # one. nextest does not run doctests — the next step keeps them.
641 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
642 shell: bash
643 run: sh scripts/with-hermetic-test-home.sh cargo nextest run --workspace --all-features --locked --profile ci
644 env:
645 # sccache 0.17 panics resolving its config directory under the
646 # isolated Windows home before Cargo can compile or run any test.
647 # Bypass only that optional cache; keep the full suite and isolation.
648 RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
649 # Give test threads the stack the product gives itself. main.rs runs
650 # the owner thread and every tokio worker at
651 # CODEWHALE_MAIN_STACK_BYTES (32 MiB) because the engine and
652 # runtime-thread futures are genuinely deep. `#[tokio::test]` builds
653 # its own runtime and never sees that, so tests ran the same code on
654 # ~2 MiB (~1 MiB on Windows) — a configuration that never ships.
655 # That gap is what aborted the whole Windows test binary with
656 # STATUS_STACK_OVERFLOW in start_turn_accepts_dynamic_tools_and_
657 # environment_id, masking every other Windows result (78afd8d3d4
658 # Box::pin'd that one frame; the mismatch itself remained). std reads
659 # this for any thread spawned without an explicit size, which covers
660 # both libtest's per-test threads and tokio's workers. Test threads
661 # hold 16 MiB: the engine-only chains they run measured a ~2.5 MiB
662 # debug high-water, while the full ~16.5 MiB UI-loop chain that
663 # forced 32 MiB lives in spawned binaries, which size their own
664 # stacks explicitly and never read this variable.
665 RUST_MIN_STACK: '16777216'
666 - name: Run doctests
667 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
668 shell: bash
669 run: sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked --doc
670 env:
671 RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
672 RUST_MIN_STACK: '16777216'
673 # The Ubuntu lint lane validates non-RSS backlog fields. Run the same
674 # source-bound measurement on macOS so loss or growth of RSS evidence
675 # fails closed instead of becoming an unsupported-field skip.
676 - name: Check persistence-backlog RSS budget
677 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
678 run: python3 scripts/check-persistence-backlog-budget.py
679 - name: Lockfile drift guard
680 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
681 run: git diff --exit-code -- Cargo.lock
682 - name: Run Offline Eval Harness
683 # The eval harness is OS-independent prompt/composition checking;
684 # running it once (on the faster macOS leg, warm from the test build)
685 # instead of once per desktop OS keeps the coverage while taking
686 # ~2min off the Windows critical path.
687 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
688 run: cargo run -p codewhale-tui --all-features -- eval
689 - name: sccache stats
690 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
691 continue-on-error: true
692 shell: bash
693 run: sccache --show-stats
694 - name: Linux test location (CNB)
695 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
696 run: echo "Linux workspace tests run on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu."
697
698 npm-wrapper-smoke:
699 name: npm wrapper smoke
700 needs: changes
701 if: github.event_name != 'schedule'
702 # Same ternary rationale as the Test job: light legs only echo, so keep
703 # them off macOS/Windows runners. On pull_request the matrix is
704 # ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)"
705 # context is unaffected. Heavy pull requests execute the Ubuntu smoke
706 # here; their branches may not be mirrored to CNB.
707 # A cold Windows build can take 29 minutes before the smoke even starts.
708 # Leave room for installation; bound the smoke itself independently below.
709 timeout-minutes: 45
710 runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
711 strategy:
712 matrix:
713 os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }}
714 steps:
715 - name: Skip npm wrapper smoke for light change
716 if: needs.changes.outputs.heavy != 'true'
717 run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
718 - uses: actions/checkout@v7
719 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
720 - uses: dtolnay/rust-toolchain@stable
721 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
722 - uses: mozilla-actions/sccache-action@v0.0.11
723 id: sccache
724 continue-on-error: true
725 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
726 - name: Enable sccache
727 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
728 shell: bash
729 run: |
730 echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
731 echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
732 echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
733 - uses: actions/setup-node@v7
734 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
735 with:
736 node-version: 20
737 - name: Install Linux system dependencies
738 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
739 run: |
740 for i in 1 2 3 4 5; do
741 sudo apt-get update && break
742 echo "apt-get update failed (attempt $i); retrying in 15s"
743 sleep 15
744 done
745 sudo apt-get install -y libdbus-1-dev pkg-config
746 - uses: Swatinem/rust-cache@v2
747 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
748 with:
749 cache-bin: false
750 save-if: ${{ github.ref == 'refs/heads/main' }}
751 - name: Build wrapper binaries
752 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
753 # The smoke validates wrapper install/delegation plumbing, not
754 # codegen quality, so skip fat LTO + codegen-units=1 for a much
755 # cheaper release build. Shipped binaries keep the real profile via
756 # the Release workflow.
757 env:
758 CARGO_PROFILE_RELEASE_LTO: 'off'
759 CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
760 run: cargo build --release --locked -p codewhale-cli -p codewhale-tui
761 - name: Smoke wrapper install and delegated entrypoints
762 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
763 timeout-minutes: 5
764 run: node scripts/release/npm-wrapper-smoke.js
765 - name: sccache stats
766 if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
767 continue-on-error: true
768 shell: bash
769 run: sccache --show-stats
770 - name: Linux smoke location
771 if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
772 run: echo "Linux npm wrapper smoke runs on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu."
773
774 mobile-smoke:
775 name: Mobile runtime smoke
776 needs: changes
777 # Not a required PR context. Pull requests run it only when the mobile
778 # runtime surface changed (see the `mobile` filter above); every push to
779 # main runs it unconditionally as the pre-release safety net.
780 if: >-
781 github.event_name != 'schedule' &&
782 needs.changes.outputs.heavy == 'true' &&
783 (github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true')
784 timeout-minutes: 30
785 runs-on: ubuntu-latest
786 steps:
787 - uses: actions/checkout@v7
788 - uses: dtolnay/rust-toolchain@stable
789 - uses: mozilla-actions/sccache-action@v0.0.11
790 id: sccache
791 continue-on-error: true
792 - name: Enable sccache
793 if: steps.sccache.outcome == 'success'
794 shell: bash
795 run: |
796 echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
797 echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
798 echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
799 - name: Install Linux system dependencies
800 run: |
801 for i in 1 2 3 4 5; do
802 sudo apt-get update && break
803 echo "apt-get update failed (attempt $i); retrying in 15s"
804 sleep 15
805 done
806 sudo apt-get install -y libdbus-1-dev pkg-config
807 - uses: Swatinem/rust-cache@v2
808 with:
809 cache-bin: false
810 save-if: ${{ github.ref == 'refs/heads/main' }}
811 - name: Run mobile smoke tests
812 # The smoke exercises HTTP/SSE runtime behaviour, not codegen
813 # quality; skipping fat LTO + codegen-units=1 cuts the in-script
814 # release build from ~12min to a fraction of that.
815 env:
816 CARGO_PROFILE_RELEASE_LTO: 'off'
817 CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
818 run: ./scripts/mobile-smoke.sh
819 - name: sccache stats
820 if: steps.sccache.outcome == 'success'
821 continue-on-error: true
822 shell: bash
823 run: sccache --show-stats
824
825 actionlint:
826 name: Workflow lint
827 needs: changes
828 if: needs.changes.outputs.actions == 'true'
829 timeout-minutes: 15
830 runs-on: ubuntu-latest
831 steps:
832 - uses: actions/checkout@v7
833 - name: Run actionlint
834 uses: docker://rhysd/actionlint:1.7.12
835 with:
836 # SC2129 (grouped redirects) is style-only and endemic to the
837 # existing GITHUB_ENV/GITHUB_OUTPUT append pattern; SC2221/SC2222
838 # flag the long-standing `*.md` glob shadowing the PR-template
839 # entry in change detection, which is intentional.
840 args: -color -ignore SC2129 -ignore SC2221 -ignore SC2222
841
842 # Check documentation builds without warnings
843 docs:
844 name: Documentation
845 if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
846 timeout-minutes: 60
847 runs-on: ubuntu-latest
848 steps:
849 - uses: actions/checkout@v7
850 - uses: dtolnay/rust-toolchain@stable
851 - name: Install Linux system dependencies
852 if: runner.os == 'Linux'
853 run: |
854 for i in 1 2 3 4 5; do
855 sudo apt-get update && break
856 echo "apt-get update failed (attempt $i); retrying in 15s"
857 sleep 15
858 done
859 sudo apt-get install -y libdbus-1-dev pkg-config
860 - uses: Swatinem/rust-cache@v2
861 with:
862 cache-bin: false
863 - name: Build docs
864 run: cargo doc --workspace --no-deps
865 env:
866 RUSTDOCFLAGS: -Dwarnings
867
867 lines YAML