mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge latest master into worktree/semantic-session-checkpoints
This commit is contained in:
@@ -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
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 7d1fb1ad5a2e7563bdddffde1f49368b9f0c13f7
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: 669eb02221aea4b0654497bb81327d725648dabe
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 40d152b2320ac65f9ea7d8732b1a667236d2780a
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: bd2f440d5dce15b352e7bcea0d1243400d290f11
|
||||
|
||||
@@ -59,7 +59,7 @@ Session-backed companions validate existing durable events when they load, using
|
||||
|
||||
`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls.
|
||||
|
||||
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate stages each package's exact `npm pack` file inventory, imports its compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so an unpublished shared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
||||
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. After the structural gate validates each publication map, an artifact gate stages its manifest-declared `lib/` files, imports the compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so a companion that imports an undeclared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Status: implemented
|
||||
|
||||
`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。
|
||||
|
||||
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁会按每个包的精确 `npm pack` 文件清单暂存文件,在 plain Node 下导入该包已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,未发布的共享运行时分片会在正式发布前导致门禁失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。结构门禁验证每个包的发布映射后,产物门禁会暂存其 manifest(元数据清单)声明的 `lib/` 文件,在 plain Node 下导入已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,若 companion 导入未声明的运行时分片,门禁就会在发布前失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -4,36 +4,45 @@ 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 command chain makes the workflow wall clock equal the sum of those gates, while splitting every leaf gate into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck.
|
||||
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 command chain makes the workflow wall clock equal the sum of those gates, while splitting every short leaf into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck.
|
||||
|
||||
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.
|
||||
The original broad-lane split stopped meeting that balance as the workspace grew. On the merge of PR #404, Linux static, coverage, snapshot, and artifact jobs took 148, 195, 94, and 230 seconds; Windows static and artifacts took 251 and 482 seconds. Package-manager packing once per package dominated both artifact validators, coverage needlessly rebuilt output before a source-only suite, and CPU-heavy gates contended inside the static and coverage lanes.
|
||||
|
||||
The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, compiled invariant loading, and built-bin smoke tests need emitted `lib/` output. Sharding cannot race those consumers ahead of build or replace their published-artifact signal with source execution.
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) groups keyless checks into broad primary-runtime lanes plus a compatibility matrix. The workflow file owns the current lane and runtime inventory.
|
||||
The production topology below is historical and is superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). The larger-runner decision removes its shard selectors and workflow jobs; this note preserves why that earlier topology was implemented.
|
||||
|
||||
Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), which schedules independent gates with bounded concurrency and prints an attributable result block for each one. Artifact consumers depend on one build within their lane, while compatibility jobs combine typechecking with a real unbuilt worker launch to cover runtime-specific loader behavior.
|
||||
[CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle.
|
||||
|
||||
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.
|
||||
In that topology, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) was the common bounded scheduler and GitHub supplied explicit shard names for the expensive gate families. `scripts/static-shards.ts` partitioned static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejected a missing or duplicate gate assignment. Linux lint used disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while Windows used complete package-source and package-test lanes; both included a repository complement starting from `.` so new top-level targets could not disappear between shards and owned the single cross-file duplication run. `scripts/coverage-shards.ts` assigned every workspace package to exactly one source-coverage lane. Directory filters retained a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named siblings. Each coverage lane included only its owned source files, repeated the exhaustive companion topology test, and ran without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed.
|
||||
|
||||
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.
|
||||
Snapshot replay used two explicit multi-file lanes and eight scenario partitions of the large ACP file. `scripts/snapshot-shards.ts` owned that inventory, and its test discovered every file admitted by the snapshot config. Each snapshot job installed dependencies while its Linux runner prepared Bubblewrap, built the shipped runtime, and ran only its assigned replay surface. The suite retained bounded concurrency of five subprocesses because replay spent most of its time waiting on child protocol I/O. Fixture guards still inspected the complete ACP scenario table in every partition.
|
||||
|
||||
Both workflows cache the pnpm store. The real-API workflow uses the shared bounded Vitest file pool rather than a separate job per test group.
|
||||
Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job.
|
||||
|
||||
Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails.
|
||||
|
||||
Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal.
|
||||
|
||||
The workflow caches the pnpm store, keys each immutable ESLint cache to its owning lint shard, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational.
|
||||
|
||||
## 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 as a separate GitHub job** - maximizes GitHub-visible fan-out, but it creates too many checks and pays repeated setup/install overhead for gates whose runtime is shorter than the runner preparation.
|
||||
- **Upload build artifacts to artifact-dependent jobs** - preserves correctness across many jobs, but it adds artifact upload/download time and keeps the workflow wide when the artifact consumers can run behind a local dependency in the primary job.
|
||||
- **Run `typecheck` and `build` concurrently** - exposes more work to the scheduler, but both commands invoke `tsc -b`; sharing incremental build state between them is a needless race for a small wall-clock gain.
|
||||
- **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.
|
||||
- **Keep the broad lanes** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop.
|
||||
- **Run every leaf gate as a separate GitHub job** - maximizes fan-out, but short generators and prose checks would spend more time preparing a runner than checking the repository.
|
||||
- **Upload one build to artifact consumers** - avoids repeated compilation, but upload/download and dependency scheduling lengthen wall time; the clean build is short enough to repeat inside bounded lanes.
|
||||
- **Keep package-manager packing in both publication gates** - delegates inventory selection to pnpm, but repeats more than 200 package-manager processes. The manifest structural gate plus publication-view fixtures make the optimized inventory contract explicit and fail on an on-disk but unpublished dependency.
|
||||
- **Keep build before coverage** - provides emitted output the source suite no longer consumes; a clean-tree coverage proof showed it was pure latency.
|
||||
- **Typecheck on every Node version** - repeats compiler work while the compatibility smokes already exercise actual Node-specific loading and compression behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
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 shard inventories and matrix jobs described above are not part of the current repository contract. The superseding larger-runner decision keeps the complete primary inventory in one process and uses the serial suite as its independent completeness oracle.
|
||||
|
||||
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 optimized publication validators rely on the manifest `files` contract enforced by `verify-package-invariants`. If publication rules grow beyond that contract, the structural gate and both staged views must change together.
|
||||
|
||||
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 compatibility signal is narrower than the primary Node 24 signal. It proves that the source graph typechecks and that the real unbuilt workflow-worker launch path executes on every advertised runtime line without doubling documentation, coverage, publication, snapshot replay, and unrelated smoke checks whose failures are not expected to vary by Node version.
|
||||
Compatibility jobs no longer claim that TypeScript itself was exercised under every Node runtime. They prove runtime-sensitive source loading on Node 22, 24, and 26, while the primary runtime owns the single source-graph typecheck.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
2026-07-21-serial-cross-platform-ci-reference.md: ffc1fd5b37bc6c9e3427ee55a55300f93a1292f3
|
||||
2026-07-21-serial-cross-platform-ci-reference.zh.md: d7f87916865b83973abe6b0708203618cf536c8e
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Serial cross-platform CI reference
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The pull-request workflow reaches its latency targets by scheduling the complete primary Node inventory concurrently inside one larger runner. The optimized scheduler still should not be its own only completeness oracle: a defect in its gate inventory or dependency graph could omit work while the optimized job stays green.
|
||||
|
||||
Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish.
|
||||
|
||||
Reviewers also need a direct answer to a simpler question: what happens when the repository's complete primary Node CI aggregate runs without matrix selection, shard variables, or concurrent gates on each selected hosted operating system?
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run only the optimized larger-runner and compatibility jobs. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
|
||||
|
||||
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
|
||||
|
||||
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only the optimized jobs; a master push runs only the three serial references. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values.
|
||||
|
||||
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
|
||||
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
|
||||
- **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop.
|
||||
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
|
||||
- **Run the serial reference on larger runners** - rejected because the reference is the portable fallback for the organization-specific pull-request topology. The fast pull-request path uses provisioned larger runners; the serial master path keeps standard labels.
|
||||
|
||||
## Consequences
|
||||
|
||||
The workflow contains duplicated setup steps and a master reference run can take much longer than the optimized pull-request path. That duplication is deliberate: reviewers can inspect each operating system's complete command without resolving a matrix or concurrent scheduler.
|
||||
|
||||
The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate.
|
||||
|
||||
Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 跨平台串行 CI 参考流程
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-serial-cross-platform-ci-reference.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
拉取请求工作流通过在一台更大型运行器内并发调度完整的主 Node 门禁清单来达到延迟目标。优化调度器仍不应成为自身唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使优化作业保持绿灯,也可能漏掉部分工作。
|
||||
|
||||
将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。
|
||||
|
||||
评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 Node CI 聚合流程不使用矩阵选择、分片变量或并发门禁,运行结果会怎样?
|
||||
|
||||
## 决策
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求只运行使用更大型运行器的优化作业和兼容性作业。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
|
||||
|
||||
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
|
||||
|
||||
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行优化作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
|
||||
|
||||
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
|
||||
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
|
||||
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们。
|
||||
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
|
||||
- **在更大型运行器上运行串行参考流程**:不予采纳,因为该参考流程是特定组织拉取请求拓扑的可移植后备方案。快速拉取请求路径使用已预配的更大型运行器;串行 master 路径保留标准标签。
|
||||
|
||||
## 后果
|
||||
|
||||
工作流包含重复的设置步骤,master 参考运行也可能比优化后的拉取请求路径耗时长得多。这些重复是有意保留的:评审人无需解析矩阵或并发调度器,就能直接检查每种操作系统执行的完整命令。
|
||||
|
||||
参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。
|
||||
|
||||
移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: c0fae2841f21c431d6416cd5d421929d70197abb
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 51c73a8a631af4f1254c795d09585770fc4e68bb
|
||||
@@ -0,0 +1,75 @@
|
||||
# Agent Note: Evidence-based larger hosted runners
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-evidence-based-larger-hosted-runners.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The shard-heavy CI topology met its latency targets by spreading primary Node work across 40 Linux jobs and Windows work across nine jobs. Most gates were shorter than checkout, runner setup, cache restore, and dependency installation, so repeated setup waves created both cost and latency variance. One hosted run finished its slowest Linux job in 49 seconds yet took 231 seconds for a Windows lint shard whose checkout, cache restore, and install alone consumed 158 seconds.
|
||||
|
||||
Larger runners make it possible to pay setup once and parallelize inside the repository scheduler, but the useful size cannot be selected from core counts alone. Critical-lane benchmarks did not scale monotonically, and a whole-repository aggregate exposed different bottlenecks from isolated typecheck or site builds.
|
||||
|
||||
## Decision
|
||||
|
||||
The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand.
|
||||
|
||||
Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded:
|
||||
|
||||
- `node 24 / complete` uses one 96-core Linux runner. One checkout, direct selection of the image's preinstalled Node 24 toolcache, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 10 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials.
|
||||
- Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory.
|
||||
- `windows node 24 / complete` uses one 32-core Windows runner. One preparation wave feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step.
|
||||
|
||||
The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
|
||||
|
||||
An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:
|
||||
|
||||
| Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Active time | 243 s | 144 s | 103 s | 87 s | 62 s | 65 s |
|
||||
|
||||
The 96-core trace spent 39.14 seconds in repository gates. Typecheck occupied 25.71 seconds, then a scheduler dependency delayed the 2.13-second build and 11.29-second snapshot replay until it finished. The same run already proved build and typecheck independently, and the former CPU lane ran them concurrently. Removing that dependency makes lint at 33.30 seconds the measured critical gate while preserving dependencies only for consumers of build output. The 64-core trace exposed the same idle chain: typecheck, build, and snapshot consumed 44.85 seconds in sequence while its independent lint and documentation builds finished in 36.83 and 36.15 seconds. More cores therefore become useful only after the repository scheduler can feed them.
|
||||
|
||||
The same benchmark measured the required Windows build surfaces across every provisioned size:
|
||||
|
||||
| Windows blocking builds | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s |
|
||||
|
||||
Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated.
|
||||
|
||||
The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host.
|
||||
|
||||
Three host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. The setup-node action later spent 3.68 seconds printing cached Linux environment details and 46.56 seconds doing the same on Windows after both had already found Node 24.18.0 in the hosted toolcache. The two latency-critical jobs select the newest preinstalled 24.x directory directly, verify its major, and fail loud if the image no longer carries it; compatibility jobs retain setup-node because selecting a non-default runtime is their contract. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation.
|
||||
|
||||
Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Production therefore retains 16 ESLint workers and admits 10 independent repository gates at once, leaving capacity for the worker pools owned by those gates without starving later independent work.
|
||||
|
||||
Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly five suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. It also includes the pi-ai adapter suite after two hosted aggregate runs delayed an idle-watchdog socket-close observation past its 100-millisecond test deadline. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files.
|
||||
|
||||
The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute.
|
||||
|
||||
**Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises.
|
||||
|
||||
**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. Production uses 96 cores for the shorter controllable critical path; the benchmark suite retains both pools so a sustained image or pricing change can reverse that choice with evidence.
|
||||
|
||||
**Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it.
|
||||
|
||||
**Keep compatibility and Python on standard runners.** Warm standard runs can fit, but runner setup alone has crossed the non-Windows target. Distinct larger pools isolate these environment contracts from that allocation lottery.
|
||||
|
||||
**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
|
||||
|
||||
**Install Bubblewrap through the system package manager.** This uses the host's package database and can dominate the job even when the payload is tiny. Pinned extraction plus a confinement probe preserves the runtime contract without mutating the hosted image.
|
||||
|
||||
## Consequences
|
||||
|
||||
Primary Node CI has one job, one setup wave, one complete gate inventory, and no shard selectors. Together with two Node compatibility executions, Python, and Windows, production has five paid larger-runner executions instead of seven coarse-lane executions or 49 gate-level executions.
|
||||
|
||||
GitHub rounds each larger-runner execution up to a whole minute, so eliminating setup waves reduces billed time as well as workflow complexity. The final aggregator remains on a standard runner because it begins only after the paid jobs release capacity.
|
||||
|
||||
The current targets are observed performance contracts, not cancellation deadlines. Exact-head production runs must show every non-Windows job below one minute and the consolidated Windows job below three minutes; manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
|
||||
|
||||
Production CI depends on the organization-owned runner labels in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). Missing or renamed pools leave jobs queued instead of falling back to standard capacity. All twelve pools remain provisioned so the manual benchmarks can re-evaluate the production size without an administrative setup cycle.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Agent Note: 基于实证选用 GitHub 托管大型运行器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-evidence-based-larger-hosted-runners.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
高度分片的 CI 拓扑通过把主 Node 工作分散到 40 个 Linux 作业、把 Windows 工作分散到 9 个作业来达到延迟目标。大多数门禁本身的耗时短于代码检出、运行器设置、缓存恢复和依赖安装这些准备阶段,因此反复执行多轮设置既增加成本,也带来延迟波动。一次托管运行中最慢的 Linux 作业用时 49 秒,而一个 Windows lint 分片却耗时 231 秒,其中仅代码检出、缓存恢复和安装就占了 158 秒。
|
||||
|
||||
大型运行器可以让 CI 只承担一次设置开销,再由仓库调度器在内部并行执行,但无法仅凭核心数选出有实际价值的规格。关键通道基准测试的性能提升不呈单调变化,完整仓库聚合流程暴露出的瓶颈也不同于单独运行类型检查或网站构建时的瓶颈。
|
||||
|
||||
## 决策
|
||||
|
||||
组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。
|
||||
|
||||
生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片:
|
||||
|
||||
- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、直接选择托管映像中预装的 Node 24 toolcache、恢复 pnpm 和 ESLint 缓存以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 10 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。
|
||||
- Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。
|
||||
- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一轮准备工作供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。
|
||||
|
||||
原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
|
||||
|
||||
一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:
|
||||
|
||||
| Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 活动耗时 | 243 秒 | 144 秒 | 103 秒 | 87 秒 | 62 秒 | 65 秒 |
|
||||
|
||||
96 核运行轨迹中的仓库门禁耗时 39.14 秒。类型检查占用 25.71 秒,随后一项调度器依赖使耗时 2.13 秒的构建和耗时 11.29 秒的快照回放都要等到类型检查结束后才启动。同一次运行已经分别证明构建和类型检查可以独立执行,原 CPU 通道也曾让二者并发运行。移除这项依赖后,耗时 33.30 秒的 lint 成为实测关键门禁,而只有构建输出的消费方仍保留依赖关系。64 核运行轨迹暴露了相同的空闲链:类型检查、构建和快照依次执行,共耗时 44.85 秒,而相互独立的 lint 和文档构建分别在 36.83 秒和 36.15 秒内完成。因此,只有仓库调度器能够为更多核心持续提供工作时,增加核心数才有价值。
|
||||
|
||||
同一项基准测试还测量了每种已预配规格上的 Windows 必需构建项:
|
||||
|
||||
| Windows 阻塞性构建 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 |
|
||||
|
||||
Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。
|
||||
|
||||
客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源。
|
||||
|
||||
3 项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。setup-node action 在 Linux 和 Windows 均已从托管 toolcache 找到 Node 24.18.0 后,仍分别花费 3.68 秒和 46.56 秒输出缓存的环境详情。两个延迟关键作业会直接选择最新的预装 24.x 目录并验证其主版本号;如果映像不再提供该目录,作业会明确报错并失败。兼容性作业仍使用 setup-node,因为选择非默认运行时正是它们的契约。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。
|
||||
|
||||
内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多运行 10 项相互独立的仓库门禁,既为这些门禁自身的工作线程池留出容量,又避免后续独立工作因资源不足而迟迟无法启动。
|
||||
|
||||
Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 5 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。两次托管聚合运行都将空闲看门狗对套接字关闭的观测延迟到超过其 100 毫秒测试截止时间,因此这份清单还包含 pi-ai 适配器套件。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。
|
||||
|
||||
工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。
|
||||
|
||||
**将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。
|
||||
|
||||
**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。生产环境使用 96 核来缩短可控的关键路径;基准测试套件保留两种规格,因此如果映像或定价发生持续性变化,仍可根据证据反转这项选择。
|
||||
|
||||
**让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。
|
||||
|
||||
**让兼容性和 Python 继续使用标准运行器。** 标准运行器热运行可以达到目标,但仅运行器设置一项就曾超过非 Windows 目标。不同的大型运行器池可以让这些环境契约免受这种分配波动影响。
|
||||
|
||||
**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。
|
||||
|
||||
**通过系统包管理器安装 Bubblewrap。** 此方案会使用主机的包数据库,即使包内容很小,也可能主导整个作业耗时。固定版本的解压方式配合隔离探针,无需修改托管映像即可保留运行时契约。
|
||||
|
||||
## 后果
|
||||
|
||||
主 Node CI 只有 1 个作业、1 轮设置、1 份完整门禁清单,而且没有分片选择器。加上 2 次 Node 兼容性执行、Python 和 Windows,生产环境共有 5 次付费大型运行器执行,而非 7 次粗粒度通道执行或 49 次门禁级执行。
|
||||
|
||||
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此消除设置轮次既能减少计费时长,也能降低工作流复杂度。最终聚合作业仍使用标准运行器,因为它只会在付费作业释放容量后启动。
|
||||
|
||||
当前目标是基于观测得到的性能契约,而非取消截止时间。分支头精确的生产运行必须表明每个非 Windows 作业都低于 1 分钟,合并后的 Windows 作业低于 3 分钟;当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
|
||||
|
||||
生产 CI 依赖 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 中由组织持有的运行器标签。池缺失或改名会让作业一直排队,不会回退到标准容量。全部 12 个池均保持已预配状态,因此手动基准测试无需再次经过管理配置周期,就能重新评估生产规格。
|
||||
606
.github/workflows/ci.yml
vendored
606
.github/workflows/ci.yml
vendored
@@ -2,8 +2,18 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
branches: [master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: Manual CI suite to run
|
||||
required: true
|
||||
default: larger-runner-benchmark
|
||||
type: choice
|
||||
options:
|
||||
- larger-runner-benchmark
|
||||
- consolidated-runner-benchmark
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -16,106 +26,89 @@ env:
|
||||
PRIMARY_NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
|
||||
# One large runner pays hosted setup once, then the repository scheduler
|
||||
# overlaps the complete unsharded primary Node inventory. Build starts eagerly;
|
||||
# only consumers of emitted output wait for it.
|
||||
node-24:
|
||||
runs-on: ubuntu-latest
|
||||
name: node 24 / ${{ matrix.lane }}
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: dsh-ubuntu-24-04-96core
|
||||
name: node 24 / complete
|
||||
env:
|
||||
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:
|
||||
include:
|
||||
- lane: static
|
||||
command: pnpm run check:ci:static
|
||||
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: ''
|
||||
DSH_COVERAGE_MAX_WORKERS: '16'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_ESLINT_CONCURRENCY: '16'
|
||||
DSH_GATE_CONCURRENCY: '10'
|
||||
DSH_PUBLINT_CONCURRENCY: '16'
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: '8'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
# Pull requests consume the default-branch cache but do not put cache
|
||||
# compression and upload on the paid latency-critical path.
|
||||
- uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
path: /home/runner/.local/share/pnpm/store/v11
|
||||
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
|
||||
|
||||
# The snapshot lane REPLAYS the sandbox example's recorded scenarios,
|
||||
# re-executing their bash calls under a real runner. ubuntu-latest has
|
||||
# no bubblewrap preinstalled and no built Landlock launcher, so without
|
||||
# this the confined executions fail closed (SANDBOX_UNAVAILABLE). Same
|
||||
# install as sandbox.yml's bwrap leg (incl. the Ubuntu 24.04 AppArmor
|
||||
# userns knob).
|
||||
- name: Install bubblewrap (unrestrict userns)
|
||||
if: matrix.lane == 'snapshot'
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -yq bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
|
||||
|| echo "apparmor userns knob absent — the functional probe decides"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: matrix.lane == 'lint'
|
||||
- uses: actions/cache/restore@v4
|
||||
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') }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ 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-
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
|
||||
|
||||
- name: Select preinstalled Node, install dependencies, and prepare bubblewrap
|
||||
run: |
|
||||
node_root="$(printf '%s\n' "$RUNNER_TOOL_CACHE"/node/"${PRIMARY_NODE_VERSION}".*/x64 | sort -V | tail -n 1)"
|
||||
if [[ ! -d "$node_root" ]]; then
|
||||
echo "preinstalled Node ${PRIMARY_NODE_VERSION}.x not found in $RUNNER_TOOL_CACHE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$node_root/bin" >> "$GITHUB_PATH"
|
||||
export PATH="$node_root/bin:$PATH"
|
||||
[[ "$(node --version)" == "v${PRIMARY_NODE_VERSION}."* ]]
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile &
|
||||
install_pid=$!
|
||||
bash scripts/prepare-ci-bubblewrap.sh &
|
||||
sandbox_pid=$!
|
||||
install_status=0
|
||||
wait "$install_pid" || install_status=$?
|
||||
sandbox_status=0
|
||||
wait "$sandbox_pid" || sandbox_status=$?
|
||||
if (( install_status != 0 )); then exit "$install_status"; fi
|
||||
exit "$sandbox_status"
|
||||
|
||||
- name: Run complete unsharded primary Node CI concurrently
|
||||
run: pnpm run check:ci
|
||||
|
||||
- name: Run gates
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
node-compat:
|
||||
runs-on: ubuntu-latest
|
||||
name: node ${{ matrix.node }}
|
||||
if: github.event_name == 'pull_request'
|
||||
# Distinct larger-runner pools avoid both standard-runner setup outliers and
|
||||
# delayed allocation when independent environment contracts share one pool.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
name: ${{ matrix.name }}
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: '2'
|
||||
DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }}
|
||||
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['22.19', 24, 26]
|
||||
include:
|
||||
- node: '22.19'
|
||||
name: node 22.19
|
||||
runner: dsh-ubuntu-24-04-4core
|
||||
gate_concurrency: '2'
|
||||
- node: 26
|
||||
name: node 26
|
||||
runner: dsh-ubuntu-24-04-32core
|
||||
gate_concurrency: '2'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -123,12 +116,11 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
- name: Enable corepack and resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
corepack enable
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
@@ -140,11 +132,12 @@ jobs:
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run compatibility gates
|
||||
- name: Run compatibility smokes
|
||||
run: pnpm run check:node-compat
|
||||
|
||||
python-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: dsh-ubuntu-24-04-8core
|
||||
name: python 3.10 / keyless SDK
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -160,12 +153,114 @@ jobs:
|
||||
- name: Run complete keyless Python suite
|
||||
run: uv run --python 3.10 --group test --project python/sdk pytest
|
||||
|
||||
# Blocking Windows build lane: keep the already-green native build protected
|
||||
# while the broader observational gate matrix below exposes the remaining
|
||||
# portability work without blocking mainline merges.
|
||||
windows-build:
|
||||
runs-on: windows-2025
|
||||
name: windows / build
|
||||
# One Windows box shares setup across the required build/site checks and the
|
||||
# complete observational portability inventory. run-gates reports failures
|
||||
# from observational gates without allowing them to fail the required job.
|
||||
windows:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: dsh-windows-2025-32core
|
||||
name: windows node 24 / complete
|
||||
env:
|
||||
# Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on
|
||||
# this image. The outer scheduler still overlaps lint with the other gates.
|
||||
DSH_COVERAGE_MAX_WORKERS: '12'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_GATE_CONCURRENCY: '16'
|
||||
DSH_PUBLINT_CONCURRENCY: '16'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ 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-full-
|
||||
|
||||
# Extracting the many-file pnpm store cache is slower on this image than
|
||||
# a clean parallel install, and saving it adds more latency after gates.
|
||||
- name: Select preinstalled Node and install (immutable)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$nodeRoot = Get-ChildItem -Path "$env:RUNNER_TOOL_CACHE\node" -Directory |
|
||||
Where-Object { $_.Name -like "$env:PRIMARY_NODE_VERSION.*" } |
|
||||
Sort-Object { [version]$_.Name } |
|
||||
Select-Object -Last 1
|
||||
if ($null -eq $nodeRoot) {
|
||||
throw "preinstalled Node $env:PRIMARY_NODE_VERSION.x not found in $env:RUNNER_TOOL_CACHE"
|
||||
}
|
||||
$nodeBin = Join-Path $nodeRoot.FullName 'x64'
|
||||
if (-not (Test-Path $nodeBin -PathType Container)) {
|
||||
throw "preinstalled Node x64 directory not found at $nodeBin"
|
||||
}
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $nodeBin
|
||||
$env:PATH = "$nodeBin;$env:PATH"
|
||||
if ((node --version) -notlike "v$env:PRIMARY_NODE_VERSION.*") {
|
||||
throw "selected unexpected Node version $(node --version)"
|
||||
}
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run blocking and observational Windows gates concurrently
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:windows-complete
|
||||
|
||||
# Master pushes run only the serial reference jobs below.
|
||||
# Each host executes the complete, unsharded primary Node aggregate with one
|
||||
# gate worker, giving reviewers a simple cross-platform oracle for completeness
|
||||
# and timing.
|
||||
serial-linux:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
name: serial / linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Enable corepack and resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
corepack enable
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Master refreshes the caches that pull requests restore without saving.
|
||||
- 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-
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ 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-full-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare bubblewrap (unrestrict userns)
|
||||
run: bash scripts/prepare-ci-bubblewrap.sh
|
||||
|
||||
- name: Run complete unsharded primary Node CI serially
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||
DSH_E2E_MAX_WORKERS: '1'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_GATE_CONCURRENCY: '1'
|
||||
DSH_PUBLINT_CONCURRENCY: '1'
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
|
||||
run: pnpm run check:ci
|
||||
|
||||
serial-macos:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
name: serial / macos
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -179,55 +274,19 @@ jobs:
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build (tsc -b + tsdown)
|
||||
run: pnpm run build
|
||||
- name: Run complete unsharded primary Node CI serially
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||
DSH_E2E_MAX_WORKERS: '1'
|
||||
DSH_GATE_CONCURRENCY: '1'
|
||||
DSH_PUBLINT_CONCURRENCY: '1'
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
|
||||
run: pnpm run check:ci
|
||||
|
||||
# Observational, non-blocking Windows mirror of the Linux gate lanes. Run the
|
||||
# gates from native PowerShell: an MSYS parent would change the environment
|
||||
# being measured. This job intentionally stays out of all-checks-passed.needs.
|
||||
windows-gates:
|
||||
continue-on-error: true
|
||||
serial-windows:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
name: serial / windows
|
||||
runs-on: windows-2025
|
||||
name: windows node 24 / ${{ matrix.lane }}
|
||||
env:
|
||||
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:
|
||||
include:
|
||||
- lane: static
|
||||
command: pnpm run check:ci:static
|
||||
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
|
||||
|
||||
@@ -245,12 +304,112 @@ jobs:
|
||||
shell: pwsh
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
# Master refreshes the small cache that pull requests restore without
|
||||
# putting package-store extraction back on the Windows critical path.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ 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-full-
|
||||
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run complete unsharded primary Node CI serially
|
||||
shell: pwsh
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||
DSH_E2E_MAX_WORKERS: '1'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_GATE_CONCURRENCY: '1'
|
||||
DSH_PUBLINT_CONCURRENCY: '1'
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
|
||||
run: pnpm run check:ci
|
||||
|
||||
# Manual, bounded comparison of the actual critical Linux and Windows lanes.
|
||||
# The named pools are restricted at the organization level to this repository.
|
||||
larger-runner-benchmark:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
cores: '4'
|
||||
runner: dsh-ubuntu-24-04-4core
|
||||
workload: typecheck
|
||||
- platform: linux
|
||||
cores: '8'
|
||||
runner: dsh-ubuntu-24-04-8core
|
||||
workload: typecheck
|
||||
- platform: linux
|
||||
cores: '16'
|
||||
runner: dsh-ubuntu-24-04-16core
|
||||
workload: typecheck
|
||||
- platform: linux
|
||||
cores: '32'
|
||||
runner: dsh-ubuntu-24-04-32core
|
||||
workload: typecheck
|
||||
- platform: linux
|
||||
cores: '64'
|
||||
runner: dsh-ubuntu-24-04-64core
|
||||
workload: typecheck
|
||||
- platform: linux
|
||||
cores: '96'
|
||||
runner: dsh-ubuntu-24-04-96core
|
||||
workload: typecheck
|
||||
- platform: windows
|
||||
cores: '4'
|
||||
runner: dsh-windows-2025-4core
|
||||
workload: production-site
|
||||
- platform: windows
|
||||
cores: '8'
|
||||
runner: dsh-windows-2025-8core
|
||||
workload: production-site
|
||||
- platform: windows
|
||||
cores: '16'
|
||||
runner: dsh-windows-2025-16core
|
||||
workload: production-site
|
||||
- platform: windows
|
||||
cores: '32'
|
||||
runner: dsh-windows-2025-32core
|
||||
workload: production-site
|
||||
- platform: windows
|
||||
cores: '64'
|
||||
runner: dsh-windows-2025-64core
|
||||
workload: production-site
|
||||
- platform: windows
|
||||
cores: '96'
|
||||
runner: dsh-windows-2025-96core
|
||||
workload: production-site
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Report runner capacity
|
||||
run: >-
|
||||
node -e "const os = require('node:os');
|
||||
console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
|
||||
memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
|
||||
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
if: matrix.platform == 'linux'
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: matrix.platform == 'linux'
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -258,26 +417,161 @@ jobs:
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run critical Linux typecheck lane
|
||||
if: matrix.platform == 'linux'
|
||||
run: pnpm run typecheck
|
||||
|
||||
- name: Run critical Windows production-site lane
|
||||
if: matrix.platform == 'windows'
|
||||
run: pnpm run docs:build
|
||||
|
||||
# Manual comparison of the intended low-fanout topology. Linux runs the
|
||||
# complete unsharded primary aggregate with bounded in-runner parallelism;
|
||||
# Windows runs both blocking build surfaces concurrently through run-gates.
|
||||
consolidated-runner-benchmark:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
cores: '4'
|
||||
runner: dsh-ubuntu-24-04-4core
|
||||
workers: '4'
|
||||
- platform: linux
|
||||
cores: '8'
|
||||
runner: dsh-ubuntu-24-04-8core
|
||||
workers: '8'
|
||||
- platform: linux
|
||||
cores: '16'
|
||||
runner: dsh-ubuntu-24-04-16core
|
||||
workers: '16'
|
||||
- platform: linux
|
||||
cores: '32'
|
||||
runner: dsh-ubuntu-24-04-32core
|
||||
workers: '32'
|
||||
- platform: linux
|
||||
cores: '64'
|
||||
runner: dsh-ubuntu-24-04-64core
|
||||
workers: '32'
|
||||
- platform: linux
|
||||
cores: '96'
|
||||
runner: dsh-ubuntu-24-04-96core
|
||||
workers: '32'
|
||||
- platform: windows
|
||||
cores: '4'
|
||||
runner: dsh-windows-2025-4core
|
||||
workers: '2'
|
||||
- platform: windows
|
||||
cores: '8'
|
||||
runner: dsh-windows-2025-8core
|
||||
workers: '2'
|
||||
- platform: windows
|
||||
cores: '16'
|
||||
runner: dsh-windows-2025-16core
|
||||
workers: '2'
|
||||
- platform: windows
|
||||
cores: '32'
|
||||
runner: dsh-windows-2025-32core
|
||||
workers: '2'
|
||||
- platform: windows
|
||||
cores: '64'
|
||||
runner: dsh-windows-2025-64core
|
||||
workers: '2'
|
||||
- platform: windows
|
||||
cores: '96'
|
||||
runner: dsh-windows-2025-96core
|
||||
workers: '2'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Report runner capacity
|
||||
run: >-
|
||||
node -e "const os = require('node:os');
|
||||
console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
|
||||
memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
|
||||
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
id: pnpm-store-linux
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve pnpm store path (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
id: pnpm-store-windows
|
||||
shell: pwsh
|
||||
run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT'
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store-linux.outputs.path || steps.pnpm-store-windows.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-
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: matrix.platform == 'linux'
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ 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-full-
|
||||
|
||||
- name: Install and prepare Linux
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
pnpm install --frozen-lockfile &
|
||||
install_pid=$!
|
||||
bash scripts/prepare-ci-bubblewrap.sh &
|
||||
sandbox_pid=$!
|
||||
install_status=0
|
||||
wait "$install_pid" || install_status=$?
|
||||
sandbox_status=0
|
||||
wait "$sandbox_pid" || sandbox_status=$?
|
||||
if (( install_status != 0 )); then exit "$install_status"; fi
|
||||
exit "$sandbox_status"
|
||||
|
||||
- name: Install (immutable)
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
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 complete unsharded primary Node CI concurrently
|
||||
if: matrix.platform == 'linux'
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ matrix.workers }}
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_ESLINT_CONCURRENCY: ${{ matrix.workers }}
|
||||
DSH_GATE_CONCURRENCY: ${{ matrix.workers }}
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }}
|
||||
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }}
|
||||
run: pnpm run check:ci
|
||||
|
||||
- name: Run gates
|
||||
- name: Run blocking Windows builds concurrently
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: ${{ matrix.command }}
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: ${{ matrix.workers }}
|
||||
run: pnpm run check:ci:windows-blocking
|
||||
|
||||
# Single stable required check for branch protection: require "all checks
|
||||
# passed" instead of enumerating matrix legs whose names change as lanes and
|
||||
# node versions evolve. Every blocking job in THIS workflow must be listed in
|
||||
# `needs`; explicitly observational jobs such as windows-gates stay out
|
||||
# (`needs` cannot reach across workflow files; e2e.yml stays its own check).
|
||||
# `needs`; observational Windows gates share the required Windows job but are
|
||||
# marked non-blocking inside run-gates. (`needs` cannot reach across workflow
|
||||
# files; e2e.yml stays its own check.)
|
||||
# `if: always()` is load-bearing: without it a failed dependency
|
||||
# would SKIP this job, and GitHub counts a skipped required check as passing
|
||||
# — so this job always runs and fails on any non-success result, including
|
||||
@@ -285,8 +579,8 @@ jobs:
|
||||
all-checks-passed:
|
||||
name: all checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [node-24, node-compat, python-sdk, windows-build]
|
||||
if: always()
|
||||
needs: [node-24, node-compat, python-sdk, windows]
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Fail if any needed job did not succeed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"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:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
|
||||
"check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
|
||||
"check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
|
||||
"check:node-compat": "tsx scripts/run-gates.ts node-compat",
|
||||
"knip": "knip --treat-config-hints-as-errors",
|
||||
"publint": "tsx scripts/publint-all.ts",
|
||||
@@ -55,6 +58,7 @@
|
||||
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
|
||||
"docs:dev": "pnpm --filter @deepseek-ai/website run dev",
|
||||
"docs:build": "pnpm --filter @deepseek-ai/website run build",
|
||||
"docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa",
|
||||
"docs:preview": "pnpm --filter @deepseek-ai/website run preview",
|
||||
"docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build",
|
||||
"website:dev": "pnpm run docs:dev",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
|
||||
@@ -38,12 +38,27 @@ function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<Lsp
|
||||
}
|
||||
|
||||
/** Mount the real seam + lsp-local plugin driving one fake server. */
|
||||
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): Promise<Context> {
|
||||
async function mount(
|
||||
fakeEnv: Record<string, string> = {},
|
||||
overrides: Partial<LspLocalServerConfig> = {},
|
||||
captureProvider?: (provider: LspProvider) => void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: { fake: fakeServer(fakeEnv, overrides) },
|
||||
})
|
||||
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
|
||||
const registrationSpy = captureProvider === undefined
|
||||
? undefined
|
||||
: vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
|
||||
captureProvider(provider)
|
||||
return register(provider)
|
||||
})
|
||||
try {
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: { fake: fakeServer(fakeEnv, overrides) },
|
||||
})
|
||||
} finally {
|
||||
registrationSpy?.mockRestore()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -247,10 +262,22 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
// The first query succeeds, then the server exits before the second arrives, leaving a dead
|
||||
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
|
||||
// failing once on the closed connection first.
|
||||
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
let provider: LspProvider | undefined
|
||||
const ctx = await mount(
|
||||
{ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) },
|
||||
{},
|
||||
(registered) => { provider = registered },
|
||||
)
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
if (provider === undefined) throw new Error('expected lsp-local to register a provider')
|
||||
// This implementation-local test reaches the private pool only to synchronize with its actual
|
||||
// close state. A fixed wall-clock sleep can expire before a CPU-starved child runs its exit timer.
|
||||
const instances = (provider as unknown as {
|
||||
readonly instances: ReadonlyMap<string, { readonly dead: boolean }>
|
||||
}).instances
|
||||
const instance = [...instances.values()][0]
|
||||
if (instance === undefined) throw new Error('expected one pooled LSP instance')
|
||||
await waitFor(async () => instance.dead)
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -391,6 +391,9 @@ describe('LocalSkillProvider', () => {
|
||||
await empty.plugin(SkillService)
|
||||
SkillLocal.apply(empty, {})
|
||||
expect(await empty.skills.list()).toEqual([])
|
||||
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
|
||||
} finally {
|
||||
if (previousDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
|
||||
32
scripts/prepare-ci-bubblewrap.sh
Executable file
32
scripts/prepare-ci-bubblewrap.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Ubuntu's package transaction scans the hosted image's full dpkg database and
|
||||
# runs post-install hooks. CI needs only the signed-archive payload, so pin and
|
||||
# verify that payload before extracting it into the ephemeral runner directory.
|
||||
readonly BUBBLEWRAP_VERSION='0.9.0-1ubuntu0.1'
|
||||
readonly BUBBLEWRAP_SHA256='1b506492bd9c7fd0cdb4f02ac822f1d3e336b0aead5113c1239baf8db5db562a'
|
||||
readonly BUBBLEWRAP_URL="https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
|
||||
|
||||
: "${RUNNER_TEMP:?prepare-ci-bubblewrap requires RUNNER_TEMP}"
|
||||
: "${GITHUB_PATH:?prepare-ci-bubblewrap requires GITHUB_PATH}"
|
||||
|
||||
if [[ "$(uname -s)" != 'Linux' || "$(uname -m)" != 'x86_64' ]]; then
|
||||
echo 'prepare-ci-bubblewrap supports only Linux x86_64 hosted runners' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
|
||||
root="${RUNNER_TEMP}/dsh-bubblewrap"
|
||||
|
||||
curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL"
|
||||
printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status
|
||||
mkdir -p "$root"
|
||||
dpkg-deb --extract "$archive" "$root"
|
||||
printf '%s\n' "$root/usr/bin" >> "$GITHUB_PATH"
|
||||
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
|
||||
|| echo 'apparmor userns knob absent — the functional probe decides'
|
||||
"$root/usr/bin/bwrap" --version
|
||||
"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true
|
||||
echo 'bubblewrap functional probe passed'
|
||||
61
scripts/publint-all.spec.ts
Normal file
61
scripts/publint-all.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const runner = fileURLToPath(new URL('./publint-all.ts', import.meta.url))
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(exportPath = './lib/index.js'): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
|
||||
roots.push(root)
|
||||
const packageDir = join(root, 'packages/core/probe')
|
||||
mkdirSync(join(packageDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
version: '0.0.1',
|
||||
type: 'module',
|
||||
license: 'MIT',
|
||||
engines: { node: '>=22.19' },
|
||||
sideEffects: false,
|
||||
files: ['lib'],
|
||||
exports: { '.': { default: exportPath } },
|
||||
}, null, 2)}\n`)
|
||||
writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
|
||||
writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n')
|
||||
writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
|
||||
return root
|
||||
}
|
||||
|
||||
function run(root: string) {
|
||||
return spawnSync(process.execPath, [
|
||||
'--import', 'tsx', runner,
|
||||
'--packages-root', root,
|
||||
], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
})
|
||||
}
|
||||
|
||||
describe('publint package runner', () => {
|
||||
it('lints recursively declared files from an in-memory publication view', () => {
|
||||
const result = run(fixture())
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('linting 1 package(s)')
|
||||
expect(result.stdout).toContain('All good!')
|
||||
})
|
||||
|
||||
it('rejects an export that exists in the workspace but is not published', () => {
|
||||
const result = run(fixture('./unpublished.js'))
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain('unpublished.js')
|
||||
})
|
||||
})
|
||||
@@ -1,46 +1,53 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
/** Run publint over the exact manifest-declared publication view of every package. */
|
||||
|
||||
import {
|
||||
globSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from 'node:fs'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { dirname, relative, resolve, sep } from 'node:path'
|
||||
import { publint, type Message, type PackFile } from 'publint'
|
||||
import { formatMessage } from 'publint/utils'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
|
||||
|
||||
// Discover harness packages at packages/<group>/<pkg>; group containers,
|
||||
// examples, and private vendored sources are not package targets.
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const packagesRoot = resolve(root, 'packages')
|
||||
interface PackageTarget {
|
||||
path: string
|
||||
directory: string
|
||||
manifest: PackageManifest
|
||||
}
|
||||
|
||||
// Run publint's JS CLI through the current node, not the .bin shim: the
|
||||
// extensionless shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd
|
||||
// variant needs shell:true, which space-joins args UNESCAPED (DEP0190) and
|
||||
// breaks when the repo path contains spaces. The JS entry is identical on every
|
||||
// platform (`bin` is `./src/cli.js` per publint's package.json).
|
||||
const publintCli = resolve(root, 'node_modules/publint/src/cli.js')
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
files?: unknown
|
||||
}
|
||||
|
||||
type PublintResult =
|
||||
| { path: string; status: 'passed'; stdout: string; stderr: string }
|
||||
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
|
||||
| { path: string; status: 'passed'; messages: Message[]; manifest: Record<string, unknown> }
|
||||
| { path: string; status: 'failed'; messages: Message[]; manifest: Record<string, unknown>; failure?: string }
|
||||
|
||||
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 workspacePackages(): PackageTarget[] {
|
||||
return globSync('packages/*/*/package.json', { cwd: packagesRoot })
|
||||
.sort()
|
||||
.map((manifestPath) => {
|
||||
const absoluteManifestPath = resolve(packagesRoot, manifestPath)
|
||||
const manifest = JSON.parse(readFileSync(absoluteManifestPath, 'utf8')) as PackageManifest
|
||||
return { path: dirname(manifestPath), directory: dirname(absoluteManifestPath), manifest }
|
||||
})
|
||||
}
|
||||
|
||||
function publintConcurrency(total: number): number {
|
||||
if (total === 0) return 0
|
||||
|
||||
const raw = process.env[CONCURRENCY_ENV]
|
||||
if (raw !== undefined) {
|
||||
if (raw !== undefined && raw !== '') {
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return Math.min(total, parsed)
|
||||
@@ -49,57 +56,106 @@ function publintConcurrency(total: number): number {
|
||||
return Math.min(total, availableParallelism())
|
||||
}
|
||||
|
||||
function outputText(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (Buffer.isBuffer(value)) return value.toString()
|
||||
return ''
|
||||
function publicationFiles(target: PackageTarget): PackFile[] {
|
||||
const paths = new Set<string>()
|
||||
addPath(resolve(target.directory, 'package.json'), paths)
|
||||
const declared = Array.isArray(target.manifest.files)
|
||||
? target.manifest.files.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
for (const pattern of [
|
||||
...declared,
|
||||
'README*',
|
||||
'LICENSE*',
|
||||
'LICENCE*',
|
||||
'CHANGELOG*',
|
||||
'CHANGES*',
|
||||
'HISTORY*',
|
||||
'NOTICE*',
|
||||
]) {
|
||||
for (const match of globSync(pattern, { cwd: target.directory })) {
|
||||
addPath(resolve(target.directory, match), paths)
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths]
|
||||
.sort()
|
||||
.map(path => ({
|
||||
name: `package/${relative(target.directory, path).split(sep).join('/')}`,
|
||||
data: readFileSync(path),
|
||||
}))
|
||||
}
|
||||
|
||||
async function runPublint(path: string): Promise<PublintResult> {
|
||||
function addPath(path: string, paths: Set<string>): void {
|
||||
const stat = statSync(path)
|
||||
if (stat.isDirectory()) {
|
||||
for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths)
|
||||
} else if (stat.isFile()) {
|
||||
paths.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPublint(target: PackageTarget): Promise<PublintResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [publintCli, path], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
const result = await publint({
|
||||
pkgDir: 'package',
|
||||
pack: { files: publicationFiles(target) },
|
||||
})
|
||||
return { path, status: 'passed', stdout, stderr }
|
||||
const manifest = result.pkg as Record<string, unknown>
|
||||
return result.messages.some(message => message.type === 'error')
|
||||
? { path: target.path, status: 'failed', messages: result.messages, manifest }
|
||||
: { path: target.path, status: 'passed', messages: result.messages, manifest }
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: unknown; stderr?: unknown; message?: string }
|
||||
return {
|
||||
path,
|
||||
path: target.path,
|
||||
status: 'failed',
|
||||
stdout: outputText(failed.stdout),
|
||||
stderr: outputText(failed.stderr),
|
||||
message: failed.message ?? 'publint failed',
|
||||
messages: [],
|
||||
manifest: target.manifest as Record<string, unknown>,
|
||||
failure: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll(paths: string[], concurrency: number): Promise<PublintResult[]> {
|
||||
async function runAll(targets: PackageTarget[], concurrency: number): Promise<PublintResult[]> {
|
||||
let next = 0
|
||||
const results: Array<PublintResult | undefined> = []
|
||||
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)
|
||||
const target = targets[index]
|
||||
if (target === undefined) return
|
||||
results[index] = await runPublint(target)
|
||||
}
|
||||
}))
|
||||
|
||||
return paths.map((path, index) => {
|
||||
return targets.map((target, index) => {
|
||||
const result = results[index]
|
||||
if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`)
|
||||
if (result === undefined) throw new Error(`publint-all: missing result for ${target.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)
|
||||
if ('failure' in result) console.error(result.failure)
|
||||
for (const message of result.messages) {
|
||||
console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
|
||||
}
|
||||
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): Map<string, string> {
|
||||
const parsed = new Map<string, string>()
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const name = args[index]
|
||||
const value = args[index + 1]
|
||||
if (name !== '--packages-root' || value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`)
|
||||
}
|
||||
if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`)
|
||||
parsed.set(name, value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const packages = workspacePackages()
|
||||
|
||||
@@ -16,6 +16,9 @@ type Mode =
|
||||
| 'ci-coverage'
|
||||
| 'ci-snapshot'
|
||||
| 'ci-artifacts'
|
||||
| 'ci-windows-blocking'
|
||||
| 'ci-windows-complete'
|
||||
| 'ci-windows-observational'
|
||||
| 'node-compat'
|
||||
| 'pre-push'
|
||||
| 'manual-push'
|
||||
@@ -32,6 +35,7 @@ interface Gate {
|
||||
env?: Record<string, string | undefined>
|
||||
input?: string
|
||||
verify?: (result: GateResult) => Promise<void>
|
||||
allowFailure?: boolean
|
||||
}
|
||||
|
||||
interface GateResult {
|
||||
@@ -77,7 +81,9 @@ console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcur
|
||||
const results = await runGates(gates, maxConcurrency)
|
||||
printSummary(results, performance.now() - startedAt)
|
||||
|
||||
if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1)
|
||||
if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parseMode(raw: string | undefined): Mode {
|
||||
switch (raw) {
|
||||
@@ -87,13 +93,17 @@ function parseMode(raw: string | undefined): Mode {
|
||||
case 'ci-coverage':
|
||||
case 'ci-snapshot':
|
||||
case 'ci-artifacts':
|
||||
case 'ci-windows-blocking':
|
||||
case 'ci-windows-complete':
|
||||
case 'ci-windows-observational':
|
||||
case 'node-compat':
|
||||
case 'pre-push':
|
||||
case 'manual-push':
|
||||
case 'doc-sync':
|
||||
return raw
|
||||
default:
|
||||
throw new Error(
|
||||
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | manual-push | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,31 +177,19 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
coverageGate(),
|
||||
]
|
||||
return [coverageGate()]
|
||||
case 'ci-snapshot':
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
snapshotGate(),
|
||||
]
|
||||
return [pnpmScript('build', 'build'), snapshotGate()]
|
||||
case 'ci-artifacts':
|
||||
return ciArtifactGates()
|
||||
case 'ci-windows-blocking':
|
||||
return ciWindowsBlockingGates()
|
||||
case 'ci-windows-complete':
|
||||
return ciWindowsCompleteGates()
|
||||
case 'ci-windows-observational':
|
||||
return ciWindowsObservationalGates()
|
||||
case 'node-compat':
|
||||
return [
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
pnpmExec('source-worker-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
|
||||
], { label: 'source worker smoke' }),
|
||||
pnpmExec('jsonl-zstd-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
]
|
||||
return nodeCompatGates()
|
||||
case 'pre-push': return []
|
||||
case 'manual-push':
|
||||
return [
|
||||
@@ -225,11 +223,12 @@ function ciPrimaryGates(): Gate[] {
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
coverageGate(),
|
||||
...nodeCompatSmokeGates(),
|
||||
snapshotGate(),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
@@ -240,13 +239,40 @@ function ciPrimaryGates(): Gate[] {
|
||||
]
|
||||
}
|
||||
|
||||
function nodeCompatGates(): Gate[] {
|
||||
return [
|
||||
...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')],
|
||||
...nodeCompatSmokeGates(),
|
||||
]
|
||||
}
|
||||
|
||||
function nodeCompatSmokeGates(): Gate[] {
|
||||
return [
|
||||
pnpmExec('source-worker-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
|
||||
], { label: 'source worker smoke' }),
|
||||
pnpmExec('jsonl-zstd-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
]
|
||||
}
|
||||
|
||||
function ciStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('build', 'build'),
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['build'],
|
||||
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
docsBuildScript: 'docs:build:mpa',
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
]
|
||||
@@ -265,11 +291,54 @@ function ciArtifactGates(): Gate[] {
|
||||
]
|
||||
}
|
||||
|
||||
function lintGate(): Gate {
|
||||
function ciWindowsBlockingGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('windows-build', 'build', { label: 'build' }),
|
||||
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
||||
]
|
||||
}
|
||||
|
||||
function ciWindowsCompleteGates(): Gate[] {
|
||||
const observational = ciWindowsObservationalGates()
|
||||
// The required production site replaces the observational MPA build; both
|
||||
// VitePress modes write the same output directory and cannot overlap.
|
||||
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
|
||||
.map(gate => ({ ...gate, allowFailure: true }))
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
||||
...observational,
|
||||
]
|
||||
}
|
||||
|
||||
function ciWindowsObservationalGates(): Gate[] {
|
||||
return [
|
||||
...ciStaticGates(),
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
{
|
||||
...coverageGate(),
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
needs: ['build'],
|
||||
},
|
||||
snapshotGate(),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtPackageInvariantsGate(['build']),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function lintGate(eslintTargets: readonly string[] = ['.']): Gate {
|
||||
const concurrencyArgs = eslintConcurrencyArgs()
|
||||
if (process.env.DSH_ESLINT_CACHE === '1') {
|
||||
return pnpmExec('lint', [
|
||||
'eslint',
|
||||
'.',
|
||||
...eslintTargets,
|
||||
...concurrencyArgs,
|
||||
'--cache',
|
||||
'--cache-location',
|
||||
'.cache/eslint/',
|
||||
@@ -280,11 +349,28 @@ function lintGate(): Gate {
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
if (concurrencyArgs.length > 0) {
|
||||
return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], {
|
||||
label: 'lint',
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
return pnpmScript('lint', 'lint', {
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
|
||||
function eslintConcurrencyArgs(): string[] {
|
||||
const raw = process.env.DSH_ESLINT_CONCURRENCY
|
||||
if (raw === undefined || raw === '') return []
|
||||
if (raw === 'auto') return ['--concurrency=auto']
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`run-gates: DSH_ESLINT_CONCURRENCY must be a positive integer or auto, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return [`--concurrency=${raw}`]
|
||||
}
|
||||
|
||||
function coverageGate(): Gate {
|
||||
return pnpmExec('coverage', [
|
||||
'vitest',
|
||||
@@ -293,8 +379,6 @@ function coverageGate(): Gate {
|
||||
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
|
||||
], {
|
||||
label: 'test:coverage',
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
needs: ['build'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -325,6 +409,13 @@ function positiveIntArg(envName: string, flag: string): string[] {
|
||||
return [`${flag}=${raw}`]
|
||||
}
|
||||
|
||||
function flagEnabled(envName: string): boolean {
|
||||
const raw = process.env[envName]
|
||||
if (raw === undefined || raw === '') return false
|
||||
if (raw !== '1') throw new Error(`run-gates: ${envName} must be 1 when set, got ${JSON.stringify(raw)}.`)
|
||||
return true
|
||||
}
|
||||
|
||||
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
||||
return [
|
||||
@@ -343,6 +434,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
function docSyncLeafGates(options: {
|
||||
docTypecheckNeeds?: string[]
|
||||
docTypecheckEnv?: Record<string, string | undefined>
|
||||
docsBuildScript?: 'docs:build' | 'docs:build:mpa'
|
||||
} = {}): Gate[] {
|
||||
const docTypecheckOptions: Partial<Gate> = {}
|
||||
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
|
||||
@@ -369,8 +461,11 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
// Keep the VitePress build in this single gate because projection rewrites website/.generated.
|
||||
pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }),
|
||||
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], {
|
||||
label: 'documentation projection',
|
||||
}),
|
||||
// Keep the VitePress build itself in one gate because projection rewrites website/.generated.
|
||||
pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }),
|
||||
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
||||
]
|
||||
}
|
||||
@@ -540,7 +635,8 @@ function printSummary(results: GateResult[], durationMs: number): void {
|
||||
for (const result of unsuccessful) {
|
||||
const duration = (result.durationMs / 1000).toFixed(2)
|
||||
const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`)
|
||||
console.error(` - ${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
|
||||
const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : ''
|
||||
console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
|
||||
console.error(` ${result.gate.displayCommand}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,106 @@
|
||||
/** Verify every packed companion through its package self-reference under plain Node. */
|
||||
/** Verify every compiled companion through its staged package self-reference under plain Node. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
globSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href
|
||||
const repositoryRoot = resolve(import.meta.dirname, '..')
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
|
||||
const loaderUrl = options.get('--loader-url')
|
||||
?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href
|
||||
const failures = []
|
||||
const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort()
|
||||
const packArgs = ['pack', '--dry-run', '--json', '--ignore-scripts']
|
||||
// Windows cannot spawn npm's .cmd shim directly; setup-node installs this JS
|
||||
// entrypoint beside node.exe, so the probe stays shell-free on every runner.
|
||||
const npmInvocation = process.platform === 'win32'
|
||||
? [process.execPath, [resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), ...packArgs]]
|
||||
: ['npm', packArgs]
|
||||
const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort()
|
||||
const { default: Loader } = await import(loaderUrl)
|
||||
const loader = Object.create(Loader.prototype)
|
||||
|
||||
for (const manifestPath of manifests) {
|
||||
const packageDir = dirname(resolve(root, manifestPath))
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8'))
|
||||
const packageDir = dirname(resolve(packagesRoot, manifestPath))
|
||||
const manifest = JSON.parse(readFileSync(resolve(packagesRoot, manifestPath), 'utf8'))
|
||||
const packageName = manifest.name
|
||||
if (typeof packageName !== 'string' || packageName.length === 0) {
|
||||
failures.push(`${manifestPath}: missing package name`)
|
||||
continue
|
||||
}
|
||||
|
||||
const pack = spawnSync(npmInvocation[0], npmInvocation[1], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (pack.status !== 0) {
|
||||
const detail = pack.error?.message
|
||||
?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`)
|
||||
failures.push(`${packageName}: ${detail}`)
|
||||
const invariantExport = manifest.exports?.['./invariant']
|
||||
if (typeof invariantExport !== 'object'
|
||||
|| invariantExport.default !== './lib/invariant.js'
|
||||
|| !manifest.files?.includes('lib/invariant.js')) {
|
||||
failures.push(`${packageName}: manifest does not publish ./lib/invariant.js as ./invariant`)
|
||||
continue
|
||||
}
|
||||
|
||||
let files
|
||||
try {
|
||||
const result = JSON.parse(pack.stdout)
|
||||
files = result[0]?.files
|
||||
if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory')
|
||||
} catch (error) {
|
||||
failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep the packed view below its owning package so Node reaches the real
|
||||
// Keep the staged view below its owning package so Node reaches the real
|
||||
// pnpm dependency links. Junctioning node_modules elsewhere breaks pnpm's
|
||||
// relative workspace links on Windows.
|
||||
const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-packed-invariant-'))
|
||||
// relative workspace links on Windows. Copy the manifest-declared lib view
|
||||
// so a companion that imports an undeclared runtime chunk fails here.
|
||||
const stagedPackageDir = mkdtempSync(resolve(packageDir, '.dsh-built-invariant-'))
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (typeof file.path !== 'string'
|
||||
|| (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue
|
||||
const target = resolve(stagedPackageDir, file.path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(resolve(packageDir, file.path), target)
|
||||
}
|
||||
|
||||
const probe = `
|
||||
const companion = await import(${JSON.stringify(`${packageName}/invariant`)});
|
||||
const { default: Loader } = await import(${JSON.stringify(loaderUrl)});
|
||||
if ('default' in companion) throw new Error('companion has a default export');
|
||||
const loader = Object.create(Loader.prototype);
|
||||
const unwrapped = loader.unwrapExports(companion);
|
||||
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace');
|
||||
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing');
|
||||
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
|
||||
throw new Error('companion does not inject invariants');
|
||||
}
|
||||
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing');
|
||||
`
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], {
|
||||
cwd: stagedPackageDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
const detail = result.error?.message
|
||||
?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`)
|
||||
failures.push(`${packageName}: ${detail}`)
|
||||
copyFileSync(resolve(packageDir, 'package.json'), resolve(stagedPackageDir, 'package.json'))
|
||||
copyDeclaredLibFiles(packageDir, stagedPackageDir, manifest.files)
|
||||
const probePath = resolve(stagedPackageDir, 'probe.mjs')
|
||||
writeFileSync(
|
||||
probePath,
|
||||
`import * as companion from ${JSON.stringify(`${packageName}/invariant`)}\nexport default companion\n`,
|
||||
)
|
||||
const { default: companion } = await import(pathToFileURL(probePath).href)
|
||||
if ('default' in companion) throw new Error('companion has a default export')
|
||||
const unwrapped = loader.unwrapExports(companion)
|
||||
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace')
|
||||
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing')
|
||||
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
|
||||
throw new Error('companion does not inject invariants')
|
||||
}
|
||||
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing')
|
||||
} catch (error) {
|
||||
failures.push(`${packageName}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
rmSync(stagedPackageDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-built-package-invariants: packed companion failures:')
|
||||
console.error('verify-built-package-invariants: compiled companion failures:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-built-package-invariants: ${manifests.length} packed companion(s) passed plain-Node Loader checks.`)
|
||||
console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
|
||||
|
||||
function parseOptions(args) {
|
||||
const allowed = new Set(['--packages-root', '--loader-url'])
|
||||
const parsed = new Map()
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const name = args[index]
|
||||
const value = args[index + 1]
|
||||
if (!allowed.has(name) || value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`)
|
||||
}
|
||||
if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`)
|
||||
parsed.set(name, value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) {
|
||||
for (const pattern of files) {
|
||||
if (!pattern.startsWith('lib/')) continue
|
||||
for (const relativePath of globSync(pattern, { cwd: packageDir })) {
|
||||
const source = resolve(packageDir, relativePath)
|
||||
if (!existsSync(source)) continue
|
||||
const target = resolve(stagedPackageDir, relativePath)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
cpSync(source, target, { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
88
scripts/verify-built-package-invariants.spec.ts
Normal file
88
scripts/verify-built-package-invariants.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const verifier = fileURLToPath(new URL('./verify-built-package-invariants.mjs', import.meta.url))
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(options: {
|
||||
invariantSource?: string
|
||||
invariantExport?: string
|
||||
runtimeChunk?: string
|
||||
} = {}): { root: string; loaderUrl: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-built-package-invariants-'))
|
||||
roots.push(root)
|
||||
const packageDir = join(root, 'packages/core/probe')
|
||||
mkdirSync(join(packageDir, 'lib'), { recursive: true })
|
||||
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-probe',
|
||||
type: 'module',
|
||||
files: ['lib/invariant.js'],
|
||||
exports: {
|
||||
'./invariant': {
|
||||
default: options.invariantExport ?? './lib/invariant.js',
|
||||
},
|
||||
},
|
||||
}, null, 2)}\n`)
|
||||
writeFileSync(
|
||||
join(packageDir, 'lib/invariant.js'),
|
||||
options.invariantSource ?? "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
|
||||
)
|
||||
if (options.runtimeChunk !== undefined) {
|
||||
writeFileSync(join(packageDir, 'lib/chunk.js'), options.runtimeChunk)
|
||||
}
|
||||
const loaderPath = join(root, 'loader.mjs')
|
||||
writeFileSync(loaderPath, 'export default class Loader { unwrapExports(value) { return value } }\n')
|
||||
return { root, loaderUrl: pathToFileURL(loaderPath).href }
|
||||
}
|
||||
|
||||
function verify(root: string, loaderUrl: string) {
|
||||
return spawnSync(process.execPath, [
|
||||
verifier,
|
||||
'--packages-root', root,
|
||||
'--loader-url', loaderUrl,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000,
|
||||
})
|
||||
}
|
||||
|
||||
describe('built package invariant verifier', () => {
|
||||
it('loads the staged compiled self-reference through plain Node and Loader normalization', () => {
|
||||
const { root, loaderUrl } = fixture()
|
||||
const result = verify(root, loaderUrl)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('1 compiled companion(s) passed plain-Node Loader checks')
|
||||
})
|
||||
|
||||
it('rejects a default export and a broken invariant export map', () => {
|
||||
const withDefault = fixture({
|
||||
invariantSource: "export default {}\nexport const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
|
||||
})
|
||||
const defaultResult = verify(withDefault.root, withDefault.loaderUrl)
|
||||
expect(defaultResult.status).toBe(1)
|
||||
expect(defaultResult.stderr).toContain('companion has a default export')
|
||||
|
||||
const brokenExport = fixture({ invariantExport: './lib/missing.js' })
|
||||
const exportResult = verify(brokenExport.root, brokenExport.loaderUrl)
|
||||
expect(exportResult.status).toBe(1)
|
||||
expect(exportResult.stderr).toContain('@deepseek-ai/dsh-probe')
|
||||
})
|
||||
|
||||
it('rejects an invariant bundle that needs an unstaged runtime chunk', () => {
|
||||
const { root, loaderUrl } = fixture({
|
||||
invariantSource: "export * from './chunk.js'\n",
|
||||
runtimeChunk: "export const name = 'probe-invariant'\nexport const inject = ['invariants']\nexport const apply = () => {}\n",
|
||||
})
|
||||
const result = verify(root, loaderUrl)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('chunk.js')
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,23 @@ const windowsCoverageExclusions = process.platform === 'win32'
|
||||
]
|
||||
: []
|
||||
|
||||
const testIncludes = [
|
||||
'packages/*/*/tests/**/*.spec.{ts,tsx}',
|
||||
'examples/*/tests/**/*.spec.ts',
|
||||
'scripts/**/*.spec.ts',
|
||||
]
|
||||
|
||||
// These suites exercise process-global state, process APIs, or timing-sensitive process I/O
|
||||
// that worker threads cannot isolate reliably under aggregate gate contention.
|
||||
// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes.
|
||||
const processBoundTests = [
|
||||
'packages/bash/bash-local/tests/run.spec.ts',
|
||||
'packages/context/time-context/tests/time-context.spec.ts',
|
||||
'packages/llm/llm-pi-ai/tests/adapter.spec.ts',
|
||||
'packages/ui/app-boot/tests/app-boot.spec.ts',
|
||||
'packages/workflow/workflow-workerthread/tests/session.spec.ts',
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
// Native path resolution reads each package's nearest tsconfig, but only the root defines
|
||||
// workspace paths. Keep this plugin pinned to the root map so bare package imports resolve
|
||||
@@ -32,8 +49,35 @@ export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./scripts/test-invariants.ts'],
|
||||
// .tsx: client component specs (jsdom via per-file @vitest-environment pragma).
|
||||
include: ['packages/*/*/tests/**/*.spec.{ts,tsx}', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'],
|
||||
include: testIncludes,
|
||||
exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`),
|
||||
// One coverage invocation aggregates both projects. Most suites use threads
|
||||
// for lower startup/IPC overhead; only explicit process-bound suites fork.
|
||||
projects: [
|
||||
{
|
||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.vitest.json'] })],
|
||||
test: {
|
||||
name: 'thread-safe',
|
||||
pool: 'threads',
|
||||
setupFiles: ['./scripts/test-invariants.ts'],
|
||||
include: testIncludes,
|
||||
exclude: [
|
||||
...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`),
|
||||
...processBoundTests,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.vitest.json'] })],
|
||||
test: {
|
||||
name: 'process-bound',
|
||||
pool: 'forks',
|
||||
setupFiles: ['./scripts/test-invariants.ts'],
|
||||
include: processBoundTests,
|
||||
exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`),
|
||||
},
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
// Coverage measures OUR runtime source. Types-only files carry no
|
||||
|
||||
Reference in New Issue
Block a user