返回 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 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
1429 lines YAML