Kaynağa Gözat

Merge pull request #2988 from deepseek-harness/worktree-npmalpha

feat(release): add DSH alpha and canary channels
imccyu 3 hafta önce
ebeveyn
işleme
5761890711

+ 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: f83bce914bae4cfc5a047bf975b623579dc9156f
-2026-08-10-npm-release-sequences.zh.md: c24e5c0a29af2f875136d439ce3fe06577c061b1
+2026-08-10-npm-release-sequences.md: 46c5620dd1180132b4a590088b6edb1892fe7f9a
+2026-08-10-npm-release-sequences.zh.md: 2c282c0cc77ea6414c1cf906ea988c1230aa2289

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

@@ -32,7 +32,9 @@ 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. A prerelease such as `0.0.1-rc.1` drives pack, the installed-artifact probe, and one real private publication before numbered versions follow. The dist-tag decision is the one `landlock-run-release.yml` already made: a version with a prerelease segment publishes under `--tag next`, anything else takes `latest`.
+`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.
+
+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.
 
 ### vendor: publish what changed, and let tags be the ledger
 

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

@@ -32,7 +32,9 @@ 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-rc.1` 这样的预发布号先把 pack、已安装产物探针和一次真实私有发布跑通,数字版本随后。dist-tag 沿用 `landlock-run-release.yml` 已有的判定:版本带预发布段就 `--tag next`,否则进 `latest`。
+`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 规则。
+
+基础版本号相同时,SemVer 按字典序比较字母数字型预发布标识:`alpha` 小于 `canary`,`canary` 小于 `rc`,所有预发布版本都小于稳定版本。npm dist-tag 是可变别名,不参与版本优先级比较。
 
 ### vendor:谁改了谁发版,tag 就是账本
 

+ 34 - 0
scripts/release/families.spec.ts

@@ -67,6 +67,22 @@ describe('release families', () => {
     ])
   })
 
+  it.each(['0.0.2-alpha.1', '0.0.2-canary.1', '0.0.2-rc.1'])(
+    'accepts the explicit dsh prerelease version %s',
+    (version) => {
+      const root = mkdtempSync(join(tmpdir(), 'dsh-release-prerelease-'))
+      roots.push(root)
+      write(join(root, 'package.json'), '{"version":"0.0.1"}\n')
+
+      const dsh = releaseFamily('dsh')
+      const published = member('packages/core/published', '@deepseek-ai/dsh-published')
+      const plan = planShared(dsh, root, [published], version)
+
+      expect(plan.version).toBe(version)
+      expect(plan.planned[1]?.tag).toBe(`dsh-v${version}`)
+    },
+  )
+
   it('names one tag for the whole dsh family and one per vendored package', () => {
     const dsh = releaseFamily('dsh')
     const vendor = releaseFamily('vendor')
@@ -81,6 +97,18 @@ describe('release families', () => {
     expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7')
   })
 
+  it('assigns alpha and canary dist-tags only to dsh releases', () => {
+    const dsh = releaseFamily('dsh')
+    const vendor = releaseFamily('vendor')
+
+    expect(dsh.distTagForVersion('0.0.2-alpha.1')).toBe('alpha')
+    expect(dsh.distTagForVersion('0.0.2-canary.1')).toBe('canary')
+    expect(dsh.distTagForVersion('0.0.2-rc.1')).toBe('next')
+    expect(dsh.distTagForVersion('0.0.2')).toBeUndefined()
+    expect(vendor.distTagForVersion('4.0.1-alpha.1')).toBe('next')
+    expect(vendor.distTagForVersion('4.0.1-canary.1')).toBe('next')
+  })
+
   it('rejects a family whose members disagree on the shared version', () => {
     const dsh = releaseFamily('dsh')
     const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-web-frontend'), version: '0.0.2' }]
@@ -277,6 +305,12 @@ describe('vendored version baseline', () => {
 })
 
 describe('version precedence', () => {
+  it('orders alpha, canary, and release-candidate versions by semver precedence', () => {
+    expect(compareVersions('4.0.1-alpha.1', '4.0.1-canary.1')).toBeLessThan(0)
+    expect(compareVersions('4.0.1-canary.1', '4.0.1-rc.1')).toBeLessThan(0)
+    expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
+  })
+
   it('ranks a release above the prerelease it follows', () => {
     // git --sort=v:refname disagrees, placing 4.0.1-rc.1 above 4.0.1, which is
     // why the newest published version is chosen here rather than by git.

+ 17 - 0
scripts/release/families.ts

@@ -285,6 +285,15 @@ export abstract class ReleaseFamily {
    */
   abstract tagPrefixFor(member: ReleaseMember): string
 
+  /**
+   * The npm dist-tag assigned while publishing a version.
+   * @param version - package version from the packed manifest.
+   * @returns `next` for a prerelease, or undefined so npm uses `latest`.
+   */
+  distTagForVersion(version: string): string | undefined {
+    return version.includes('-') ? 'next' : undefined
+  }
+
   /**
    * The tag a member publishes from.
    * @param member - the member being published.
@@ -339,6 +348,14 @@ class DshFamily extends ReleaseFamily {
     return this.tagPrefix
   }
 
+  override distTagForVersion(version: string): string | undefined {
+    const separator = version.indexOf('-')
+    if (separator === -1) return undefined
+    const [channel] = version.slice(separator + 1).split('.')
+    if (channel === 'alpha' || channel === 'canary') return channel
+    return 'next'
+  }
+
   /**
    * Reject source and declaration-map members, the repository's publication policy.
    * @param member - the packed member.

+ 9 - 4
scripts/release/publish.ts

@@ -93,10 +93,15 @@ function registryState(name: string, version: string): RegistryState {
  * @param tarball - absolute tarball path.
  * @param name - package name the tarball declares.
  * @param version - package version the tarball declares.
+ * @param distTag - explicit npm dist-tag, or undefined for npm's `latest` default.
  */
-async function publishTarball(tarball: string, name: string, version: string): Promise<void> {
-  // A prerelease version never takes the latest dist-tag.
-  const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
+async function publishTarball(
+  tarball: string,
+  name: string,
+  version: string,
+  distTag: string | undefined,
+): Promise<void> {
+  const tagArgs = distTag === undefined ? [] : ['--tag', distTag]
   for (let tries = 1; tries <= PUBLISH_ATTEMPTS; tries += 1) {
     // No --access: the sequences do not share one access level, so a
     // command-line flag could not serve both and would override the manifest
@@ -164,7 +169,7 @@ async function main(): Promise<void> {
     // Space out the writes: the gap belongs between publishes, so a run that
     // only skips does not wait at all.
     if (published > 0) await sleep(PUBLISH_SPACING_MS)
-    await publishTarball(tarball, name, version)
+    await publishTarball(tarball, name, version, family.distTagForVersion(version))
     console.log(`release publish: ${progress} ${name}@${version} published`)
     published += 1
   }