diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 121d82d701..45adb82b57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,7 +319,7 @@ jobs: run: ${{ matrix.command }} # One large runner pays hosted setup once, then the repository scheduler - # overlaps the complete unsharded primary gate inventory inside that box. + # overlaps the unsharded primary inventory except the production site build. node-24: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: dsh-ubuntu-24-04-96core @@ -383,6 +383,38 @@ jobs: - name: Run complete unsharded primary Node CI concurrently run: pnpm run check:ci:large-runner + # Keep only the longest independent Linux gate on a second coarse-grained + # runner so cold install variance does not push the primary box over a minute. + node-24-site: + if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' + runs-on: dsh-ubuntu-24-04-16core + name: node 24 / production site + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack and resolve pnpm store path + id: pnpm-store + run: | + corepack enable + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Build documentation site (production SPA) + run: pnpm run docs:build + node-compat: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' runs-on: ubuntu-latest @@ -583,49 +615,13 @@ jobs: shell: pwsh run: ${{ matrix.command }} - # The two blocking Windows build surfaces share one setup and run concurrently. - windows-build: + # One Windows box shares setup across the required build/site checks and the + # complete observational portability inventory. run-gates reports failures + # from observational gates without allowing them to fail the required job. + windows: if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - runs-on: dsh-windows-2025-16core - name: windows / blocking builds - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - shell: pwsh - run: | - corepack enable - "path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - - name: Install (immutable) - shell: pwsh - run: pnpm install --frozen-lockfile - - - name: Run blocking Windows builds concurrently - shell: pwsh - env: - DSH_GATE_CONCURRENCY: '2' - run: pnpm run check:ci:windows-blocking - - # Wider Windows portability gates remain observational, but one larger box - # now runs their complete unsharded inventory with in-runner concurrency. - windows-gates: - if: github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners' - continue-on-error: true runs-on: dsh-windows-2025-32core - name: windows node 24 / observational + name: windows node 24 / complete env: # Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on # this image. The outer scheduler still overlaps lint with the other gates. @@ -670,9 +666,9 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Run observational Windows gates concurrently + - name: Run blocking and observational Windows gates concurrently shell: pwsh - run: pnpm run check:ci:windows-observational + run: pnpm run check:ci:windows-complete # Manual reference runs deliberately avoid the optimized jobs above. # Each host executes the complete, unsharded primary Node aggregate with one @@ -1011,7 +1007,7 @@ jobs: DSH_GATE_CONCURRENCY: ${{ matrix.workers }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }} DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }} - run: pnpm run check:ci:large-runner + run: pnpm run check:ci - name: Run blocking Windows builds concurrently if: matrix.platform == 'windows' @@ -1023,8 +1019,9 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`; explicitly observational jobs such as windows-gates stay out - # (`needs` cannot reach across workflow files; e2e.yml stays its own check). + # `needs`; observational Windows gates share the required Windows job but are + # marked non-blocking inside run-gates. (`needs` cannot reach across workflow + # files; e2e.yml stays its own check.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including @@ -1032,7 +1029,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat, python-sdk, windows-build] + needs: [node-24, node-24-site, node-compat, python-sdk, windows] if: always() && (github.event_name != 'workflow_dispatch' || inputs.suite == 'optimized-larger-runners') steps: - name: Fail if any needed job did not succeed diff --git a/package.json b/package.json index c3aaa3c29d..c9e5e01a71 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", + "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "check:pre-push": "tsx scripts/run-gates.ts pre-push", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 80c9c9bc5c..11155551db 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -22,6 +22,7 @@ type Mode = | 'ci-snapshot' | 'ci-artifacts' | 'ci-windows-blocking' + | 'ci-windows-complete' | 'ci-windows-observational' | 'node-compat' | 'pre-push' @@ -38,6 +39,7 @@ interface Gate { env?: Record input?: string verify?: (result: GateResult) => Promise + allowFailure?: boolean } interface GateResult { @@ -83,7 +85,9 @@ console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcur const results = await runGates(gates, maxConcurrency) printSummary(results, performance.now() - startedAt) -if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1) +if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) { + process.exit(1) +} function parseMode(raw: string | undefined): Mode { switch (raw) { @@ -95,6 +99,7 @@ function parseMode(raw: string | undefined): Mode { case 'ci-snapshot': case 'ci-artifacts': case 'ci-windows-blocking': + case 'ci-windows-complete': case 'ci-windows-observational': case 'node-compat': case 'pre-push': @@ -102,7 +107,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-primary-large-runner | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -189,6 +194,8 @@ function gatesForMode(selected: Mode): Gate[] { return ciArtifactGates() case 'ci-windows-blocking': return ciWindowsBlockingGates() + case 'ci-windows-complete': + return ciWindowsCompleteGates() case 'ci-windows-observational': return ciWindowsObservationalGates() case 'node-compat': @@ -254,6 +261,7 @@ function ciPrimaryLargeRunnerGates(): Gate[] { // Typecheck does not consume build output, so a large runner can start both // together while snapshot and artifact consumers still wait for the build. return ciPrimaryGates() + .filter(gate => gate.id !== 'docs-site-build') .map((gate) => { if (gate.id !== 'build') return gate const eagerBuild = { ...gate } @@ -306,6 +314,19 @@ function ciWindowsBlockingGates(): Gate[] { ] } +function ciWindowsCompleteGates(): Gate[] { + const observational = ciWindowsObservationalGates() + // The required production site replaces the observational MPA build; both + // VitePress modes write the same output directory and cannot overlap. + .filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build') + .map(gate => ({ ...gate, allowFailure: true })) + return [ + pnpmScript('build', 'build'), + pnpmScript('windows-site', 'docs:build', { label: 'production site' }), + ...observational, + ] +} + function ciWindowsObservationalGates(): Gate[] { return [ ...ciStaticGates(), @@ -638,7 +659,8 @@ function printSummary(results: GateResult[], durationMs: number): void { for (const result of unsuccessful) { const duration = (result.durationMs / 1000).toFixed(2) const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`) - console.error(` - ${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) + const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' + console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) console.error(` ${result.gate.displayCommand}`) } }