From 80358aed9f020b30d978a24d99ee6aad8f0100a5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:15:19 +0800 Subject: [PATCH 01/20] ci: parallelize github workflows --- .github/workflows/ci.yml | 300 +++++++++++++++++++++++++++++--------- .github/workflows/e2e.yml | 11 ++ 2 files changed, 239 insertions(+), 72 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4212717339..f8b7d4f41b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,226 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + jobs: - checks: + quality: runs-on: ubuntu-latest + name: quality / ${{ matrix.name }} strategy: fail-fast: false matrix: - node: [24, 26] - name: node ${{ matrix.node }} + include: + - name: constraints + command: pnpm run constraints + - name: typecheck + command: pnpm run typecheck + - name: lint + command: | + # Root typecheck validates the package/vendor reference graph and + # refreshes TSC intermediates before type-aware ESLint loads the + # project graph. + pnpm run typecheck + NODE_OPTIONS=--max-old-space-size=8192 pnpm run lint + - name: coverage + command: pnpm run test:coverage + - name: snapshots + command: pnpm run test:snapshot + - name: demo smoke + command: | + set -euo pipefail + out=$(printf 'echo ci smoke\n' | timeout 60 pnpm run demo:echo 2>&1) + echo "$out" + echo "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' + echo "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' + # The JSONL backend (root ./.sessions, no cwd -> _no-cwd bucket) + # writes a per-run session log named main-session-.jsonl. + ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null + rm -rf .sessions + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: 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: Run ${{ matrix.name }} + run: ${{ matrix.command }} + + docs: + runs-on: ubuntu-latest + name: docs / ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: doc-typecheck + command: pnpm run doc-typecheck + - name: cordis catalog + command: pnpm run verify-cordis-catalog + - name: tool catalog + command: pnpm run verify-tool-catalog + - name: persistence catalog + command: pnpm run verify-persistence-catalog + - name: doc graphs + command: pnpm run verify-doc-graphs + - name: markdown wrap + command: pnpm run verify-md-wrap + - name: markdown links + command: pnpm run verify-md-links + - name: doc refs + command: pnpm run verify-doc-refs + - name: package paths + command: pnpm run verify-package-paths + - name: mermaid + command: pnpm run verify-mermaid + - name: rfc classification + command: pnpm run verify-rfc-classification + - name: type equivalence + command: pnpm run verify-type-equiv + - name: translation pairing + command: pnpm run verify-translation-pairing + - name: doc budgets + command: pnpm run verify-doc-budgets + - name: module graph + command: pnpm run verify-module-graph + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: 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: Run ${{ matrix.name }} + run: ${{ matrix.command }} + + build: + runs-on: ubuntu-latest + name: build + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: 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 (tsc -b + tsdown bundles) + run: pnpm run build + + - uses: actions/upload-artifact@v4 + with: + name: build-output + path: | + packages/*/*/lib + vendor/*/lib + if-no-files-found: error + retention-days: 1 + + artifact-gate: + runs-on: ubuntu-latest + name: artifact / ${{ matrix.name }} + needs: build + strategy: + fail-fast: false + matrix: + include: + - name: hygiene + command: pnpm run hygiene + - name: built-bin smoke + command: pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: 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 + + - uses: actions/download-artifact@v4 + with: + name: build-output + + - name: Run ${{ matrix.name }} + run: ${{ matrix.command }} + + node-compat: + runs-on: ubuntu-latest + name: node ${{ matrix.node }} compatibility + strategy: + fail-fast: false + matrix: + node: [26] steps: - uses: actions/checkout@v6 @@ -27,78 +239,22 @@ jobs: - name: Enable corepack (pnpm) run: corepack enable + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node }}-pnpm- + - name: Install (immutable) run: pnpm install --frozen-lockfile - - name: Constraints - run: pnpm run constraints - - # Before lint: root typecheck validates the package/vendor reference graph - # and refreshes TSC intermediates so type-aware ESLint sees the same project - # boundaries as the build. - name: Typecheck (src + tests + examples) run: pnpm run typecheck - # Type-aware ESLint loads every package tsconfig through the project - # service and peaks at ~3.4GB; the default V8 old-space ceiling (~2GB) - # OOMs it (exit 134). Raise the ceiling well above the peak. - - name: Lint - run: pnpm run lint - env: - NODE_OPTIONS: --max-old-space-size=8192 - - # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the - # fenced ts blocks against the root project-reference graph. The cordis - # catalog freshness check, type-equiv check, Mermaid syntax check, and - # markdown wrap/link checks only read source. Same `doc-sync` script the pre-push hook runs - # (quality-gates RFC: one source of truth). - - name: Doc-sync gates (doc code blocks + catalogs + mermaid + markdown) - run: pnpm run doc-sync - - # Module-graph freshness: regenerate docs/module-graph.md from the - # packages' peerDependencies and fail if it differs from the committed - # file. Only reads source package.json — no build needed. - - name: Module-graph freshness - run: pnpm run verify-module-graph - - - name: Tests with coverage gate (per-file 100%) - run: pnpm run test:coverage - - # ACP snapshot tests (acp-snapshot-tests RFC): boot the real acp-agent - # subprocess and replay recorded session-log fixtures, diffing the - # normalized stdout transcript + re-persisted log against committed - # goldens. KEYLESS by design — the same `test:snapshot` script the pre-push - # hook runs (one source of truth), so the full-transcript regression net - # is part of every PR gate, not just local pre-push. - - name: Snapshot tests (ACP transcript replay) - run: pnpm run test:snapshot - - # Before hygiene: publint validates the packed artifacts (lib/index.js), - # which only the tsdown bundling step emits, and verify-node-next-types - # validates the built declarations. - - name: Build (tsc -b + tsdown bundles) - run: pnpm run build - - - name: Hygiene (knip + publint + constraints + NodeNext types) - run: pnpm run hygiene - - - name: Demo smoke test - run: | - set -euo pipefail - out=$(printf 'echo ci smoke\n' | timeout 60 pnpm run demo:echo 2>&1) - echo "$out" - echo "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' - echo "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' - # The JSONL backend (root ./.sessions, no cwd → _no-cwd bucket) writes a - # per-run session log named main-session-.jsonl. Assert one exists. - ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null - rm -rf .sessions - - # The published `bin` is `lib/bin.js`, run under plain `node` by a real - # consumer — NOT the tsx dev path the demo smoke and demo:* scripts use. - # These keyless smokes boot the BUILT bins (this step runs AFTER the build) - # in a temp dir that mirrors a real install, catching a regression in the - # published artifact that tsx would mask. They self-skip if lib/ is absent, - # so the e2e job (which does not build) does not run them. - - name: Built-bin smoke test (published lib/bin.js under node) - run: pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts + - name: Unit tests + run: pnpm run test diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7a8c9dc88c..ace789915b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -67,6 +67,17 @@ jobs: - name: Enable corepack (pnpm) run: corepack enable + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-24-pnpm- + - name: Install (immutable) run: pnpm install --frozen-lockfile From 72c83c771a52186a4971f09b5863078d7e318ae4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:51:50 +0800 Subject: [PATCH 02/20] chore: parallelize pre-push gates --- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- lefthook.yml | 58 ++++++++++++++++++-- scripts/publint-all.ts | 106 +++++++++++++++++++++++++++++++++---- 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index cef11b8352..9de851345c 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: f032764fff29baaca007211db8b69d9a5129078f -development.zh.md: 3a650d03ce7cafd0e34290ae918e5a303c2ad8a9 +development.md: 2c39e0c531638eb0fa3561ec32e6d5c00d87a8be +development.zh.md: 6c9c2a5480db3b71a2d9e25e89c6afd34d24fdb0 diff --git a/docs/development.md b/docs/development.md index f032764fff..2c39e0c531 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as an early local checkpoint before review: - `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard. -- `pre-push` runs `pnpm run test`, `pnpm run test:snapshot`, `pnpm run hygiene`, `pnpm run doc-sync`, and `pnpm run verify-module-graph`. +- `pre-push` runs unit tests, snapshot tests, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` as parallel lefthook jobs. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. diff --git a/docs/development.zh.md b/docs/development.zh.md index 3a650d03ce..6c9c2a5480 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点: - `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest 守卫。 -- `pre-push` 运行 `pnpm run test`、`pnpm run test:snapshot`、`pnpm run hygiene`、`pnpm run doc-sync` 和 `pnpm run verify-module-graph`。 +- `pre-push` 将单元测试、快照测试、module graph 新鲜度,以及 `pnpm run hygiene` 和 `pnpm run doc-sync` 的成员门禁拆成并行 lefthook job 运行。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。编辑 vendor 代码前先看 `vendor/README.md`。 diff --git a/lefthook.yml b/lefthook.yml index 2a255424fa..3cf1f5a034 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,11 +28,61 @@ pre-push: - name: snapshot run: pnpm run test:snapshot - - name: hygiene - run: pnpm run hygiene + # Flatten `pnpm run hygiene` and `pnpm run doc-sync` so lefthook can run + # their independent member gates concurrently before a push. + - name: knip + run: pnpm run knip - - name: doc-sync - run: pnpm run doc-sync + - name: publint + run: pnpm run publint + + - name: constraints + run: pnpm run constraints + + - name: node-next types + run: pnpm run verify-node-next-types + + - name: doc typecheck + run: pnpm run doc-typecheck + + - name: cordis catalog + run: pnpm run verify-cordis-catalog + + - name: tool catalog + run: pnpm run verify-tool-catalog + + - name: persistence catalog + run: pnpm run verify-persistence-catalog + + - name: doc graphs + run: pnpm run verify-doc-graphs + + - name: markdown wrap + run: pnpm run verify-md-wrap + + - name: markdown links + run: pnpm run verify-md-links + + - name: doc refs + run: pnpm run verify-doc-refs + + - name: package paths + run: pnpm run verify-package-paths + + - name: mermaid + run: pnpm run verify-mermaid + + - name: rfc classification + run: pnpm run verify-rfc-classification + + - name: type equivalence + run: pnpm run verify-type-equiv + + - name: translation pairing + run: pnpm run verify-translation-pairing + + - name: doc budgets + run: pnpm run verify-doc-budgets - name: module-graph freshness run: pnpm run verify-module-graph diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 0e06372c3f..126943c8dd 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -1,6 +1,11 @@ -import { execFileSync } from 'node:child_process' +import { execFile } from 'node:child_process' import { existsSync, readdirSync } from 'node:fs' +import { availableParallelism } from 'node:os' import { resolve } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' // publint every harness package. Packages live at packages// // (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private @@ -9,15 +14,94 @@ import { resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') const packagesRoot = resolve(root, 'packages') -const packages = readdirSync(packagesRoot, { withFileTypes: true }) - .filter(group => group.isDirectory()) - .flatMap(group => - readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) - .filter(pkg => pkg.isDirectory()) - .filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json'))) - .map(pkg => `packages/${group.name}/${pkg.name}`), - ) +type PublintResult = + | { path: string; status: 'passed'; stdout: string; stderr: string } + | { path: string; status: 'failed'; stdout: string; stderr: string; message: string } -for (const path of packages) { - execFileSync('node_modules/.bin/publint', [path], { cwd: root, stdio: 'inherit' }) +function workspacePackages(): string[] { + return readdirSync(packagesRoot, { withFileTypes: true }) + .filter(group => group.isDirectory()) + .flatMap(group => + readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) + .filter(pkg => pkg.isDirectory()) + .filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json'))) + .map(pkg => `packages/${group.name}/${pkg.name}`), + ) } + +function publintConcurrency(total: number): number { + if (total === 0) return 0 + + const raw = process.env[CONCURRENCY_ENV] + if (raw !== undefined) { + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`) + } + return Math.min(total, parsed) + } + + return Math.min(total, availableParallelism()) +} + +function outputText(value: unknown): string { + if (typeof value === 'string') return value + if (Buffer.isBuffer(value)) return value.toString() + return '' +} + +async function runPublint(path: string): Promise { + try { + const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], { + cwd: root, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }) + return { path, status: 'passed', stdout, stderr } + } catch (error: unknown) { + const failed = error as { stdout?: unknown; stderr?: unknown; message?: string } + return { + path, + status: 'failed', + stdout: outputText(failed.stdout), + stderr: outputText(failed.stderr), + message: failed.message ?? 'publint failed', + } + } +} + +async function runAll(paths: string[], concurrency: number): Promise { + let next = 0 + const results: Array = [] + await Promise.all(Array.from({ length: concurrency }, async () => { + for (;;) { + const index = next + next += 1 + const path = paths[index] + if (path === undefined) return + results[index] = await runPublint(path) + } + })) + + return paths.map((path, index) => { + const result = results[index] + if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`) + return result + }) +} + +function printResult(result: PublintResult): void { + console.log(`Running publint for ${result.path}...`) + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + if (result.status === 'failed') console.error(result.message) +} + +const packages = workspacePackages() +const concurrency = publintConcurrency(packages.length) +console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`) + +const results = await runAll(packages, concurrency) +for (const result of results) printResult(result) + +if (results.some(result => result.status === 'failed')) process.exit(1) From 08e09217bffce25eb444fd1a67fd0ada69aec1a3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:04:06 +0800 Subject: [PATCH 03/20] docs(rfc): record parallel GitHub CI gates --- .github/workflows/ci.yml | 2 ++ docs/rfc/INDEX.md | 1 + .../2026-07-06-parallel-github-ci-gates.md | 32 +++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8b7d4f41b..813fa71234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,8 @@ jobs: command: pnpm run verify-mermaid - name: rfc classification command: pnpm run verify-rfc-classification + - name: rfc format + command: pnpm run verify-rfc-format - name: type equivalence command: pnpm run verify-type-equiv - name: translation pairing diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 0c5a11a4f9..4c57c28475 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -141,6 +141,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 | | [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 | | [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 | +| [Parallel GitHub CI gates](implemented/process/2026-07-06-parallel-github-ci-gates.md) | 2026-07-06 | ### Testing 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 new file mode 100644 index 0000000000..3c3be0782f --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -0,0 +1,32 @@ +# RFC: Parallel GitHub CI gates + +Status: implemented + +## Problem + +The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered job makes the workflow wall clock equal the sum of those gates, and running the whole chain on multiple Node versions spends the same expensive repo-wide signal twice when the compatibility question is narrower than the full quality suite. + +The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and built-bin smoke tests need the built `lib/` outputs, while most gates only need source and dependencies. A blind fan-out either races those artifact consumers before `pnpm run build` has emitted declarations and bundles, or repeats the build in every artifact-dependent job. + +## Decision + +[CI](../../../../.github/workflows/ci.yml) uses Node 24 as the primary quality lane and fans out independent source-only work into matrix jobs. The `quality` matrix runs constraints, typecheck, lint, coverage, snapshot replay, and the echo-agent demo smoke independently. The `docs` matrix expands the `doc-sync` members into leaf jobs, including the RFC classification and format gates, so documentation failures report at the gate that failed instead of hiding behind one aggregate step. + +Build output is produced once by a `build` job and uploaded as a short-retention artifact. Artifact consumers run behind that boundary: the `artifact-gate` matrix downloads the built package tree and runs `pnpm run hygiene` plus the built-bin smoke tests. Node-version compatibility stays explicit but narrower: the Node 26 lane runs typecheck and unit tests, while the full quality/documentation/artifact surface runs on the package engine floor. + +Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow keeps its serial test execution because the e2e config deliberately disables file parallelism for API-quota stability; its speedup is dependency-cache reuse, not concurrent model calls. + +## Alternatives considered + +- **Keep the full serial chain in a Node matrix** - simplest to reason about, but it duplicates repo-wide gates that do not produce Node-version-specific signal and leaves every PR waiting for the sum of all gates. +- **Run every gate independently with no build artifact handoff** - maximizes fan-out, but the publication and built-bin checks are defined over built `lib/` outputs and would either fail, skip, or rebuild the same tree in several jobs. +- **Build inside every artifact-dependent job** - preserves correctness but shifts the bottleneck from the serial chain to repeated `tsc -b` and bundling work. +- **Parallelize real-API e2e files** - rejected because the e2e suite's Vitest config uses `fileParallelism: false` to stay within shared API-key quota and avoid rate-limit flakes. + +## Consequences + +PR feedback arrives as many smaller checks rather than one large status. That makes failures easier to localize and lets independent gates finish as soon as their own runner is done, at the cost of more GitHub job setup overhead and a larger workflow file. + +The split introduces a maintenance obligation: when `package.json` adds or removes a `doc-sync` member, the docs matrix needs the matching leaf job. That obligation is intentional because CI is now 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. From c0af59f80455a246ac830afe74fab5ff3e64eef3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:04:06 +0800 Subject: [PATCH 04/20] docs(rfc): record parallel pre-push gates --- docs/rfc/INDEX.md | 1 + .../2026-07-06-parallel-pre-push-gates.md | 32 +++++++++++++++++++ lefthook.yml | 3 ++ 3 files changed, 36 insertions(+) create mode 100644 docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 0c5a11a4f9..44e1ea0c11 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -141,6 +141,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 | | [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 | | [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 | +| [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md new file mode 100644 index 0000000000..f17cf7e558 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -0,0 +1,32 @@ +# RFC: Parallel pre-push gates + +Status: implemented + +## Problem + +The pre-push hook is the last local checkpoint before a branch leaves the machine, so its wall clock directly shapes whether contributors keep it enabled and trust its signal. Lefthook already runs top-level jobs in parallel, but aggregate jobs such as `pnpm run hygiene` and `pnpm run doc-sync` hide long sequential chains inside one job. The hook can therefore be configured as parallel while still waiting on serial subcommands whose members are independent. + +`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state. + +## Decision + +[lefthook.yml](../../../../lefthook.yml) expands the pre-push hook into leaf jobs for the unit suite, snapshot suite, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including RFC classification and RFC format, but lets lefthook schedule the independent checks concurrently and report each failure by its own job name. + +[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. + +The aggregate package scripts remain the source of truth for CI and ad hoc local runs. The hook is a parallel execution plan over their member gates, not a replacement vocabulary. + +## Alternatives considered + +- **Keep aggregate `hygiene` and `doc-sync` jobs in the hook** - simpler config, but it leaves most of the pre-push wall clock inside serial command chains that lefthook cannot see or schedule. +- **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about. +- **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added. +- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs. + +## Consequences + +The hook's critical path becomes the slowest real gate instead of the sum of hidden gate chains. Lefthook reports per-job timing for every leaf gate, so a slow local checkpoint points at the gate that actually dominates the run. + +The hook file is longer and must track the member list of `hygiene` and `doc-sync`. That duplication is acceptable because the purpose of the hook is scheduling, while `package.json` remains the command vocabulary that CI and humans call directly. + +`publint-all.ts` becomes asynchronous code and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning. diff --git a/lefthook.yml b/lefthook.yml index 3cf1f5a034..63d775ec9a 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -75,6 +75,9 @@ pre-push: - name: rfc classification run: pnpm run verify-rfc-classification + - name: rfc format + run: pnpm run verify-rfc-format + - name: type equivalence run: pnpm run verify-type-equiv From 6ea7d7af53138cb5b28a8bf8dff147382e3de4f8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:10:27 +0800 Subject: [PATCH 05/20] ci: parallelize real-api e2e files --- .github/workflows/e2e.yml | 5 ++-- .../2026-07-06-parallel-github-ci-gates.md | 4 ++-- .../testing/2026-06-19-real-api-e2e-ci.md | 4 ++-- examples/coding-agent/tests/compaction.e2e.ts | 2 +- examples/coding-agent/tests/full-loop.e2e.ts | 9 ++++++- examples/coding-agent/tests/todo-write.e2e.ts | 9 ++++++- vitest.e2e.config.ts | 24 +++++++++++++++---- 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ace789915b..934cbb8bda 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -54,8 +54,8 @@ jobs: if: >- github.event_name != 'pull_request' || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]') - # Serial files (fileParallelism: false), 120s/test, retry 2. 45m bounds a - # wedged run while leaving headroom for retry storms against a slow API. + # Bounded file parallelism (DSH_E2E_MAX_WORKERS), 120s/test, retry 2. 45m + # still bounds retry storms against a slow API while the happy path fans out. timeout-minutes: 45 steps: - uses: actions/checkout@v6 @@ -108,4 +108,5 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} DEEPSEEK_BASE_URL: https://api.deepseek.com + DSH_E2E_MAX_WORKERS: 4 run: pnpm run test:e2e 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 3c3be0782f..5a2621c1f9 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 @@ -14,14 +14,14 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and Build output is produced once by a `build` job and uploaded as a short-retention artifact. Artifact consumers run behind that boundary: the `artifact-gate` matrix downloads the built package tree and runs `pnpm run hygiene` plus the built-bin smoke tests. Node-version compatibility stays explicit but narrower: the Node 26 lane runs typecheck and unit tests, while the full quality/documentation/artifact surface runs on the package engine floor. -Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow keeps its serial test execution because the e2e config deliberately disables file parallelism for API-quota stability; its speedup is dependency-cache reuse, not concurrent model calls. +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=4` 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 - **Keep the full serial chain in a Node matrix** - simplest to reason about, but it duplicates repo-wide gates that do not produce Node-version-specific signal and leaves every PR waiting for the sum of all gates. - **Run every gate independently with no build artifact handoff** - maximizes fan-out, but the publication and built-bin checks are defined over built `lib/` outputs and would either fail, skip, or rebuild the same tree in several jobs. - **Build inside every artifact-dependent job** - preserves correctness but shifts the bottleneck from the serial chain to repeated `tsc -b` and bundling work. -- **Parallelize real-API e2e files** - rejected because the e2e suite's Vitest config uses `fileParallelism: false` to stay within shared API-key quota and avoid rate-limit flakes. +- **Use unbounded real-API e2e parallelism** - rejected because the suite includes many live model/tool scenarios; the worker pool needs an explicit `DSH_E2E_MAX_WORKERS` cap so CI and local runs can fan out without hiding quota or resource problems behind flaky rate-limit failures. ## Consequences 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 d23483eb88..b3812d10f7 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 @@ -20,7 +20,7 @@ ci.yml's value is that it is keyless, forkable, and always-green: any contributo ### Cost is not the constraint; reliability is -The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all six `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy. +The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all matching `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy. ### Triggers: trusted events only @@ -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 `[24, 26]` matrix already owns; a second Node version would double real-API calls for no added signal. `timeout-minutes: 45` bounds a wedged run given serial files (`fileParallelism: false`), 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 `[24, 26]` matrix already owns; 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 and CI value `4`) 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/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index cb7ce43811..f306e20e98 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -62,7 +62,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa maxTokens: 2048, compactionRetries: 1, }, - persistenceRoot: './.sessions', + persistenceRoot: join(workdir, '.sessions'), }) const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash' }) diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 095d2a42a1..8718139ced 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import { AgentId } from '@deepseek-ai/dsh-agent' @@ -9,6 +12,7 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness. */ let ctx: Context | undefined +let workdir: string | undefined afterEach(async () => { // Always dispose the harness, even on failure/retry/timeout: agent-loop @@ -16,11 +20,14 @@ afterEach(async () => { // process the model left behind. await ctx?.fiber.dispose() ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => { it('runs a bash command on request and reports its output', async () => { - ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT }) + workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-')) + ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/coding-agent/tests/todo-write.e2e.ts index b100091a0f..698fbd9e9e 100644 --- a/examples/coding-agent/tests/todo-write.e2e.ts +++ b/examples/coding-agent/tests/todo-write.e2e.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import { AgentId } from '@deepseek-ai/dsh-agent' @@ -10,15 +13,19 @@ import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts' */ let ctx: Context | undefined +let workdir: string | undefined afterEach(async () => { await ctx?.fiber.dispose() ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a plan', () => { it('appends a todo/write event with the model-produced task list', async () => { - ctx = await codingHarness(process.cwd(), { persona: TODO_SYSTEM_PROMPT }) + workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-')) + ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 63ccbb8a27..7283a6925d 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -19,6 +19,21 @@ try { // No .env — fine, the environment may already carry the variables. } +const DEFAULT_E2E_MAX_WORKERS = 4 + +function positiveIntFromEnv(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined || raw === '') return fallback + + const value = Number(raw) + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`) + } + return value +} + +const e2eMaxWorkers = positiveIntFromEnv('DSH_E2E_MAX_WORKERS', DEFAULT_E2E_MAX_WORKERS) + export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the root tsconfig paths map; the native option cannot do this. @@ -31,9 +46,10 @@ export default defineConfig({ testTimeout: 120_000, hookTimeout: 30_000, retry: 2, - // Run e2e files one at a time: the shared internal API key has a small - // concurrency quota, and parallel files issue enough simultaneous requests - // to trip it (manifesting as flaky rate-limit errors). - fileParallelism: false, + // Run files in a bounded pool: enough lower-level parallelism to keep CI + // and local with-key runs moving, while leaving a resource knob for shared + // API quotas (`DSH_E2E_MAX_WORKERS=1` restores serial execution). + fileParallelism: e2eMaxWorkers > 1, + maxWorkers: e2eMaxWorkers, }, }) From 44664551cfa83012ba1358851d67ef221bb951a9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:54:09 +0800 Subject: [PATCH 06/20] test: wait for ACP stdout frame under e2e parallelism --- examples/acp-agent/tests/acp.e2e.ts | 51 +++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 06ef962743..3245fe1750 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -92,6 +92,46 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn let spawned: Spawned | undefined let workdir: string | undefined +function hasStdoutLine(out: string[]): boolean { + return out.join('').split('\n').some(line => line.trim().length > 0) +} + +async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise { + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeout) + child.stdout.off('data', onData) + child.off('exit', onExit) + child.off('error', onError) + } + const pass = () => { + cleanup() + resolve() + } + const fail = (reason: string) => { + cleanup() + reject(new Error(`${reason}; stderr: ${stderr.join('')}`)) + } + const onData = () => { + if (hasStdoutLine(out)) pass() + } + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`) + } + const onError = (error: Error) => { + fail(`ACP child failed before emitting a stdout frame: ${error.message}`) + } + const timeout = setTimeout(() => { + fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`) + }, timeoutMs) + + child.stdout.on('data', onData) + child.on('exit', onExit) + child.on('error', onError) + onData() + }) +} + afterEach(async () => { if (spawned) { spawned.child.kill('SIGKILL') @@ -114,16 +154,21 @@ describe('acp-agent over real stdio (no key required)', () => { stdio: ['pipe', 'pipe', 'pipe'], }) const out: string[] = [] + const stderr: string[] = [] child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') child.stdout.on('data', (c: string) => out.push(c)) + child.stderr.on('data', (c: string) => stderr.push(c)) // Send a single initialize request as a newline-delimited JSON-RPC frame. const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } }) child.stdin.write(req + '\n') - // Give it a moment to boot + reply, then inspect stdout. - await new Promise(r => setTimeout(r, 4000)) - child.kill('SIGKILL') + try { + await waitForStdoutLine(child, out, stderr, 15_000) + } finally { + child.kill('SIGKILL') + } const lines = out.join('').split('\n').filter(l => l.trim().length > 0) expect(lines.length).toBeGreaterThan(0) From c64eca451c5c2af0699eec4d5b11bc57867897cc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:04:32 +0800 Subject: [PATCH 07/20] ci: ignore doc-typecheck temp dirs during lint --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 9884227855..cbb696bccb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,6 +22,7 @@ export default tseslint.config( '**/lib/**', '**/node_modules/**', '**/.sessions/**', + '**/.doc-typecheck-*/**', 'vendor/**', // vendored source keeps upstream style and idioms '**/*.js', '**/*.mjs', From 7cd486805627cfe097b841bab03fcd968229450e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:22:22 +0800 Subject: [PATCH 08/20] ci: split primary gates into broad lanes --- .github/workflows/ci.yml | 28 ++++++++-- .github/workflows/e2e.yml | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 4 +- docs/development.zh.md | 4 +- .../2026-07-06-parallel-github-ci-gates.md | 16 +++--- .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- package.json | 4 ++ scripts/run-gates.ts | 56 ++++++++++++++++++- 9 files changed, 98 insertions(+), 22 deletions(-) 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 [ From 2d80d868bed2aa4fa8df8d769e50894635b2a167 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:39:26 +0800 Subject: [PATCH 09/20] ci: isolate lint and tune coverage lane --- .github/workflows/ci.yml | 10 ++++ docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- .../2026-07-06-parallel-github-ci-gates.md | 6 +-- package.json | 1 + scripts/run-gates.ts | 48 +++++++++++++++---- 7 files changed, 56 insertions(+), 17 deletions(-) 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 [ From 5efc449af02417f34959d076c93336418eeb9e4a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:43:42 +0800 Subject: [PATCH 10/20] ci: keep static lane source-only --- .github/workflows/ci.yml | 2 +- .../implemented/process/2026-07-06-parallel-github-ci-gates.md | 2 +- scripts/run-gates.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a17aa8b77..ce6a316b3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: include: - lane: static command: pnpm run check:ci:static - gate_concurrency: '4' + gate_concurrency: '8' publint_concurrency: '8' coverage_max_workers: '' - lane: lint 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 759fd6d903..aaaa15909f 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 @@ -12,7 +12,7 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and [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, 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. +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, 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 Node 26 compatibility job owns the TypeScript typecheck. 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. 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. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 8fba4a6681..bacfb37b67 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -180,7 +180,6 @@ function ciPrimaryGates(): Gate[] { function ciStaticGates(): Gate[] { return [ pnpmScript('constraints', 'constraints'), - pnpmScript('typecheck', 'typecheck'), demoSmokeGate(), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), From 133be74b1552375cd390bc9e01a9da7333b95329 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:46:58 +0800 Subject: [PATCH 11/20] ci: tune static lane concurrency --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce6a316b3d..2a17aa8b77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: include: - lane: static command: pnpm run check:ci:static - gate_concurrency: '8' + gate_concurrency: '4' publint_concurrency: '8' coverage_max_workers: '' - lane: lint From 09326fd84ae550eb586a23168f6dbb395455332c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:56:23 +0800 Subject: [PATCH 12/20] ci: split lint lane internally --- .github/workflows/ci.yml | 2 +- .../2026-07-06-parallel-github-ci-gates.md | 2 +- scripts/run-gates.ts | 35 ++++++++++++++----- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a17aa8b77..de8c26a972 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: coverage_max_workers: '' - lane: lint command: pnpm run check:ci:lint - gate_concurrency: '1' + gate_concurrency: '2' publint_concurrency: '8' coverage_max_workers: '' - lane: coverage 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 aaaa15909f..cf7fb14af8 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 @@ -12,7 +12,7 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and [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, 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 Node 26 compatibility job owns the TypeScript typecheck. 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, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs source/script/demo files and tests as separate ESLint leaves with their 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 Node 26 compatibility job owns the TypeScript typecheck. 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. 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. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index bacfb37b67..72e6806a7e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -127,9 +127,7 @@ function gatesForMode(selected: Mode): Gate[] { case 'ci-static': return ciStaticGates() case 'ci-lint': - return [ - lintGate(), - ] + return lintGates() case 'ci-coverage': return [ coverageGate(), @@ -160,10 +158,10 @@ function ciPrimaryGates(): Gate[] { return [ pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), - lintGate(), + ...lintGates(), coverageGate(), pnpmScript('snapshot', 'test:snapshot'), - demoSmokeGate({ needs: ['lint'] }), + demoSmokeGate({ needs: ['lint-source', 'lint-tests'] }), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), @@ -199,10 +197,29 @@ function ciArtifactGates(): Gate[] { ] } -function lintGate(): Gate { - return pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, - }) +function lintGates(): Gate[] { + const env = { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') } + return [ + pnpmExec('lint-source', [ + 'eslint', + 'packages/*/*/src/**/*.ts', + 'examples/**/*.ts', + 'scripts/**/*.ts', + '--ignore-pattern', + 'examples/*/tests/**/*.ts', + ], { + label: 'lint source', + env, + }), + pnpmExec('lint-tests', [ + 'eslint', + 'packages/*/*/tests/**/*.ts', + 'examples/*/tests/**/*.ts', + ], { + label: 'lint tests', + env, + }), + ] } function coverageGate(): Gate { From d71c96a44cd88a4d6cf43438843f2e1767e46b0d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:59:50 +0800 Subject: [PATCH 13/20] ci: keep lint lane serial --- .github/workflows/ci.yml | 2 +- .../2026-07-06-parallel-github-ci-gates.md | 2 +- scripts/run-gates.ts | 35 +++++-------------- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8c26a972..2a17aa8b77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: coverage_max_workers: '' - lane: lint command: pnpm run check:ci:lint - gate_concurrency: '2' + gate_concurrency: '1' publint_concurrency: '8' coverage_max_workers: '' - lane: coverage 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 cf7fb14af8..aaaa15909f 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 @@ -12,7 +12,7 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and [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, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs source/script/demo files and tests as separate ESLint leaves with their 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 Node 26 compatibility job owns the TypeScript typecheck. 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, 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 Node 26 compatibility job owns the TypeScript typecheck. 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. 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. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 72e6806a7e..bacfb37b67 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -127,7 +127,9 @@ function gatesForMode(selected: Mode): Gate[] { case 'ci-static': return ciStaticGates() case 'ci-lint': - return lintGates() + return [ + lintGate(), + ] case 'ci-coverage': return [ coverageGate(), @@ -158,10 +160,10 @@ function ciPrimaryGates(): Gate[] { return [ pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), - ...lintGates(), + lintGate(), coverageGate(), pnpmScript('snapshot', 'test:snapshot'), - demoSmokeGate({ needs: ['lint-source', 'lint-tests'] }), + demoSmokeGate({ needs: ['lint'] }), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), @@ -197,29 +199,10 @@ function ciArtifactGates(): Gate[] { ] } -function lintGates(): Gate[] { - const env = { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') } - return [ - pnpmExec('lint-source', [ - 'eslint', - 'packages/*/*/src/**/*.ts', - 'examples/**/*.ts', - 'scripts/**/*.ts', - '--ignore-pattern', - 'examples/*/tests/**/*.ts', - ], { - label: 'lint source', - env, - }), - pnpmExec('lint-tests', [ - 'eslint', - 'packages/*/*/tests/**/*.ts', - 'examples/*/tests/**/*.ts', - ], { - label: 'lint tests', - env, - }), - ] +function lintGate(): Gate { + return pnpmScript('lint', 'lint', { + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + }) } function coverageGate(): Gate { From 1b9408268b69050c69b485e855dc23dc0412545b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:07:35 +0800 Subject: [PATCH 14/20] ci: cache eslint lane --- .github/workflows/ci.yml | 14 ++++++++++++++ .github/workflows/e2e.yml | 2 +- .gitignore | 1 + docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- .../process/2026-07-06-parallel-github-ci-gates.md | 4 ++-- .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- scripts/run-gates.ts | 14 ++++++++++++++ 9 files changed, 37 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a17aa8b77..efa4dfc458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} + DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false matrix: @@ -32,26 +33,31 @@ jobs: gate_concurrency: '4' publint_concurrency: '8' coverage_max_workers: '' + eslint_cache: '' - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' publint_concurrency: '8' coverage_max_workers: '' + eslint_cache: '1' - lane: coverage command: pnpm run check:ci:coverage gate_concurrency: '1' publint_concurrency: '8' coverage_max_workers: '4' + eslint_cache: '' - lane: snapshot command: pnpm run check:ci:snapshot gate_concurrency: '1' publint_concurrency: '8' coverage_max_workers: '' + eslint_cache: '' - lane: artifacts command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' coverage_max_workers: '' + eslint_cache: '' steps: - uses: actions/checkout@v6 @@ -76,6 +82,14 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + - uses: actions/cache@v4 + if: 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') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint- + - name: Run gates run: ${{ matrix.command }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0dd14a46a5..1ae0733286 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: 12 + DSH_E2E_MAX_WORKERS: 14 run: pnpm run test:e2e diff --git a/.gitignore b/.gitignore index 2788817b23..df36ca9214 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ lib/ *.tsbuildinfo pnpm-debug.log .pnpm-store/ +.cache/ examples/*/*.jsonl .sessions/ examples/*/.sessions/ diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 1de17c1c92..2ab3478a61 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: 376d6a5740f01358daca407c7b8ab5e1a636417a -development.zh.md: 2f56aab8bdb1914668574d37279ef817443f1a30 +development.md: 97ca3f6b9fc9653ab658e480e6155fc1e121854f +development.zh.md: e837afb6a01ed4d0c4801886bd6ca6a7602ac573 diff --git a/docs/development.md b/docs/development.md index 376d6a5740..97ca3f6b9f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,7 +69,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou 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`. +`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=14`. ## Daily commands diff --git a/docs/development.zh.md b/docs/development.zh.md index 2f56aab8bd..e837afb6a0 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -69,7 +69,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 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`。 +`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。 ## 日常命令 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 aaaa15909f..d63a8ad713 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 @@ -12,13 +12,13 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and [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, 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 Node 26 compatibility job owns the TypeScript typecheck. 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, 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 and a content-strategy ESLint cache; 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 Node 26 compatibility job owns the TypeScript typecheck. 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. 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. -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. +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=14` 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 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 445083faa8..982c0fcb3c 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 `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. +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 `14`) 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/scripts/run-gates.ts b/scripts/run-gates.ts index bacfb37b67..195e188858 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -200,6 +200,20 @@ function ciArtifactGates(): Gate[] { } function lintGate(): Gate { + if (process.env.DSH_ESLINT_CACHE === '1') { + return pnpmExec('lint', [ + 'eslint', + '.', + '--cache', + '--cache-location', + '.cache/eslint/', + '--cache-strategy', + 'content', + ], { + label: 'lint', + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + }) + } return pnpmScript('lint', 'lint', { env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) From 3464f8829703e9738d63ca294e624ce428b54289 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:12:37 +0800 Subject: [PATCH 15/20] test: shorten compaction e2e smoke --- examples/coding-agent/tests/compaction.e2e.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index f306e20e98..854cf49d2a 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -43,8 +43,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // A handful of files for the model to read, so multiple bash steps // accumulate surface nodes (tool calls + results) and grow the history past // the (deliberately tiny) window. - for (let i = 1; i <= 6; i++) { - await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40)) + for (let i = 1; i <= 4; i++) { + await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(50)) } // Tiny window so a couple of steps crosses the threshold. The generation @@ -55,11 +55,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT, compact: { - contextWindow: 2400, + contextWindow: 2000, thresholdRatio: 0.5, - retainTokens: 500, + retainTokens: 400, summarizationModel: '', - maxTokens: 2048, + maxTokens: 1024, compactionRetries: 1, }, persistenceRoot: join(workdir, '.sessions'), @@ -68,8 +68,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa agent.send([{ type: 'text', - text: 'Read file1.txt, file2.txt, file3.txt, file4.txt, file5.txt, and file6.txt one at a ' - + 'time using cat (a separate bash command for each). After reading all six, tell me how ' + text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' + + 'time using cat (a separate bash command for each). After reading all four, tell me how ' + 'many files you read and the number mentioned in file1.txt.', }]) await waitForIdle(ctx, agent) @@ -98,9 +98,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0) // The conversation survived compaction: the agent produced a final answer - // that reflects the work (it read six files). + // that reflects the work (it read four files). const answer = finalText(events).toLowerCase() expect(answer.length).toBeGreaterThan(0) - expect(answer).toMatch(/\b(6|six)\b/) + expect(answer).toMatch(/\b(4|four)\b/) }, 240_000) }) From cc670c0762b7e5c70b22d5338eb50f2b6c49ff9d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:16:34 +0800 Subject: [PATCH 16/20] ci: use full e2e worker fanout --- .github/workflows/e2e.yml | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- .../process/2026-07-06-parallel-github-ci-gates.md | 2 +- docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1ae0733286..ea24d78686 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: 14 + DSH_E2E_MAX_WORKERS: 16 run: pnpm run test:e2e diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ab3478a61..7d16a669db 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: 97ca3f6b9fc9653ab658e480e6155fc1e121854f -development.zh.md: e837afb6a01ed4d0c4801886bd6ca6a7602ac573 +development.md: 370e2c2205fd1f0b8c648e4ccd36c7aa04d07ef9 +development.zh.md: ef4163532a256fdacdac652258c287848b72a323 diff --git a/docs/development.md b/docs/development.md index 97ca3f6b9f..370e2c2205 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,7 +69,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou 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=14`. +`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=16`. ## Daily commands diff --git a/docs/development.zh.md b/docs/development.zh.md index e837afb6a0..ef4163532a 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -69,7 +69,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 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=14`。 +`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=16`。 ## 日常命令 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 d63a8ad713..d742c05977 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 @@ -18,7 +18,7 @@ Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored 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=14` 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=16` 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 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 982c0fcb3c..f89bfd85b0 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 `14`) 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 `16`) 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 From 616c2ffba30d0279b351cd39dd89174c642166fe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:20:08 +0800 Subject: [PATCH 17/20] ci: keep measured e2e worker cap --- .github/workflows/e2e.yml | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- .../process/2026-07-06-parallel-github-ci-gates.md | 2 +- docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ea24d78686..1ae0733286 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: 16 + DSH_E2E_MAX_WORKERS: 14 run: pnpm run test:e2e diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 7d16a669db..2ab3478a61 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: 370e2c2205fd1f0b8c648e4ccd36c7aa04d07ef9 -development.zh.md: ef4163532a256fdacdac652258c287848b72a323 +development.md: 97ca3f6b9fc9653ab658e480e6155fc1e121854f +development.zh.md: e837afb6a01ed4d0c4801886bd6ca6a7602ac573 diff --git a/docs/development.md b/docs/development.md index 370e2c2205..97ca3f6b9f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,7 +69,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou 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=16`. +`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=14`. ## Daily commands diff --git a/docs/development.zh.md b/docs/development.zh.md index ef4163532a..e837afb6a0 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -69,7 +69,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 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=16`。 +`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。 ## 日常命令 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 d742c05977..d63a8ad713 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 @@ -18,7 +18,7 @@ Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored 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=16` 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=14` 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 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 f89bfd85b0..982c0fcb3c 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 `16`) 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 `14`) 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 From 7af8f980801fec5ae334bb9e583d62ec3775398b Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 6 Jul 2026 13:14:03 +0800 Subject: [PATCH 18/20] docs: clarify local gate cadence --- AGENTS.md | 4 ++-- docs/development.i18n.yaml | 4 ++-- docs/development.md | 4 +++- docs/development.zh.md | 4 +++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8433ac6e79..2a71edb05a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -This is the monorepo of the DeepSeek Harness group; it hosts **DeepSeek Harness SDK**, a plugin-based SDK for building agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing anything under `packages/` — the service map, event surface, loop lifecycle, and extension seams. The documentation standard is [docs/AGENTS.md](docs/AGENTS.md). +This is the monorepo of the DeepSeek Harness group; it hosts **DeepSeek Harness SDK**, a plugin-based SDK for building agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; the documentation standard is [docs/AGENTS.md](docs/AGENTS.md). ## Pre-release stance: foundation over blast radius @@ -52,7 +52,7 @@ pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) ### Run the CI gates locally before marking a PR ready -From a fresh clone or worktree, `pnpm run build` first — publint and the NodeNext check validate built `lib/`. The CI-equivalent run: +During implementation, run the narrowest affected checks; run this full CI-equivalent sequence only when complete and before marking a PR ready. From a fresh clone/worktree, `pnpm run build` first because publint and NodeNext validate built `lib/`: ```sh set -euo pipefail diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index cef11b8352..393ba013d5 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: f032764fff29baaca007211db8b69d9a5129078f -development.zh.md: 3a650d03ce7cafd0e34290ae918e5a303c2ad8a9 +development.md: 3201eb8c8715eea437098409e7cfe93e13c3204f +development.zh.md: fcf6a7f2718cc4f3009f38fa4360ae38bdfe843f diff --git a/docs/development.md b/docs/development.md index f032764fff..3201eb8c87 100644 --- a/docs/development.md +++ b/docs/development.md @@ -84,9 +84,11 @@ The GitHub workflow runs these gates on each pull request: `pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types`; CI also runs `pnpm run constraints` as an earlier fail-fast step, then runs the full hygiene script after `pnpm run build`. +During implementation, prefer the narrowest gate that can fail for the change you just made: filtered tests for touched packages/specs, `pnpm run typecheck` after type or API changes, `pnpm run lint` after code edits, and the specific doc/generator check only when that surface changed. Run the full CI-gate sequence from `AGENTS.md` once the change is complete and before marking a PR ready; repeat it only after later edits could invalidate the result. + ## Daily commands -Use these from the repo root: +Use these from the repo root as a command reference, not as a checklist to rerun after every edit: ```sh pnpm run test # unit tests diff --git a/docs/development.zh.md b/docs/development.zh.md index 3a650d03ce..fcf6a7f271 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -84,9 +84,11 @@ GitHub 工作流在每个 pull request 上运行这些门禁: `pnpm run hygiene` 是 `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types` 的本地简写;CI 还会把 `pnpm run constraints` 作为更早的快速失败步骤单独跑一次,然后在 `pnpm run build` 之后跑完整的 hygiene 脚本。 +开发过程中,优先运行刚才那次改动可能打红的最窄门禁:针对受影响 package/spec 的过滤测试;类型或 API 变动后跑 `pnpm run typecheck`;代码改动后跑 `pnpm run lint`;只在触及文档或生成面时跑对应的 doc/generator 检查。改动完成、准备把 PR 标为 ready 前,再按 `AGENTS.md` 中的完整 CI 门禁跑一遍;只有后续改动可能让结果失效时才重复全量。 + ## 日常命令 -在仓库根目录使用: +在仓库根目录使用;下面是命令参考,不是每次编辑后都要重新跑一遍的 checklist: ```sh pnpm run test # unit tests From e1b85da182fa72a99f00187465955a798f54f5fb Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 6 Jul 2026 13:36:27 +0800 Subject: [PATCH 19/20] docs: trim daily command wording --- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 393ba013d5..f551bff5ff 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: 3201eb8c8715eea437098409e7cfe93e13c3204f -development.zh.md: fcf6a7f2718cc4f3009f38fa4360ae38bdfe843f +development.md: 509888b3d74bd22de658c5176355bb34d95e4cfd +development.zh.md: 5260eeefccac99cd8080f0bef8c9b4f3fd04fca9 diff --git a/docs/development.md b/docs/development.md index 3201eb8c87..509888b3d7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -88,7 +88,7 @@ During implementation, prefer the narrowest gate that can fail for the change yo ## Daily commands -Use these from the repo root as a command reference, not as a checklist to rerun after every edit: +Use these from the repo root: ```sh pnpm run test # unit tests diff --git a/docs/development.zh.md b/docs/development.zh.md index fcf6a7f271..5260eeefcc 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -88,7 +88,7 @@ GitHub 工作流在每个 pull request 上运行这些门禁: ## 日常命令 -在仓库根目录使用;下面是命令参考,不是每次编辑后都要重新跑一遍的 checklist: +在仓库根目录使用: ```sh pnpm run test # unit tests From e48a344f6a1cec2c7f548acfe2aa84d156d4d51e Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 6 Jul 2026 17:08:50 +0800 Subject: [PATCH 20/20] docs: keep gate cadence guidance agent-only --- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 -- docs/development.zh.md | 2 -- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index f551bff5ff..cef11b8352 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: 509888b3d74bd22de658c5176355bb34d95e4cfd -development.zh.md: 5260eeefccac99cd8080f0bef8c9b4f3fd04fca9 +development.md: f032764fff29baaca007211db8b69d9a5129078f +development.zh.md: 3a650d03ce7cafd0e34290ae918e5a303c2ad8a9 diff --git a/docs/development.md b/docs/development.md index 509888b3d7..f032764fff 100644 --- a/docs/development.md +++ b/docs/development.md @@ -84,8 +84,6 @@ The GitHub workflow runs these gates on each pull request: `pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types`; CI also runs `pnpm run constraints` as an earlier fail-fast step, then runs the full hygiene script after `pnpm run build`. -During implementation, prefer the narrowest gate that can fail for the change you just made: filtered tests for touched packages/specs, `pnpm run typecheck` after type or API changes, `pnpm run lint` after code edits, and the specific doc/generator check only when that surface changed. Run the full CI-gate sequence from `AGENTS.md` once the change is complete and before marking a PR ready; repeat it only after later edits could invalidate the result. - ## Daily commands Use these from the repo root: diff --git a/docs/development.zh.md b/docs/development.zh.md index 5260eeefcc..3a650d03ce 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -84,8 +84,6 @@ GitHub 工作流在每个 pull request 上运行这些门禁: `pnpm run hygiene` 是 `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types` 的本地简写;CI 还会把 `pnpm run constraints` 作为更早的快速失败步骤单独跑一次,然后在 `pnpm run build` 之后跑完整的 hygiene 脚本。 -开发过程中,优先运行刚才那次改动可能打红的最窄门禁:针对受影响 package/spec 的过滤测试;类型或 API 变动后跑 `pnpm run typecheck`;代码改动后跑 `pnpm run lint`;只在触及文档或生成面时跑对应的 doc/generator 检查。改动完成、准备把 PR 标为 ready 前,再按 `AGENTS.md` 中的完整 CI 门禁跑一遍;只有后续改动可能让结果失效时才重复全量。 - ## 日常命令 在仓库根目录使用: