Skip to content

ci: split the quality gate into parallel jobs and cut test import cost - #964

Open
realcodesiman wants to merge 5 commits into
mainfrom
ci/tune-docker-build-cache
Open

ci: split the quality gate into parallel jobs and cut test import cost#964
realcodesiman wants to merge 5 commits into
mainfrom
ci/tune-docker-build-cache

Conversation

@realcodesiman

@realcodesiman realcodesiman commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The PR quality gate ran as a single 10m17s job (run 32017233665). The remaining cost was structural, not a tuning knob, so this splits the phases onto their own runners and attacks the two real bottlenecks: a serialized type-check graph and per-file module import cost.
  • Also closes a correctness gap found along the way: the root pnpm lint (ultracite check, repo-wide) was never run in CI. turbo run lint only ever reached builder's own script — the other 57 tasks are <NONEXISTENT> placeholders.

Changes

Split the gate into three parallel jobs (.github/workflows/ci.yml) — types, lint, and tests each get a full 4-vCPU runner, so wall clock is the slowest phase instead of their sum. Each picks its own concurrency: tsc is single-threaded and CPU-bound, vitest parallelizes internally.

Dropped meaningless dependsOn edges (turbo.json, apps/builder/turbo.json, packages/ui/turbo.json):

  • ^check-types serialized 56 tasks 13 deep. Nothing here sets composite/references and no package ships a dist (every exports resolves to ./src/*.ts), so each tsc --noEmit reparses its dependencies' source and consumes nothing a prior task emitted. Chain is now depth 1.
  • ^lint — Biome lints files independently; there was no ordering to preserve.
  • outputs: ["tsconfig.tsbuildinfo"] is kept: it governs caching, not ordering.

Cut per-file import cost (packages/vitest-config/src/node.ts + 8 consumer configs). Import, not execution, dominated the suites — builder measured import 419.94s vs tests 16.92s:

  • MSW is now opt-in via the exported mswSetupFiles. It was booting a ~7MB graph plus three lifecycle hooks in every test file, while only 24 files across 8 workspaces mock HTTP — none of builder's 248.
  • Enabled deps.optimizer.ssr and moved to the threads pool at 4 workers on CI.

Measurements

Builder (248 files, CI env, cold) — the critical path:

Config Wall clock Result
forks + isolate, 2w (previous) 71.9s 248 pass
threads + isolate, 2w 66.6s 248 pass
threads + isolate, 4w (shipped) 38.4s 248 pass
threads, --no-isolate, 2w 30.8s ❌ 18 files fail

Full 56-suite run: 98.4s. Builder setup 41.45s → 1.19s.

Notes for the reviewer

Isolation stays ON, deliberately. --no-isolate is the fastest option but breaks the suite: vi.mock registers per module registry, so files sharing a worker overwrite each other's mocks. 18 files failed on threads, 2 on forks, and the failing set changed between runs — yet each file passes when run alone. That is pre-existing cross-file coupling; decoupling it belongs in its own change. Documented in the config so it isn't re-attempted blind.

The first CI run after this will be a full cache miss. Dependency hashes feed each task hash, so dropping the edges invalidates all 56 check-types entries. Judge the win on the second run.

Test plan

  • pnpm lint — clean, 5246 files (this is the repo-wide check that previously never ran in CI)
  • pnpm turbo run check-types --force — 56/56, cold cache
  • pnpm turbo run test --force56/56 suites green
  • builder: 248 files / 1538 tests pass
  • All 8 MSW-opting workspaces pass (business 960 tests, messenger 197)
  • Verified the MSW opt-in is load-bearing: reverting one workspace to the bare preset fails 6 tests, restoring it passes 8
  • Confirm the three jobs appear in parallel on CI, and compare timing on run Dashboad > list chatbots #2 (not run Authentication & Authorization #1)

buildkit-cache-dance round-tripped the pnpm store and .next/cache through
the GitHub Actions cache every run, but turbo.json excludes .next/cache
from build outputs so a remote-cache hit skips next build entirely anyway
— the round-trip cost more than the ~40s install it saved. Also disables
Next's turbopackFileSystemCacheForBuild since the Docker build starts from
a clean layer and never reads it back.

Adds a per-leg `runner` matrix field (all still ubuntu-latest) and a
45-minute job timeout so a future larger-runner label is a one-word swap
once one is actually provisioned.
The CI quality gate spent 298s with 111/113 turbo tasks already cached.
Nearly all of it was one task: builder:test reported 289s wall for 8.68s
of actual test execution, the rest being per-file module-graph rebuild.

Cause was maxWorkers: 1 on CI in the shared vitest preset, which ran
builder's 248 files serially in a single fork. The comment defending it
argued turbo already parallelizes at the task level, but turbo does not
parallelize *within* a suite — each suite is one `vitest run` process, so
wall clock is bounded below by the slowest single suite. Measured: turbo
--concurrency=4 over the 4 heaviest suites gave 2m46s, ~= builder alone.

Raise maxWorkers to 2 on CI and drop turbo --concurrency to 2, keeping
(concurrency x maxWorkers) <= the runner's 4 vCPU so forks never
oversubscribe. Builder, through `turbo run test`: 147s -> 76s.

VITEST_MAX_WORKERS overrides the preset for experimentation. It needs
passThroughEnv because turbo runs in strict env mode and would otherwise
strip it on the very path CI uses; passThroughEnv rather than env since
worker count changes timing, not results, and must stay out of the hash.

Also cache tsconfig.tsbuildinfo for check-types. incremental: true was
inert in CI because the task declared no outputs, so every miss was a
cold tsc (~30s for builder). Only apps/builder and packages/ui emit a
buildinfo, so the outputs key lives in their own turbo.json — declaring
it at the root makes turbo warn for the other 56 check-types tasks.

Bump pnpm/action-setup v4 -> v6.0.10 in both workflows to clear the
Node 20 deprecation annotation.
@realcodesiman realcodesiman changed the title ci(release): drop ineffective docker build caching, prep runner matrix ci: parallelize vitest workers and drop ineffective docker build caching Aug 17, 2026
The PR gate ran as one 10m17s job. Three structural problems, not tuning:

- check-types was serialized 13 deep by `dependsOn: ["^check-types"]`, but
  nothing here sets `composite`/`references` and no package ships a `dist`
  (every `exports` resolves to `./src/*.ts`), so each `tsc --noEmit` reparses
  its dependencies' source and consumes nothing a prior task emitted. The
  edge conveyed no data. Same for `^lint`: Biome lints files independently,
  and only builder defines a real lint script — the other 57 tasks were
  <NONEXISTENT> placeholders turbo still scheduled behind the chain.
- MSW booted in every test file (~7MB graph plus three hooks) while only 24
  files across 8 workspaces mock HTTP — none of builder's 248. It is now
  opt-in via `mswSetupFiles`.
- Import, not execution, dominated the suites (builder: import 419.94s vs
  tests 16.92s), so enable `deps.optimizer.ssr` and move to the `threads`
  pool with 4 workers on CI.

Types, lint and tests now run as three jobs on their own runners, so wall
clock is the slowest phase rather than their sum. This also closes a gap:
the root `pnpm lint` (`ultracite check`, repo-wide) was never run in CI —
`turbo run lint` only ever reached builder's own script.

Isolation stays ON. `--no-isolate` is faster (~30s on builder) but breaks
the suite: `vi.mock` registers per module registry, so files sharing a
worker overwrite each other's mocks — 18 files failed on threads, 2 on
forks, with the failing set changing between runs, though each passes
alone. That is pre-existing cross-file coupling and needs its own change.

Measured on builder (248 files, CI env, cold): forks/2w 71.9s (previous
setting), threads/2w 66.6s, threads/4w 38.4s, all 248 green. Full 56-suite
run 98.4s.

Note the first CI run after this is a full cache miss: dependency hashes
feed each task hash, so dropping the edges invalidates all 56 check-types
entries. Judge the win on the second run.
@realcodesiman realcodesiman changed the title ci: parallelize vitest workers and drop ineffective docker build caching ci: split the quality gate into parallel jobs and cut test import cost Aug 17, 2026
Scope workflow tokens to read-only where jobs never write, split
dependabot's docker ecosystem per-app (root has no Dockerfile so the
directory glob never tracked base images), drop the pr-labeler
issues:write scope now that labels are pre-bootstrapped, and remove
duplicate lint/type-check runs between ci.yml and release.yml so each
check runs once per relevant trigger.
getRedisConnection() only skipped lazyConnect for next build
(NEXT_PHASE=phase-production-build), so importing any queue barrel
under vitest still dialed the non-routable REDIS_URL from setup-env
and retried forever. Add isNoRedisEnv() to also treat VITEST=true as
a no-dial environment and use it everywhere the build-phase check was
duplicated across queue barrels.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci CI/CD pipeline changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant