Files
multica/turbo.json
Bohan Jiang 982a8c5784 perf(ci): split frontend jobs and cache turbo results (MUL-5347) (#5982)
* perf(ci): split frontend build and test onto separate runners (MUL-5347)

The frontend job is the critical path of every CI run that touches web
code: p50 576s, versus 267s for backend. 94% of it is a single
`turbo build typecheck lint test` invocation.

Profiling showed the cost is contention, not inefficient tasks. A
standard runner has 4 vCPUs, and the job scheduled two CPU-saturating
tasks onto them concurrently: `@multica/views:test` (259 jsdom files)
and `@multica/web:build` (a webpack production build). Measured against
the same suites running with the machine to themselves:

  @multica/views:test    104s owning 4 cores  ->  500s sharing them
  @multica/web:build      26s owning 10 cores ->  342s sharing 4

Split the work across two runners so neither starves the other. The
split is weighted rather than even, because the halves are not close:
the test group costs ~575 CPU-seconds and everything else ~250, so
tests get a runner alone.

Also drop `^typecheck` from the `test` and `lint` task definitions. No
package in this repo emits build artifacts -- core/ui/views export raw
.ts that the consumer transpiles -- so that edge ordered tasks without
producing anything they consumed, and left the heaviest task in the
graph idle for ~38s while core/ui ran `tsc --noEmit`. Removing it also
drops three redundant typecheck tasks from the test job, taking it from
7 tasks / 798 CPU-seconds to 4 / 575. Type errors still fail CI through
the `typecheck` task, which frontend-build owns.

`frontend` is retained as an aggregate gate so the existing status-check
name keeps reporting, including the established behaviour that it goes
green rather than pending on PRs the path filter excludes.

Co-authored-by: multica-agent <github@multica.ai>

* perf(ci): cache turbo task results across runs (MUL-5347)

Every CI run rebuilt all 16 frontend tasks cold -- turbo reported
`Remote caching disabled` / `Cached: 0 cached, 16 total` on every run,
because only the pnpm store was cached. Persist turbo's filesystem cache
with actions/cache instead. No Vercel remote-cache credentials exist in
this repo, so this is the local cache keyed per job.

Measured, simulating a fresh checkout against a warm cache:

  frontend-build   54.7s -> 184ms   (12/12 cached)
  frontend-test   115.7s ->  50ms   (7/7 cached)

Cache footprint is 20MB for the build job and ~60KB for the test job --
`test` declares no outputs, so turbo stores exit codes and logs, yet a
hit still skips the most expensive task in the graph.

Turning the cache on promotes two latent hashing bugs into real ones,
so both are fixed here:

`build` declared narrow `inputs` globs that matched neither
`content/**/*.mdx` (compiled by fumadocs-mdx during the build),
`public/**` assets, nor `package.json` / `tsconfig.json`. Editing any of
them left the task hash byte-identical, which is inert when nothing is
ever restored and a stale-build bug the moment something is. Dropped in
favour of turbo's default input set.

`test` regains its `^typecheck` edge, reverting part of the previous
commit. That change was justified on the basis that the edge only
imposed ordering, which was true without a cache: a turbo task hash
covers a workspace dependency's sources only if a task edge reaches
them, so with no edge, editing packages/views or packages/ui left
`@multica/web#test` and `@multica/desktop#test` unchanged in hash and a
cached pass would be replayed over changed code. `typecheck` is the only
task every package defines, so it is the edge that closes the gap;
`^test` would miss packages/ui, which has no test script. `lint` keeps
no edge -- eslint here is not type-aware, so it cannot observe a
dependency's sources.

Also adds .github/workflows/ci.yml to globalDependencies: it pins the
Node version, and without it a toolchain bump would replay results
produced by the old runtime behind a green check.

Co-authored-by: multica-agent <github@multica.ai>

* fix(ci): scope turbo cache to the resolved runtime, drop typecheck from tests (MUL-5347)

Addresses review feedback on the caching commit.

Must-fix: the cache was not isolated by interpreter. `node-version: 22`
floats across patch releases and turbo's global hash does not include
the interpreter at all (`engines` is null in its dry-run cache inputs),
so a runner moving to another 22.x would leave the workflow text and
every task hash unchanged, restore the old cache through `restore-keys`
and report green without executing anything. Both jobs now resolve
`node --version` into a step output and carry it, plus `runner.arch`,
in the cache key and the restore prefix.

Must-fix: `actions/cache@v4` runs on the deprecated Node 20 runtime and
was being force-migrated to Node 24 with a warning on every run. Moved
to v6 -- the review suggested v5, but v6.1.0 is current and satisfies
the same runner floor (>= 2.327.1; hosted runners are on 2.335.1).

`test` no longer depends on `^typecheck`. It needs dependency sources in
its hash, not a type check: a new hash-only `cache-inputs` transit task
carries them. No package implements that script, so every node resolves
to <NONEXISTENT> and nothing executes, while the edges still pull each
dependency's files into the hash. Declared recursively so the chain
survives a package gaining a purely transitive dependency. This returns
the ~223 CPU-seconds the previous commit gave up: the cold test job goes
from 7 tasks / 115.7s back to 4 tasks / 67.2s, and editing packages/ui
still correctly re-runs the views, web and desktop suites while
core:test stays cached.

Corrects a comment that claimed `^test` would miss packages/ui because
it has no test script. It would not -- turbo materialises a
<NONEXISTENT> node that participates in hashing, verified against the
dry graph. `^test` is still the wrong edge here, but because it
serialises the suites behind each other, not for the stated reason.

Also drops the stale note that tests no longer depend on `^typecheck`,
and swaps the aggregate gate's `always()` for `!cancelled()` so a run
superseded by a newer push does not spend a runner on a verdict nobody
reads.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 15:26:13 +08:00

2.9 KiB