| 1 | name: CI |
| 2 | |
| 3 | on: |
| 4 | pull_request: |
| 5 | push: |
| 6 | branches: |
| 7 | - main |
| 8 | |
| 9 | permissions: |
| 10 | contents: read |
| 11 | |
| 12 | concurrency: |
| 13 | group: ci-${{ github.event.pull_request.number || github.sha }} |
| 14 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} |
| 15 | |
| 16 | jobs: |
| 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 |