diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md index 22d3b1ad5c..f80568510e 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -14,11 +14,11 @@ The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, [CI](../../../../.github/workflows/ci.yml) bounds every non-Windows job to one minute and every Windows job to three minutes. The timeout is an executable regression ceiling; the lane design leaves headroom below it rather than treating a timeout as normal control flow. -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) remains the common bounded scheduler, but GitHub supplies explicit shard names for the expensive gate families. [scripts/static-shards.ts](../../../../scripts/static-shards.ts) partitions static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build lanes and rejects a missing or duplicate gate assignment. Lint uses disjoint package-source, package-test, and repository-complement lanes; the complement still starts from `.` so a new top-level lint target cannot disappear between shards, and it owns the single cross-file duplication run. [scripts/coverage-shards.ts](../../../../scripts/coverage-shards.ts) assigns every workspace package to exactly one source-coverage lane; its test expands the live package tree, so a new package makes CI red until it has an owner. Each coverage lane includes only its owned source files, repeats the exhaustive companion topology test, and runs without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. Snapshot replay uses one explicit multi-file lane and four scenario partitions of the large ACP file. [scripts/snapshot-shards.ts](../../../../scripts/snapshot-shards.ts) owns that inventory, and its test discovers every file admitted by the snapshot config so a new file cannot land outside CI. Each snapshot job builds the shipped runtime while its Linux runner installs bubblewrap from the hosted image's existing package index, then runs only its assigned replay surface. CI explicitly retains the suite's bounded concurrency of five subprocesses instead of clamping it to the runner's two logical CPUs, because replay spends most of its time waiting on child protocol I/O. Fixture guards still inspect the complete ACP scenario table in every partition. Static, lint, coverage, and snapshot sharding changes only GitHub scheduling: the ordinary local package scripts still run their complete suites. -Cold standalone documentation typechecking rebuilds the complete project-reference graph, so the API-contract lane now builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. +Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation inside the one-minute budget; the blocking Windows build also produces the normal production SPA, preserving the shipped-site check under its three-minute budget. Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. @@ -26,7 +26,7 @@ Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. -The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. +The workflow caches the pnpm store, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation, catalog, and prose gates behind one shared runner setup; only scheduling differs from the Linux partitions. Windows build remains blocking; the wider Windows static, lint, and artifact matrix remains observational while carrying the same three-minute ceiling. ## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c98781cc6..5abe8a7371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,10 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' static_shard: foundation + - lane: static-doc-types + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: doc-types - lane: static-api-contracts command: pnpm run check:ci:static gate_concurrency: '4' @@ -337,36 +341,40 @@ jobs: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} DSH_ARTIFACT_SHARD: ${{ matrix.artifact_shard }} + DSH_LINT_SHARD: ${{ matrix.lint_shard }} DSH_STATIC_SHARD: ${{ matrix.static_shard }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false matrix: include: - - lane: static-foundation + - lane: static-general command: pnpm run check:ci:static gate_concurrency: '4' - static_shard: foundation + static_shard: 'foundation,catalogs,prose' + - lane: static-doc-types + command: pnpm run check:ci:static + gate_concurrency: '1' + static_shard: doc-types - lane: static-api-contracts command: pnpm run check:ci:static gate_concurrency: '4' static_shard: api-contracts - - lane: static-catalogs - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: catalogs - - lane: static-prose - command: pnpm run check:ci:static - gate_concurrency: '4' - static_shard: prose - - lane: static-site-projection - command: pnpm run check:ci:static - gate_concurrency: '1' - static_shard: site-projection - - lane: lint + - lane: lint-package-sources command: pnpm run check:ci:lint gate_concurrency: '1' eslint_cache: '1' + lint_shard: package-sources + - lane: lint-package-tests + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: package-tests + - lane: lint-repository + command: pnpm run check:ci:lint + gate_concurrency: '1' + eslint_cache: '1' + lint_shard: repository - lane: artifacts-metadata command: pnpm run check:ci:artifacts gate_concurrency: '3' @@ -410,7 +418,7 @@ jobs: run: pnpm install --frozen-lockfile - uses: actions/cache@v4 - if: matrix.lane == 'lint' + if: startsWith(matrix.lane, 'lint-') with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} diff --git a/scripts/static-shards.spec.ts b/scripts/static-shards.spec.ts index 2c57be6dd5..df9e2922f1 100644 --- a/scripts/static-shards.spec.ts +++ b/scripts/static-shards.spec.ts @@ -8,9 +8,19 @@ describe('static gate shards', () => { expect(selectStaticGates(completeInventory, shard.name).map(gate => gate.id)).toEqual(shard.gateIds) }) + it('selects multiple lanes in gate inventory order', () => { + const selectedNames = new Set(['foundation', 'catalogs', 'prose']) + const expected = staticShards + .filter(shard => selectedNames.has(shard.name)) + .flatMap(shard => shard.gateIds) + expect(selectStaticGates(completeInventory, 'foundation,catalogs,prose').map(gate => gate.id)).toEqual(expected) + }) + it('rejects missing, duplicate, and unknown assignments', () => { expect(() => selectStaticGates(completeInventory.slice(1))).toThrow('assign every static gate exactly once') expect(() => selectStaticGates([...completeInventory, completeInventory[0]!])).toThrow('static gate IDs must be unique') expect(() => selectStaticGates(completeInventory, 'missing')).toThrow('unknown DSH_STATIC_SHARD') + expect(() => selectStaticGates(completeInventory, 'foundation,foundation')).toThrow('nonempty and unique') + expect(() => selectStaticGates(completeInventory, 'foundation,,prose')).toThrow('nonempty and unique') }) }) diff --git a/scripts/static-shards.ts b/scripts/static-shards.ts index 4f495b5fda..3476099993 100644 --- a/scripts/static-shards.ts +++ b/scripts/static-shards.ts @@ -21,9 +21,10 @@ export const staticShards = [ 'knip', ], }, + { name: 'doc-types', gateIds: ['build', 'doc-typecheck'] }, { name: 'api-contracts', - gateIds: ['build', 'doc-typecheck', 'cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], + gateIds: ['cordis-api', 'export-jsdoc', 'scoped-events', 'type-equivalence'], }, { name: 'catalogs', @@ -54,8 +55,8 @@ export const staticShards = [ * Validate the complete gate partition and optionally select one lane. * * @param gates Complete static gate inventory. - * @param name Optional stable shard name. - * @returns All gates when no shard is requested, otherwise the selected lane. + * @param name Optional comma-separated stable shard names. + * @returns All gates when no shard is requested, otherwise the selected lanes in inventory order. */ export function selectStaticGates(gates: readonly T[], name?: string): T[] { const gateIds = gates.map(gate => gate.id) @@ -71,8 +72,15 @@ export function selectStaticGates(gates: readonly T[], } if (name === undefined || name === '') return [...gates] - const shard = staticShards.find(candidate => candidate.name === name) - if (shard === undefined) throw new Error(`run-gates: unknown DSH_STATIC_SHARD ${JSON.stringify(name)}.`) - const selectedIds = new Set(shard.gateIds) + const shardNames = name.split(',') + if (shardNames.some(shardName => shardName === '') || new Set(shardNames).size !== shardNames.length) { + throw new Error(`run-gates: DSH_STATIC_SHARD names must be nonempty and unique, got ${JSON.stringify(name)}.`) + } + const selectedShards = shardNames.map((shardName) => { + const shard = staticShards.find(candidate => candidate.name === shardName) + if (shard === undefined) throw new Error(`run-gates: unknown DSH_STATIC_SHARD ${JSON.stringify(shardName)}.`) + return shard + }) + const selectedIds = new Set(selectedShards.flatMap(shard => shard.gateIds)) return gates.filter(gate => selectedIds.has(gate.id)) }