diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d90b7be42a..26351b9c24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,9 +75,24 @@ 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 + env: + # Pin turbo's filesystem cache somewhere actions/cache can address. + TURBO_CACHE_DIR: .turbo/cache steps: - name: Checkout if: ${{ needs.changes.outputs.frontend == 'true' }} @@ -98,6 +113,31 @@ jobs: if: ${{ needs.changes.outputs.frontend == 'true' }} run: pnpm install + # `node-version: 22` above 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). Without the resolved version in the key, + # a runner silently moving to another 22.x would restore a cache built by + # the old interpreter and report green without executing anything. + - name: Resolve runtime for cache key + id: runtime + if: ${{ needs.changes.outputs.frontend == 'true' }} + run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT" + + # Cache entries are immutable, so the key carries the commit SHA to make + # every run publish a fresh one and `restore-keys` falls back to the most + # recent prefix match. The two frontend jobs run disjoint task sets, so + # they get their own prefixes rather than racing to save one key. + # GitHub scopes caches by branch: a PR reads main's entries (so unchanged + # tasks hit on the first push) and writes its own (so re-pushes hit too). + - name: Restore turbo cache + if: ${{ needs.changes.outputs.frontend == 'true' }} + uses: actions/cache@v6 + with: + path: .turbo/cache + key: turbo-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }} + restore-keys: | + turbo-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}- + - name: Test self-host env derivation if: ${{ needs.changes.outputs.frontend == 'true' }} run: bash scripts/selfhost-config.test.sh @@ -112,14 +152,91 @@ 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 + env: + TURBO_CACHE_DIR: .turbo/cache + 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: Resolve runtime for cache key + id: runtime + if: ${{ needs.changes.outputs.frontend == 'true' }} + run: echo "node=$(node --version)" >> "$GITHUB_OUTPUT" + + # See frontend-build for the key strategy. These entries are tiny (~60KB + # measured): `test` declares no outputs, so turbo caches exit codes and + # logs rather than artifacts -- yet a hit still skips the whole suite, + # which is the single most expensive task in the graph. + - name: Restore turbo cache + if: ${{ needs.changes.outputs.frontend == 'true' }} + uses: actions/cache@v6 + with: + path: .turbo/cache + key: turbo-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ github.sha }} + restore-keys: | + turbo-test-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}- + + - name: Test + if: ${{ needs.changes.outputs.frontend == 'true' }} + # Same filter rationale as frontend-build. Type errors are not this + # job's responsibility -- frontend-build owns the `typecheck` task. + # `test` reaches dependency sources through the hash-only + # `^cache-inputs` edge (see turbo.json), so no `tsc` runs here. + 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] + # `!cancelled()` rather than `always()`: a run superseded by a newer push + # is cancelled by the concurrency group above, and there is no reason to + # spend a runner reporting a verdict nobody will read. + if: ${{ !cancelled() }} + 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 diff --git a/turbo.json b/turbo.json index 13c56bd039..c1852127f4 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,10 @@ { "$schema": "https://turbo.build/schema.json", + // The CI workflow pins the toolchain these tasks run under (Node 22 today). + // Without it in the global hash, bumping that version would replay cached + // typecheck/test results produced by the old runtime and hide an + // incompatibility behind a green check. + "globalDependencies": [".github/workflows/ci.yml"], "globalEnv": [ "DATABASE_URL", "PORT", @@ -18,7 +23,13 @@ "tasks": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"], + // No explicit `inputs`: turbo's default (every git-tracked file in the + // package) is the only safe hash source now that the cache is actually + // restored in CI. The previous narrow glob list matched neither + // `content/**/*.mdx` (compiled by fumadocs-mdx during the build), + // `public/**` assets, nor `package.json` / `tsconfig.json`, so the task + // hash was unchanged by edits to any of them -- harmless while nothing + // was ever cached, a stale-build bug the moment it is. "outputs": [".next/**", "!.next/cache/**", "dist/**", "out/**"] }, "dev": { @@ -32,11 +43,29 @@ "typecheck": { "dependsOn": ["^typecheck"] }, - "test": { - "dependsOn": ["^typecheck"] + // Hash-only transit task. No package implements a `cache-inputs` script, + // so every node resolves to and nothing is ever executed -- + // but the edges still pull each dependency's file hashes into whatever + // depends on it. `dependsOn: ["^cache-inputs"]` makes that recursive, so + // the chain stays complete if a package ever gains a purely transitive + // dependency. This exists because a turbo task hash covers a workspace + // dependency's sources only when a task edge reaches them. + "cache-inputs": { + "dependsOn": ["^cache-inputs"] }, - "lint": { - "dependsOn": ["^typecheck"] - } + // Without a dependency edge, editing packages/views or packages/ui left + // `@multica/web#test` and `@multica/desktop#test` byte-identical in hash, + // so a cached pass would be replayed over changed code. `^cache-inputs` + // closes that without side effects: `^typecheck` would also work but drags + // three real `tsc --noEmit` runs (~223 CPU-seconds) into the test job, and + // `^test` would serialise the suites behind each other. + "test": { + "dependsOn": ["^cache-inputs"] + }, + // `lint` keeps no dependency edge: eslint here is not type-aware (no + // `projectService` / `parserOptions.project` anywhere in + // packages/eslint-config), so it only ever reads its own package's files + // and a dependency's source cannot change its result. + "lint": {} } }