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>
This commit is contained in:
Bohan-J
2026-07-27 13:57:36 +08:00
parent 85a14cde37
commit 5fa13d591e
2 changed files with 76 additions and 9 deletions

View File

@@ -75,7 +75,19 @@ jobs:
echo "frontend=false" >> "$GITHUB_OUTPUT"
fi
frontend:
# The frontend validation is split across two runners on purpose. Both
# `@multica/web:build` (a webpack production build) and `@multica/views:test`
# (259 jsdom files) are CPU-saturating, and a standard runner only has
# 4 vCPUs. Running them in one job made them starve each other: the views
# suite needs ~104s wall when it owns 4 cores but took ~500s sharing them,
# and the identical webpack compile went from ~26s to ~342s. Splitting buys
# a second 4-vCPU box rather than reducing the work; total runner-minutes go
# up slightly, wall-clock feedback time goes down.
#
# The split is weighted, not even: the test group is by far the heavier half
# (~575 CPU-seconds vs ~250 for build + typecheck + lint), so it gets a
# runner to itself and everything else shares the other one.
frontend-build:
needs: changes
runs-on: ubuntu-latest
steps:
@@ -112,14 +124,67 @@ jobs:
pnpm generate:reserved-slugs
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
- name: Build, type check, and lint
if: ${{ needs.changes.outputs.frontend == 'true' }}
# Mobile lives in a parallel mobile-verify workflow (path-filtered
# to apps/mobile/** + packages/core/**) so it doesn't add
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
# filter in sync with the root package.json scripts, which also
# exclude @multica/mobile.
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs' --filter='!@multica/mobile'
run: pnpm exec turbo build typecheck lint --filter='!@multica/docs' --filter='!@multica/mobile'
frontend-test:
needs: changes
runs-on: ubuntu-latest
steps:
- name: Checkout
if: ${{ needs.changes.outputs.frontend == 'true' }}
uses: actions/checkout@v6
- name: Setup pnpm
if: ${{ needs.changes.outputs.frontend == 'true' }}
uses: pnpm/action-setup@v4
- name: Setup Node.js
if: ${{ needs.changes.outputs.frontend == 'true' }}
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
if: ${{ needs.changes.outputs.frontend == 'true' }}
run: pnpm install
- name: Test
if: ${{ needs.changes.outputs.frontend == 'true' }}
# Same filter rationale as frontend-build. Type errors are not this
# job's responsibility — the `typecheck` task in frontend-build owns
# them, which is why `test` no longer depends on `^typecheck`.
run: pnpm exec turbo test --filter='!@multica/docs' --filter='!@multica/mobile'
# Aggregate gate. `frontend` is the status-check name the repository's
# branch rules refer to, so it has to survive the split above: this job
# keeps reporting under that name and simply fails when either half fails.
# It also inherits the old contract that the check goes green (rather than
# staying pending) on PRs the path filter excluded — both halves succeed
# trivially in that case because every step is gated off.
frontend:
needs: [frontend-build, frontend-test]
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- name: Check frontend job results
env:
BUILD_RESULT: ${{ needs.frontend-build.result }}
TEST_RESULT: ${{ needs.frontend-test.result }}
run: |
echo "frontend-build: $BUILD_RESULT"
echo "frontend-test: $TEST_RESULT"
if [ "$BUILD_RESULT" != "success" ] || [ "$TEST_RESULT" != "success" ]; then
echo "::error::frontend validation failed"
exit 1
fi
backend:
runs-on: ubuntu-latest

View File

@@ -32,11 +32,13 @@
"typecheck": {
"dependsOn": ["^typecheck"]
},
"test": {
"dependsOn": ["^typecheck"]
},
"lint": {
"dependsOn": ["^typecheck"]
}
// `test` and `lint` deliberately have no `^typecheck` dependency. No
// package emits build artifacts (core/ui/views export raw .ts that the
// consumer transpiles), so depending on a dependency's `tsc --noEmit`
// bought nothing but head-of-line blocking: the heaviest task in the
// graph, `@multica/views:test`, sat idle until core/ui typechecked.
// Type errors still fail CI through the `typecheck` task itself.
"test": {},
"lint": {}
}
}