Browse Source

Merge pull request #3533 from deepseek-harness/gate/package-version-drift

gate(constraints): enforce the dsh family version rule across apps and packages
Tianyi Cui 2 weeks ago
parent
commit
1d35bcfc8a

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
-2026-08-10-npm-release-sequences.md: 46c5620dd1180132b4a590088b6edb1892fe7f9a
-2026-08-10-npm-release-sequences.zh.md: 2c282c0cc77ea6414c1cf906ea988c1230aa2289
+2026-08-10-npm-release-sequences.md: 7e96c988f33f5753bd7a5e7f226e02a753899b8c
+2026-08-10-npm-release-sequences.zh.md: f46aa58c558bbd862b2101fb8614f02758c34b59

+ 1 - 1
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md

@@ -32,7 +32,7 @@ All three publish to the `@deepseek-ai` scope on npmjs.com, and access is per se
 
 Each sequence has one bump-and-commit command: it derives the target version, writes it into the relevant manifests, runs `pnpm install --lockfile-only`, and commits the manifests with the lockfile. The published version is therefore readable from the repository. A human creates the tag after the commit merges to master; CI never writes to the repository and needs no write permission.
 
-`release:dsh` accepts `major`, `minor`, `patch`, or an explicit version, and writes one version across the publishable family, every private package under `packages/*/*`, **and the workspace root**. Private packages receive no release tag and remain outside pack and publish; they follow the version because the workspace constraint requires every dsh package's version to equal the root's. The root check accepts a prerelease segment, so explicit versions such as `0.0.1-alpha.1`, `0.0.1-canary.1`, and `0.0.1-rc.1` drive the same pack, installed-artifact probe, and publication path. `dsh` publication maps `alpha` and `canary` to their matching npm dist-tags, maps other prereleases including `rc` to `next`, and leaves stable versions to npm's `latest` default. Other release families retain their own dist-tag policy.
+`release:dsh` accepts `major`, `minor`, `patch`, or an explicit version, and writes one version across the publishable family, every private package under `packages/*/*`, **and the workspace root**. Private packages receive no release tag and remain outside pack and publish; they follow the version because [the static version-coherence gate](2026-09-03-workspace-version-coherence-gate.md) requires every dsh package's version to equal the root's. The root check accepts a prerelease segment, so explicit versions such as `0.0.1-alpha.1`, `0.0.1-canary.1`, and `0.0.1-rc.1` drive the same pack, installed-artifact probe, and publication path. `dsh` publication maps `alpha` and `canary` to their matching npm dist-tags, maps other prereleases including `rc` to `next`, and leaves stable versions to npm's `latest` default. Other release families retain their own dist-tag policy.
 
 For equal release numbers, SemVer compares alphanumeric prerelease identifiers lexically: `alpha` is lower than `canary`, `canary` is lower than `rc`, and every prerelease is lower than the stable version. npm dist-tags are mutable aliases and do not participate in version precedence.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md

@@ -32,7 +32,7 @@ Status: implemented
 
 每条序列有一条 bump-and-commit 命令:算出目标版本,写进相关 manifest,跑 `pnpm install --lockfile-only`,再把 manifest 连 lockfile 一起 commit。发布版本因此在仓库里查得到。tag 由人工在 commit 合入 master 后打;CI 不写仓库,也不需要写权限。
 
-`release:dsh` 接受 `major`、`minor`、`patch` 或显式版本号,把同一个版本写进可发布族、`packages/*/*` 下的每个私有包**以及 workspace 根**。私有包不会获得发布 tag,仍位于 pack 与 publish 之外;它们跟随版本是因为 workspace 约束要求每个 dsh 包的版本等于根版本。根的检查接受预发布段,因此 `0.0.1-alpha.1`、`0.0.1-canary.1` 和 `0.0.1-rc.1` 等显式版本走同一条 pack、已安装产物探针和发布路径。发布 dsh 时,`alpha` 和 `canary` 分别映射到同名 npm dist-tag,包含 `rc` 在内的其他预发布版本映射到 `next`,稳定版本则沿用 npm 默认的 `latest`。其他发布家族保留各自的 dist-tag 规则。
+`release:dsh` 接受 `major`、`minor`、`patch` 或显式版本号,把同一个版本写进可发布族、`packages/*/*` 下的每个私有包**以及 workspace 根**。私有包不会获得发布 tag,仍位于 pack 与 publish 之外;它们跟随版本是因为[静态版本一致性门禁](2026-09-03-workspace-version-coherence-gate.zh.md)要求每个 dsh 包的版本等于根版本。根的检查接受预发布段,因此 `0.0.1-alpha.1`、`0.0.1-canary.1` 和 `0.0.1-rc.1` 等显式版本走同一条 pack、已安装产物探针和发布路径。发布 dsh 时,`alpha` 和 `canary` 分别映射到同名 npm dist-tag,包含 `rc` 在内的其他预发布版本映射到 `next`,稳定版本则沿用 npm 默认的 `latest`。其他发布家族保留各自的 dist-tag 规则。
 
 基础版本号相同时,SemVer 按字典序比较字母数字型预发布标识:`alpha` 小于 `canary`,`canary` 小于 `rc`,所有预发布版本都小于稳定版本。npm dist-tag 是可变别名,不参与版本优先级比较。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-03-workspace-version-coherence-gate.i18n.yaml

@@ -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 .agents/notes/implemented/process/2026-09-03-workspace-version-coherence-gate.md
+2026-09-03-workspace-version-coherence-gate.md: c1647631ca3a6b7db3620a507312ebc53b5d49a3
+2026-09-03-workspace-version-coherence-gate.zh.md: 13bb619ccd82a5cee0f364da0c34f34c30fae0ed

+ 29 - 0
.agents/notes/implemented/process/2026-09-03-workspace-version-coherence-gate.md

@@ -0,0 +1,29 @@
+# Agent Note: Workspace version coherence is a static gate
+
+Status: implemented
+
+English | [中文](2026-09-03-workspace-version-coherence-gate.zh.md)
+
+## Problem
+
+The dsh release sequence shares one version across its publishable members (`packages/` non-experimental members and `apps/*`), every private dsh package, and the workspace root; `release:dsh` writes that version, and the release lane's `verifyVersions` fails when the publishable members diverge. The always-run static lane enforced the same rule only for `packages/*`: a manifest named `@deepseek-ai/dsh-*` under `apps/`, or the root-named CLI manifest `@deepseek-ai/dsh`, carried the shared version with no static check of its own, and only the release lane's pack job (`release:verify`, `verify-npm-install-layout`) noticed a drift there.
+
+That coverage hole is how a mismatched version entered master on 2026-09-03. `packages/util/http-proxy` was added while the family carried `0.1.2-alpha.5`, and merged after the family had bumped to `0.1.2-rc.1`, with `0.1.2-alpha.5` still in its manifest. `constraints` failed on the merged state, and the release lane's Dependency layout and Pack npm tarballs jobs failed with it, reddening every pull request based on the broken master. The introducing pull request's last CI run predated the bump (2026-09-02 13:40, green; the bump merged 2026-09-03 03:21, the pull request merged 09:19 later), so every gate that would have caught the version ran green on a stale snapshot. No in-repo gate re-runs a pull request's checks when its base moves, and master had no branch protection requiring an up-to-date base.
+
+## Decision
+
+`check-workspace-constraints` owns the version rule for the whole family. Its `checkDshFamilyVersion` names the boundary: any scanned workspace manifest named `@deepseek-ai/dsh` or `@deepseek-ai/dsh-*` must carry the workspace root's version. The test is name-based, so it covers `packages/` (publishable and private/experimental members), `apps/`, and the root manifest itself, and it leaves the vendored framework and the Landlock sequence on their own version lines. The previous `packages/`-scoped comparison was the only static home of the rule; the shape checks next to it (cordis peer/dev pairing, `type`, `main`/`types`/`exports`, payload `files`) stay scoped to `packages/`.
+
+The boundary equals what `release:dsh` writes: the root, the publishable members, and every private dsh package under `packages/*/*`. A divergence therefore fails the zero-build static lane — `constraints` in `ci-static`, `ci-primary`, and `hygiene`, which run on every pull request and master push — instead of only surfacing in the release lane.
+
+## Alternatives considered
+
+**Importing the release family object for the member set.** The family object's `members()` discovers only publishable members, which is the release lane's boundary; the invariant also covers private packages, so the static gate would need the bump script's private discovery as a second rule. A name predicate states the whole boundary once and imports none of the release machinery.
+
+**Enforcing the rule only in the release lane.** That lane already fails on member drift and caught the apps/ gap, but it is a separate workflow whose runs have the same snapshot exposure as any other pull-request check; the shared static lane is one cheap check that puts the failure on the standard PR panel and protects the release lane itself.
+
+**Replacing the gate with the branch-protection setting.** Requiring an up-to-date base before merge (or a merge queue) is the protection that stops a stale-base merge from landing its green snapshot, and no repository-level gate sees a base move. It is a repository setting, not a change expressible in this repository; this gate closes the coverage half, and the setting remains the complementary protection.
+
+## Consequences
+
+Every current dsh-named manifest carries the root version — 252 manifests at `0.1.2-rc.1` plus the root itself as of 2026-09-03 — so the strengthened gate passes the present tree. A manifest that drifts anywhere in the family now fails static CI with an error naming the manifest, the root version, and the manifest's own version. The gate cannot prevent a stale-base merge: it observes only the snapshot its run was triggered against, so landing a merged state still requires the merged state to be checked, which is the branch-protection setting's role. The [release-sequence note](2026-08-10-npm-release-sequences.md) keeps the version scheme decision; this note records where the rule is enforced and the hole that enforcement previously left.

+ 29 - 0
.agents/notes/implemented/process/2026-09-03-workspace-version-coherence-gate.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 工作区版本一致性是静态门禁
+
+Status: implemented
+
+[English](2026-09-03-workspace-version-coherence-gate.md) | 中文
+
+## 问题
+
+dsh 发布序列在全部可发布成员(`packages/` 非实验成员与 `apps/*`)、每个私有 dsh 包与工作区根之间共享一个版本;`release:dsh` 写出该版本,发布通道的 `verifyVersions` 在可发布成员分歧时失败。常跑的静态通道只为 `packages/*` 执行同一条规则:位于 `apps/` 下的 `@deepseek-ai/dsh-*` 清单,或名字就是 `@deepseek-ai/dsh` 的 CLI 清单,携带共享版本却没有自己的静态检查,只有发布通道的 pack 任务(`release:verify`、`verify-npm-install-layout`)注意到这里的漂移。
+
+2026-09-03 版本失配进入 master 正是这个覆盖缺口。`packages/util/http-proxy` 在家族尚为 `0.1.2-alpha.5` 时被新增,却在家族升到 `0.1.2-rc.1` 之后才合入,其清单仍带 `0.1.2-alpha.5`。`constraints` 在合入后的状态上失败,发布通道的 Dependency layout 与 Pack npm tarballs 任务随之失败,所有基于该坏 master 的 PR 被带红。引入该包的 PR 最后一次 CI 运行早于升版(2026-09-02 13:40,绿色;升版 2026-09-03 03:21 合入,该 PR 于其约两小时后合入),因此每个本可抓住版本问题的门禁都在过期快照上跑出绿色。仓库内没有门禁会在 base 移动时重跑 PR 检查,而 master 也没有要求 base 最新的分支保护。
+
+## 决策
+
+`check-workspace-constraints` 现在拥有覆盖整个家族的版本规则。其 `checkDshFamilyVersion` 划定边界:任何被扫描的工作区清单,名字为 `@deepseek-ai/dsh` 或 `@deepseek-ai/dsh-*`,都必须携带工作区根的版本。该测试基于名字而非目录,因此覆盖 `packages/`(可发布与私有/实验成员)、`apps/` 与根清单本身,并让 vendored 框架与 Landlock 序列留在各自的版本线上。原 `packages/` 作用域的比较曾是该规则唯一的静态家园;与它相邻的形态检查(cordis peer/dev 配对、`type`、`main`/`types`/`exports`、发布 `files`)仍限定在 `packages/`。
+
+边界与 `release:dsh` 所写一致:根、可发布成员、以及 `packages/*/*` 下每个私有 dsh 包。因此分歧会让零构建静态通道失败——`ci-static`、`ci-primary` 与 `hygiene` 中的 `constraints`,它们跑在每次 PR 与 master 推送——而不是只在一个新快照的发布通道中浮现。
+
+## 曾考虑的替代方案
+
+**为成员集合导入发布家族对象。** 家族对象的 `members()` 只发现可发布成员,那是发布通道的边界;该不变式还覆盖私有包,静态门禁因此还得再写一遍 bump 脚本的私有包发现。一条名字谓词一次说清整个边界,并无需导入任何发布机制。
+
+**只在发布通道执行该规则。** 该通道本就对成员分歧失败,也抓住了 apps/ 缺口,但它是独立工作流,其运行与其他 PR 检查有同样的快照暴露;共享静态通道只需一次廉价检查,把失败放到标准 PR 面板上,并反过来保护发布通道自身。
+
+**以分支保护设置替代门禁。** 合并前要求 base 最新(或队列合并)才是阻止过期快照回合的机制,仓库级门禁看不到 base 的移动。这是仓库设置,不是本仓库能表达的改动;此门禁关闭的是覆盖这一半,该设置仍是互补的保护。
+
+## 后果
+
+当前每个 dsh 命名清单都携带根版本——截至 2026-09-03 有 252 个清单为 `0.1.2-rc.1`,根亦然——所以强化后的门禁通过现状树。家族内任何清单漂移都会让静态 CI 失败,报错点名清单、根的版本与该清单实际版本。门禁不能阻止过期 base 合入:它只观察其运行被触发时所处的快照,因此让合入后的状态被检查仍要求合入后的状态真正跑过检查,那是分支保护设置的职责。[发布序列笔记](2026-08-10-npm-release-sequences.zh.md) 保留版本方案决策;本笔记记录规则在哪里被强制,以及此前强制留下的窟窿。

+ 33 - 0
scripts/check-workspace-constraints.spec.ts

@@ -2,6 +2,7 @@
 
 import { describe, expect, it } from 'vitest'
 import {
+  checkDshFamilyVersion,
   checkExperimentalDependencyIsolation,
   checkExperimentalManifest,
   expectedDshPackageFiles,
@@ -76,6 +77,38 @@ describe('experimental workspace constraints', () => {
   })
 })
 
+describe('dsh family version coherence', () => {
+  it('rejects a package carrying a stale shared version', () => {
+    expect(checkDshFamilyVersion(
+      { name: '@deepseek-ai/dsh-http-proxy', version: '0.1.2-alpha.5' },
+      '0.1.2-rc.1',
+    )).toBe('@deepseek-ai/dsh-http-proxy: package.json version must match root version 0.1.2-rc.1')
+  })
+
+  it('rejects the root-named CLI app on a stale shared version', () => {
+    expect(checkDshFamilyVersion(
+      { name: '@deepseek-ai/dsh', version: '0.1.2-alpha.5' },
+      '0.1.2-rc.1',
+    )).toBe('@deepseek-ai/dsh: package.json version must match root version 0.1.2-rc.1')
+  })
+
+  it('accepts a manifest carrying the shared version', () => {
+    expect(checkDshFamilyVersion(
+      { name: '@deepseek-ai/dsh-http-proxy', version: '0.1.2-rc.1' },
+      '0.1.2-rc.1',
+    )).toBeUndefined()
+  })
+
+  it('leaves other sequences to their own version lines', () => {
+    expect(checkDshFamilyVersion({ name: '@deepseek-ai/cordis', version: '4.0.1' }, '0.1.2-rc.1')).toBeUndefined()
+    expect(checkDshFamilyVersion(
+      { name: '@deepseek-ai/node-addon-landlock-run', version: '0.1.1' },
+      '0.1.2-rc.1',
+    )).toBeUndefined()
+    expect(checkDshFamilyVersion({ version: '0.1.2-alpha.5' }, '0.1.2-rc.1')).toBeUndefined()
+  })
+})
+
 describe('package payload constraints', () => {
   it('includes a declared profile patch without a package-name allowlist', () => {
     expect(expectedDshPackageFiles({

+ 24 - 3
scripts/check-workspace-constraints.ts

@@ -262,6 +262,28 @@ export function checkExperimentalManifest({ dir, manifest }: WorkspaceManifest):
   return errors
 }
 
+/**
+ * Require a dsh-family manifest to carry the workspace version.
+ *
+ * The dsh release sequence publishes packages/ and apps/ members and every
+ * private dsh package on one shared version, written by `release:dsh` and
+ * shared with the workspace root. This name test is that boundary: it covers
+ * the family wherever the manifest lives, so apps/ members cannot drift with
+ * only the release lane noticing.
+ * @param manifest - the workspace package manifest.
+ * @param expected - the version every dsh-family manifest must carry (the root's).
+ * @returns one violation naming the manifest and the expected version, or
+ * undefined when the manifest is compliant or not in the family.
+ */
+export function checkDshFamilyVersion(manifest: PackageManifest, expected: string | undefined): string | undefined {
+  const name = manifest.name
+  if (name !== '@deepseek-ai/dsh' && name?.startsWith('@deepseek-ai/dsh-') !== true) return undefined
+  if (manifest.version !== expected) {
+    return `${name}: package.json version must match root version ${expected ?? '(missing)'}`
+  }
+  return undefined
+}
+
 /**
  * Check one workspace manifest against publication and dsh-package policy.
  * @param workspace - package directory and parsed manifest.
@@ -270,6 +292,8 @@ export function checkExperimentalManifest({ dir, manifest }: WorkspaceManifest):
 export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): string[] {
   const errors = checkExperimentalManifest({ dir, manifest })
   const label = manifest.name ?? dir
+  const familyVersionError = checkDshFamilyVersion(manifest, repositoryVersion)
+  if (familyVersionError !== undefined) errors.push(familyVersionError)
   const isLandlockPackageDir = dir.startsWith('native/landlock-run/packages/')
   const isPublicLandlockPackage = isLandlockPackageDir
     && manifest.name !== undefined
@@ -354,9 +378,6 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
     if (peer && dev && peer !== dev) {
       errors.push(`${label}: @deepseek-ai/cordis peer (${peer}) and dev (${dev}) ranges must match`)
     }
-    if (manifest.version !== repositoryVersion) {
-      errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`)
-    }
     if (manifest.type !== 'module') {
       errors.push(`${label}: package.json must set "type": "module"`)
     }