diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 731c0c5bd4..2a17aa8b77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} strategy: fail-fast: false matrix: @@ -30,18 +31,27 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' publint_concurrency: '8' + coverage_max_workers: '' + - lane: lint + command: pnpm run check:ci:lint + gate_concurrency: '1' + publint_concurrency: '8' + coverage_max_workers: '' - lane: coverage command: pnpm run check:ci:coverage gate_concurrency: '1' publint_concurrency: '8' + coverage_max_workers: '4' - lane: snapshot command: pnpm run check:ci:snapshot gate_concurrency: '1' publint_concurrency: '8' + coverage_max_workers: '' - lane: artifacts command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' + coverage_max_workers: '' steps: - uses: actions/checkout@v6 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b6c42549d8..1de17c1c92 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: b86ac718837a0937f3ba880c46b8ac6860adeda4 -development.zh.md: a75ba2a04dc86394d3e0fbb747eb352c4b568c0e +development.md: 376d6a5740f01358daca407c7b8ab5e1a636417a +development.zh.md: 2f56aab8bdb1914668574d37279ef817443f1a30 diff --git a/docs/development.md b/docs/development.md index b86ac71883..376d6a5740 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,7 +67,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou ## CI gates -The keyless GitHub workflow has five jobs: four Node 24 lanes run static gates, coverage, snapshot replay, and artifact gates separately, and the Node 26 compatibility job runs `pnpm run check:node-compat`. The lane schedulers fan out independent gates from `package.json`: constraints, typecheck, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test. +The keyless GitHub workflow has six jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and the Node 26 compatibility job runs `pnpm run check:node-compat`. The lane schedulers fan out independent gates from `package.json`: constraints, typecheck, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test. `pnpm run build` feeds the artifact lane, and `publint`, `verify-node-next-types`, and built-bin smoke tests wait for build output. The separate real-API workflow runs `pnpm run test:e2e` with a secret and `DSH_E2E_MAX_WORKERS=12`. diff --git a/docs/development.zh.md b/docs/development.zh.md index a75ba2a04d..2f56aab8bd 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -67,7 +67,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v ## CI 门禁 -keyless GitHub 工作流有五个 job:四个 Node 24 lane 分别运行 static gates、coverage、snapshot replay 和 artifact gates,Node 26 兼容性 job 运行 `pnpm run check:node-compat`。各 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。 +keyless GitHub 工作流有六个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,Node 26 兼容性 job 运行 `pnpm run check:node-compat`。各 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。 `pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=12`。 diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md index 7aba6a09ea..759fd6d903 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -10,11 +10,11 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and ## Decision -[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has four lanes: static gates (`pnpm run check:ci:static`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The Node 26 compatibility job installs once and runs `pnpm run check:node-compat`. +[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has five lanes: static gates (`pnpm run check:ci:static`), lint (`pnpm run check:ci:lint`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The Node 26 compatibility job installs once and runs `pnpm run check:node-compat`. -Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, typecheck, lint, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the coverage and snapshot lanes isolate the two heaviest test gates so they do not starve each other on GitHub's hosted runner; the artifact lane builds once and then fans out the artifact consumers. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log. +Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, typecheck, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs ESLint with its own Node heap cap; the coverage lane runs Vitest coverage with bounded file workers (`DSH_COVERAGE_MAX_WORKERS`); the snapshot lane isolates replay; the artifact lane builds once and then fans out the artifact consumers. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log. -Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint, and the demo smoke waits for lint before creating and removing its session log. That dependency avoids racing ESLint's directory walk while the source-only gates still overlap. +Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane. Build output is produced once inside the Node 24 artifact lane. The artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`, so there is no upload/download handoff and no consumer can race ahead of declarations or bundles. The CI coverage reporter is text-only while local coverage keeps the HTML report. diff --git a/package.json b/package.json index 264091bcc2..19758d9d38 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "check:ci": "tsx scripts/run-gates.ts ci-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", + "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9cc5a83048..8fba4a6681 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -13,6 +13,7 @@ import { performance } from 'node:perf_hooks' type Mode = | 'ci-primary' | 'ci-static' + | 'ci-lint' | 'ci-coverage' | 'ci-snapshot' | 'ci-artifacts' @@ -63,6 +64,7 @@ function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': case 'ci-static': + case 'ci-lint': case 'ci-coverage': case 'ci-snapshot': case 'ci-artifacts': @@ -71,7 +73,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`, ) } } @@ -124,9 +126,13 @@ function gatesForMode(selected: Mode): Gate[] { return ciPrimaryGates() case 'ci-static': return ciStaticGates() + case 'ci-lint': + return [ + lintGate(), + ] case 'ci-coverage': return [ - pnpmScript('coverage', 'test:coverage'), + coverageGate(), ] case 'ci-snapshot': return [ @@ -154,10 +160,8 @@ function ciPrimaryGates(): Gate[] { return [ pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), - pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, - }), - pnpmScript('coverage', 'test:coverage'), + lintGate(), + coverageGate(), pnpmScript('snapshot', 'test:snapshot'), demoSmokeGate({ needs: ['lint'] }), ...docSyncLeafGates(), @@ -177,10 +181,7 @@ function ciStaticGates(): Gate[] { return [ pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), - pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, - }), - demoSmokeGate({ needs: ['lint'] }), + demoSmokeGate(), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), @@ -199,6 +200,33 @@ function ciArtifactGates(): Gate[] { ] } +function lintGate(): Gate { + return pnpmScript('lint', 'lint', { + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + }) +} + +function coverageGate(): Gate { + return pnpmExec('coverage', [ + 'vitest', + 'run', + '--coverage', + ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), + ], { + label: 'test:coverage', + }) +} + +function positiveIntArg(envName: string, flag: string): string[] { + const raw = process.env[envName] + if (raw === undefined || raw === '') return [] + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`run-gates: ${envName} must be a positive integer, got ${JSON.stringify(raw)}.`) + } + return [`${flag}=${raw}`] +} + function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds } return [