1name: CI
2
3on:
4 pull_request:
5 push:
6 branches:
7 - main
8
9permissions:
10 contents: read
11
12concurrency:
13 group: ci-${{ github.event.pull_request.number || github.sha }}
14 cancel-in-progress: ${{ github.event_name == 'pull_request' }}
15
16jobs:
17 # Lint, typecheck, and the desktop build each get a runner so they run side by
18 # side instead of one after another. The `Check` job at the end of this file
19 # passes only when every other job does.
20 lint:
21 name: Lint
22 runs-on: blacksmith-8vcpu-ubuntu-2404
23 timeout-minutes: 10
24 steps:
25 - name: Checkout
26 uses: actions/checkout@v6
27 with:
28 sparse-checkout: |
29 /*
30 !/.repos/
31 sparse-checkout-cone-mode: false
32
33 - name: Reject repository-owned PR assets
34 run: |
35 files="$(git ls-files .github/pr-assets)"
36 if test -n "$files"; then
37 printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2
38 exit 1
39 fi
40
41 - name: Setup Vite+
42 uses: voidzero-dev/setup-vp@v1
43 with:
44 node-version-file: package.json
45 cache: true
46 run-install: true
47
48 - name: Ensure Electron runtime is installed
49 run: vp run --filter @t3tools/desktop ensure:electron
50
51 # Files/dependencies are repo-wide; export checks cover clean workspaces only.
52 - name: Check unused code
53 run: vp run knip:check
54
55 - name: Check
56 run: vp check
57
58 typecheck:
59 name: Typecheck
60 runs-on: blacksmith-8vcpu-ubuntu-2404
61 timeout-minutes: 10
62 steps:
63 - name: Checkout
64 uses: actions/checkout@v6
65 with:
66 sparse-checkout: |
67 /*
68 !/.repos/
69 sparse-checkout-cone-mode: false
70
71 - name: Setup Vite+
72 uses: voidzero-dev/setup-vp@v1
73 with:
74 node-version-file: package.json
75 cache: true
76 run-install: true
77
78 - name: Ensure Electron runtime is installed
79 run: vp run --filter @t3tools/desktop ensure:electron
80
81 - name: Typecheck
82 run: vpr typecheck
83
84 build:
85 name: Build
86 runs-on: blacksmith-8vcpu-ubuntu-2404
87 timeout-minutes: 10
88 steps:
89 - name: Checkout
90 uses: actions/checkout@v6
91 with:
92 sparse-checkout: |
93 /*
94 !/.repos/
95 sparse-checkout-cone-mode: false
96
97 - uses: ./.github/actions/setup-apt-mirrors
98
99 # Runs in the background while Vite+ installs; the step before the build waits for it.
100 - name: Start installing browser secret helper build libraries
101 run: |
102 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
103 (
104 set +e
105 sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
106 echo "$?" > "$RUNNER_TEMP/apt-status"
107 ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &
108
109 - name: Setup Vite+
110 uses: voidzero-dev/setup-vp@v1
111 with:
112 node-version-file: package.json
113 cache: true
114 run-install: true
115
116 - name: Ensure Electron runtime is installed
117 run: vp run --filter @t3tools/desktop ensure:electron
118
119 - name: Finish installing browser secret helper build libraries
120 timeout-minutes: 5
121 run: |
122 until test -s "$RUNNER_TEMP/apt-status"; do sleep 1; done
123 cat "$RUNNER_TEMP/apt.log"
124 exit "$(cat "$RUNNER_TEMP/apt-status")"
125
126 - name: Build desktop pipeline
127 run: vp run build:desktop
128
129 - name: Verify preload bundle output
130 run: node apps/desktop/scripts/verify-preload-bundle.mjs
131
132 # Everything except `t3` (apps/server) and the web app, which has its own job
133 # below. `--parallel` drops the package dependency ordering that `vp run`
134 # applies by default: these `test` tasks declare no `dependsOn` and resolve
135 # workspace deps from source, so ordering only bought us idle runners between
136 # dependency layers. The concurrency limit stays at the default 4 so peak load
137 # per runner is unchanged.
138 test:
139 name: Test
140 runs-on: blacksmith-8vcpu-ubuntu-2404
141 timeout-minutes: 10
142 steps:
143 - name: Checkout
144 uses: actions/checkout@v6
145 with:
146 sparse-checkout: |
147 /*
148 !/.repos/
149 sparse-checkout-cone-mode: false
150
151 # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present),
152 # but ACP process-tree live tests compile a small pthread fixture with `cc`
153 # and soft-skip when it is missing. Install build-essential so that path
154 # always runs in CI instead of silently no-oping.
155 - name: Install C toolchain for process-tree fixtures
156 run: |
157 sudo apt-get update
158 sudo apt-get install -y --no-install-recommends build-essential
159 command -v cc
160
161 - uses: ./.github/actions/setup-apt-mirrors
162
163 # The desktop tests compile the browser secret helper. The install runs in
164 # the background while Vite+ installs; the step before the tests waits for it.
165 - name: Start installing browser secret helper build libraries
166 run: |
167 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
168 (
169 set +e
170 sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
171 echo "$?" > "$RUNNER_TEMP/apt-status"
172 ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &
173
174 - name: Setup Vite+
175 uses: voidzero-dev/setup-vp@v1
176 with:
177 node-version-file: package.json
178 cache: true
179 run-install: true
180
181 - name: Ensure Electron runtime is installed
182 run: vp run --filter @t3tools/desktop ensure:electron
183
184 - name: Test preview artifact validation
185 run: python3 -B .github/scripts/stage-preview-bundle.test.py
186
187 - name: Test nightly release checks
188 run: node --test .github/scripts/check-nightly-release.test.cjs
189
190 - name: Finish installing browser secret helper build libraries
191 timeout-minutes: 5
192 run: |
193 until test -s "$RUNNER_TEMP/apt-status"; do sleep 1; done
194 cat "$RUNNER_TEMP/apt.log"
195 exit "$(cat "$RUNNER_TEMP/apt-status")"
196
197 - name: Test
198 run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test
199
200 # The web suite is the largest outside the server. In the shared Test job it
201 # started last and ran alone for about 40s after everything else finished.
202 test_web:
203 name: Test Web
204 runs-on: blacksmith-8vcpu-ubuntu-2404
205 timeout-minutes: 10
206 steps:
207 - name: Checkout
208 uses: actions/checkout@v6
209 with:
210 sparse-checkout: |
211 /*
212 !/.repos/
213 sparse-checkout-cone-mode: false
214
215 - name: Setup Vite+
216 uses: voidzero-dev/setup-vp@v1
217 with:
218 node-version-file: package.json
219 cache: true
220 run-install: true
221
222 - name: Test
223 run: vp run --filter @t3tools/web test
224
225 # apps/server sets `fileParallelism: false`, so its test files run strictly
226 # one at a time. Sharding spreads them over separate runners instead of
227 # separate workers, so no two server test files ever share a machine and the
228 # isolation that flag buys is preserved exactly. One file at a time keeps
229 # about 1.5 cores busy, so 4 vCPU runners lose nothing against 8. The shards
230 # split files by recorded duration (apps/server/src/testUtils/weightedShardSequencer.ts).
231 test_server:
232 name: Test Server ${{ matrix.shard }}
233 runs-on: blacksmith-4vcpu-ubuntu-2404
234 timeout-minutes: 10
235 strategy:
236 fail-fast: false
237 matrix:
238 shard: [1, 2, 3, 4, 5, 6]
239 steps:
240 - name: Checkout
241 uses: actions/checkout@v6
242 with:
243 sparse-checkout: |
244 /*
245 !/.repos/
246 sparse-checkout-cone-mode: false
247
248 - name: Setup Vite+
249 uses: voidzero-dev/setup-vp@v1
250 with:
251 node-version-file: package.json
252 cache: true
253 run-install: true
254
255 # No Electron setup here: `t3` (apps/server) has no Electron dependency
256 # and none of its tests touch the runtime. Only the non-server `test`
257 # job, which covers @t3tools/desktop, needs the download.
258 - name: Install Chromium for server browser integration tests
259 run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium
260
261 - name: Test
262 env:
263 T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md
264 T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json
265 run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}
266
267 # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard
268 # produces these files. Gating the upload on their presence keeps a
269 # single `thread-transfer-results` artifact per run, which is the name
270 # thread-transfer-report.yml resolves.
271 - name: Detect transfer budget report
272 id: transfer_budget
273 if: always()
274 run: |
275 if test -f "${{ runner.temp }}/thread-transfer-result.json"; then
276 echo "present=true" >> "$GITHUB_OUTPUT"
277 else
278 echo "present=false" >> "$GITHUB_OUTPUT"
279 fi
280
281 - name: Publish transfer budget report
282 if: always() && steps.transfer_budget.outputs.present == 'true'
283 run: |
284 if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then
285 tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md"
286 else
287 echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY"
288 fi
289
290 - name: Upload thread transfer result
291 if: always() && steps.transfer_budget.outputs.present == 'true'
292 uses: actions/upload-artifact@v7
293 with:
294 name: thread-transfer-results
295 path: ${{ runner.temp }}/thread-transfer-result.json
296 if-no-files-found: ignore
297 retention-days: 30
298
299 transfer-report:
300 name: Transfer report artifact
301 needs: test_server
302 # On Blacksmith like every other job here: the GitHub-hosted queue is shared
303 # with the PR bots and can leave this job waiting until it is abandoned,
304 # which fails `Check` on an otherwise green run.
305 runs-on: blacksmith-2vcpu-ubuntu-2404
306 timeout-minutes: 5
307 steps:
308 - name: Require transfer measurements
309 uses: actions/download-artifact@v8
310 with:
311 name: thread-transfer-results
312 path: ${{ runner.temp }}/thread-transfer
313
314 # Split out of Check and Test: both paid ~7-9s to install a Rust toolchain
315 # for checks that take under 3s, on the critical path of every PR.
316 rust:
317 name: Rust
318 runs-on: blacksmith-4vcpu-ubuntu-2404
319 timeout-minutes: 10
320 steps:
321 - name: Checkout
322 uses: actions/checkout@v6
323 with:
324 sparse-checkout: |
325 /*
326 !/.repos/
327 sparse-checkout-cone-mode: false
328
329 - name: Setup Rust
330 uses: dtolnay/rust-toolchain@stable
331 with:
332 components: rustfmt
333
334 - name: Check Rust formatting
335 run: |
336 for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do
337 cargo fmt --manifest-path "native/$crate/Cargo.toml" -- --check
338 done
339
340 - name: Test Rust crates
341 run: |
342 for crate in resource-monitor kde-snap-shot hyprland-snap-shot; do
343 cargo test --locked --manifest-path "native/$crate/Cargo.toml"
344 done
345
346 # The static analysis below needs a macOS runner, which bills ~6.7x a Linux
347 # minute, so gate it on the native sources it actually lints instead of paying
348 # for it on every push. Detection is API-only (no checkout) and fails open: if
349 # the diff cannot be resolved, the lint runs.
350 mobile_native_changes:
351 name: Mobile Native Changes
352 runs-on: blacksmith-2vcpu-ubuntu-2404
353 timeout-minutes: 5
354 permissions:
355 contents: read
356 pull-requests: read
357 outputs:
358 changed: ${{ steps.detect.outputs.changed }}
359 steps:
360 - name: Detect mobile native changes
361 id: detect
362 env:
363 GH_TOKEN: ${{ github.token }}
364 PR_NUMBER: ${{ github.event.pull_request.number }}
365 BEFORE_SHA: ${{ github.event.before }}
366 run: |
367 set -uo pipefail
368
369 fail_open() {
370 echo "$* Running native static analysis."
371 echo "changed=true" >> "$GITHUB_OUTPUT"
372 exit 0
373 }
374
375 count_rows() {
376 printf '%s\n' "$1" | grep -c . || true
377 }
378
379 # One row per changed file, holding the new path and, for a rename,
380 # the path it replaced: renaming a matched file out of the matched
381 # paths removes a lint input just like editing it.
382 row='[.filename, (.previous_filename // empty)] | @tsv'
383
384 if [[ -n "${PR_NUMBER}" ]]; then
385 # The PR files endpoint stops at 3000 files and pagination cannot
386 # extend it, so cross-check against the count the PR itself reports.
387 expected=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.changed_files') \
388 || fail_open "Could not read the pull request."
389 rows=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq ".[] | ${row}") \
390 || fail_open "Could not resolve changed files."
391
392 listed=$(count_rows "$rows")
393 if [[ "$listed" -lt "$expected" ]]; then
394 fail_open "GitHub listed only ${listed} of ${expected} changed files."
395 fi
396 else
397 rows=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BEFORE_SHA}...${GITHUB_SHA}" --jq ".files[]? | ${row}") \
398 || fail_open "Could not resolve changed files."
399
400 # The compare endpoint reports at most 300 files and pagination does
401 # not extend that list, so a full list may be hiding native changes.
402 listed=$(count_rows "$rows")
403 if [[ "$listed" -ge 300 ]]; then
404 fail_open "GitHub listed ${listed} changed files, the compare endpoint maximum."
405 fi
406 fi
407
408 paths=$(tr '\t' '\n' <<< "$rows")
409
410 # Sources scripts/mobile-native-static-check.ts lints, plus the tool
411 # and rule configuration that decides how it lints them, plus the
412 # root package.json that defines the lint:mobile command.
413 pattern='^apps/mobile/.*\.(swift|kt|kts)$|^apps/mobile/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts/mobile-native-static-check\.ts$|^package\.json$|^\.github/workflows/ci\.yml$'
414
415 if grep -qE "$pattern" <<< "$paths"; then
416 echo "Native sources or lint configuration changed:"
417 grep -E "$pattern" <<< "$paths"
418 echo "changed=true" >> "$GITHUB_OUTPUT"
419 else
420 echo "No mobile native sources or lint configuration changed."
421 echo "changed=false" >> "$GITHUB_OUTPUT"
422 fi
423
424 mobile_native_static_analysis:
425 name: Mobile Native Static Analysis
426 needs: mobile_native_changes
427 # Skip only on an explicit "no": a gate job that failed or errored leaves the
428 # output empty, and that must run the lint rather than silently skip it.
429 if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }}
430 runs-on: blacksmith-6vcpu-macos-26
431 timeout-minutes: 10
432 steps:
433 - name: Checkout
434 uses: actions/checkout@v6
435 with:
436 sparse-checkout: |
437 /*
438 !/.repos/
439 sparse-checkout-cone-mode: false
440
441 - name: Setup Vite+
442 uses: voidzero-dev/setup-vp@v1
443 with:
444 node-version-file: package.json
445 cache: true
446 run-install: |
447 args:
448 - --filter=@t3tools/scripts...
449
450 - name: Install mobile native static analysis tools
451 run: brew bundle install --file apps/mobile/Brewfile
452
453 - name: Lint mobile native sources
454 run: vp run lint:mobile
455
456 release_smoke:
457 name: Release Smoke
458 runs-on: blacksmith-8vcpu-ubuntu-2404
459 timeout-minutes: 10
460 steps:
461 - name: Checkout
462 uses: actions/checkout@v6
463 with:
464 sparse-checkout: |
465 /*
466 !/.repos/
467 sparse-checkout-cone-mode: false
468
469 - name: Setup Vite+
470 uses: voidzero-dev/setup-vp@v1
471 with:
472 node-version-file: package.json
473 cache: true
474 run-install: |
475 args:
476 - --filter=@t3tools/scripts...
477
478 - name: Exercise release-only workflow steps
479 run: node scripts/release-smoke.ts
480
481 # Branch protection requires this job by its name, `Check`. It fails when any
482 # other job fails, is cancelled, or is skipped without a reason, so splitting
483 # or resharding jobs needs no ruleset change. Add every new job to `needs`.
484 # `always()` rather than `!cancelled()`: a skipped job reports success to
485 # branch protection, so a cancelled run must still produce a failing check.
486 check:
487 name: Check
488 if: ${{ always() }}
489 needs:
490 [
491 lint,
492 typecheck,
493 build,
494 test,
495 test_web,
496 test_server,
497 transfer-report,
498 rust,
499 mobile_native_changes,
500 mobile_native_static_analysis,
501 release_smoke,
502 ]
503 runs-on: blacksmith-2vcpu-ubuntu-2404
504 timeout-minutes: 5
505 steps:
506 - name: Require every job to pass
507 env:
508 RESULTS: ${{ toJSON(needs) }}
509 run: |
510 echo "$RESULTS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
511 # Only the macOS lint skips on purpose, when no native mobile code changed.
512 echo "$RESULTS" | jq -e 'all(to_entries[];
513 .value.result == "success"
514 or (.key == "mobile_native_static_analysis" and .value.result == "skipped"))' > /dev/null