diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e741b3e87e..731c0c5bd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,30 @@ env: jobs: node-24: runs-on: ubuntu-latest - name: node 24 + name: node 24 / ${{ matrix.lane }} env: - DSH_GATE_CONCURRENCY: '8' - DSH_PUBLINT_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} + DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + strategy: + fail-fast: false + matrix: + include: + - lane: static + command: pnpm run check:ci:static + gate_concurrency: '4' + publint_concurrency: '8' + - lane: coverage + command: pnpm run check:ci:coverage + gate_concurrency: '1' + publint_concurrency: '8' + - lane: snapshot + command: pnpm run check:ci:snapshot + gate_concurrency: '1' + publint_concurrency: '8' + - lane: artifacts + command: pnpm run check:ci:artifacts + gate_concurrency: '3' + publint_concurrency: '8' steps: - uses: actions/checkout@v6 @@ -47,7 +67,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run gates - run: pnpm run check:ci + run: ${{ matrix.command }} node-compat: runs-on: ubuntu-latest diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d7102a995c..0dd14a46a5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -108,5 +108,5 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} DEEPSEEK_BASE_URL: https://api.deepseek.com - DSH_E2E_MAX_WORKERS: 8 + DSH_E2E_MAX_WORKERS: 12 run: pnpm run test:e2e diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b802c0a9d9..b6c42549d8 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: 5413785188c777d8c9cbff13549d7eda89a4e9c4 -development.zh.md: 92377bd818d91400e9c4965854532e16c99f51f3 +development.md: b86ac718837a0937f3ba880c46b8ac6860adeda4 +development.zh.md: a75ba2a04dc86394d3e0fbb747eb352c4b568c0e diff --git a/docs/development.md b/docs/development.md index 5413785188..b86ac71883 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,9 +67,9 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou ## CI gates -The keyless GitHub workflow has two jobs: the Node 24 primary job runs `pnpm run check:ci`, and the Node 26 compatibility job runs `pnpm run check:node-compat`. The primary scheduler fans 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 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. -`pnpm run build` waits for typecheck, 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=8`. +`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`. ## Daily commands diff --git a/docs/development.zh.md b/docs/development.zh.md index 92377bd818..a75ba2a04d 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -67,9 +67,9 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v ## CI 门禁 -keyless GitHub 工作流有两个 job:Node 24 主质量 job 运行 `pnpm run check:ci`,Node 26 兼容性 job 运行 `pnpm run check:node-compat`。主调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。 +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 冒烟测试。 -`pnpm run build` 等待 typecheck 完成,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=8`。 +`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 0cfea8f8ae..7aba6a09ea 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,15 +10,15 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and ## Decision -[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to two jobs. The Node 24 primary quality job installs once and runs `pnpm run check:ci`; 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 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`. -`pnpm run check:ci` delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). It fans out source-only gates from the package-script vocabulary: constraints, typecheck, lint, coverage, snapshot replay, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside one GitHub 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, 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. -Generated `.sessions/` logs 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 coverage and snapshot replay remain the critical path. +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. -Build output is produced once inside that Node 24 job. `build` waits for `typecheck` so concurrent `tsc -b` invocations do not share incremental state, and the artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`. The CI coverage reporter is text-only while local coverage keeps the HTML report. +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. -Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow also uses the shared `vitest.e2e.config.ts` bounded file pool (`DSH_E2E_MAX_WORKERS=8` in CI), so its speedup comes from dependency-cache reuse plus lower-level test-file fan-out instead of a separate GitHub job split. +Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow also uses the shared `vitest.e2e.config.ts` bounded file pool (`DSH_E2E_MAX_WORKERS=12` in CI), so its speedup comes from dependency-cache reuse plus lower-level test-file fan-out instead of a separate GitHub job split. ## Alternatives considered @@ -30,8 +30,10 @@ Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e ## Consequences -PR feedback arrives as a few GitHub checks with structured per-gate log blocks inside the primary job. That keeps runner setup overhead low and the Actions UI compact, at the cost of losing one status check per leaf gate. +PR feedback arrives as a few GitHub checks with structured per-gate log blocks inside each broad job. That keeps runner setup overhead bounded and the Actions UI compact, at the cost of losing one status check per leaf gate. + +The broad-lane split repeats checkout, setup, and install more often than a single primary job. That setup cost is intentional: on GitHub's hosted runner, running lint, coverage, and snapshot replay in one process pool oversubscribes CPU badly enough that the single-job critical path is longer than the repeated setup. The split introduces a maintenance obligation: when `package.json` adds or removes a gate that belongs in CI, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) needs the matching leaf. That obligation is intentional because the runner is the parallel execution plan for the same gate vocabulary, not a separate quality policy. -The Node 26 signal is narrower than the primary Node 24 signal. It proves the source graph and unit suite on the newer runtime without doubling documentation, coverage, publication, and smoke checks whose failures are not expected to vary by Node minor version. +The Node 26 signal is narrower than the primary Node 24 signal. It proves the source graph on the newer runtime without doubling documentation, coverage, publication, snapshot, and smoke checks whose failures are not expected to vary by Node minor version. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index 510287ed6d..445083faa8 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS ### Scope, runtime shape -Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the `engines` floor): these tests exercise *API integration*, not node-version compat, which ci.yml's Node 24/26 jobs already own; a second Node version would double real-API calls for no added signal. `vitest.e2e.config.ts` runs files through a bounded worker pool (`DSH_E2E_MAX_WORKERS`, default `4`, CI value `8`) so CI and local with-key runs parallelize independent files while retaining a one-line serial escape hatch for quota investigations. `timeout-minutes: 45` bounds a wedged run given 120s/test and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled. +Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the `engines` floor): these tests exercise *API integration*, not node-version compat, which ci.yml's Node 24/26 jobs already own; a second Node version would double real-API calls for no added signal. `vitest.e2e.config.ts` runs files through a bounded worker pool (`DSH_E2E_MAX_WORKERS`, default `4`, CI value `12`) so CI and local with-key runs parallelize independent files while retaining a one-line serial escape hatch for quota investigations. `timeout-minutes: 45` bounds a wedged run given 120s/test and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled. ## Security diff --git a/package.json b/package.json index 6bd27a706e..264091bcc2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "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: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", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "check:pre-push": "tsx scripts/run-gates.ts pre-push", "knip": "knip --treat-config-hints-as-errors", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 0a2c5e4354..9cc5a83048 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -10,7 +10,14 @@ import { availableParallelism } from 'node:os' import { join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' -type Mode = 'ci-primary' | 'node-compat' | 'pre-push' +type Mode = + | 'ci-primary' + | 'ci-static' + | 'ci-coverage' + | 'ci-snapshot' + | 'ci-artifacts' + | 'node-compat' + | 'pre-push' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' interface Gate { @@ -55,11 +62,17 @@ if (results.some(result => result.status === 'failed' || result.status === 'skip function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': + case 'ci-static': + case 'ci-coverage': + case 'ci-snapshot': + case 'ci-artifacts': case 'node-compat': case 'pre-push': return raw default: - throw new Error(`run-gates: expected mode ci-primary | node-compat | pre-push, got ${JSON.stringify(raw)}.`) + 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)}.`, + ) } } @@ -109,10 +122,21 @@ function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() + case 'ci-static': + return ciStaticGates() + case 'ci-coverage': + return [ + pnpmScript('coverage', 'test:coverage'), + ] + case 'ci-snapshot': + return [ + pnpmScript('snapshot', 'test:snapshot'), + ] + case 'ci-artifacts': + return ciArtifactGates() case 'node-compat': return [ pnpmScript('typecheck', 'typecheck'), - pnpmScript('test', 'test'), ] case 'pre-push': return [ @@ -149,6 +173,32 @@ function ciPrimaryGates(): Gate[] { ] } +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'] }), + ...docSyncLeafGates(), + pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), + pnpmScript('knip', 'knip'), + ] +} + +function ciArtifactGates(): Gate[] { + return [ + pnpmScript('build', 'build'), + pnpmScript('publint', 'publint', { needs: ['build'] }), + pnpmScript('node-next-types', 'verify-node-next-types', { + label: 'node-next types', + needs: ['build'], + }), + builtBinSmokeGate(), + ] +} + function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds } return [