Pārlūkot izejas kodu

Merge pull request #2218 from deepseek-harness/release/cordis-4.0.1-rc.1

Release: cordis 4.0.1 rc.1
imccyu 1 mēnesi atpakaļ
vecāks
revīzija
41360d98bc

+ 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: d51054b90aa0acd82d252cdb6e97dc1f3c0e51b5
-2026-08-10-npm-release-sequences.zh.md: 8c2b7b048af407a79f62c5842f0bd04790a61c9a
+2026-08-10-npm-release-sequences.md: df81756ab84163b21996b5e2f12c5c8db994d9a5
+2026-08-10-npm-release-sequences.zh.md: 03269aeb987509034564bd0cac92f5d26c7f9b58

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

@@ -50,7 +50,7 @@ The vendored packages are decoupled from upstream by their scope but keep their
 | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 |
 | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 |
 
-Taking the last published version as the baseline is what survives a re-sync: upstream restoring `4.0.0-rc.8` after this repository published `4.0.1` would otherwise compute `4.0.1` again and collide.
+Taking the last published version as the baseline is what survives a re-sync: upstream restoring `4.0.0-rc.8` after this repository published `4.0.1` would otherwise compute `4.0.1` again and collide. `--prerelease rc.1` publishes a rehearsal instead, which takes `--tag next` and leaves the release numbers free: a prerelease has lower precedence than the release it precedes, so `4.0.1` still follows `4.0.1-rc.1`. That ordering is computed here rather than read from `git tag --sort=v:refname`, which places a prerelease above its release.
 
 Only changed packages publish, and the change judgement adds no state file: **each package has its own tag, and that tag records the commit it last published from**. For each package, bump reads the newest `vendor-<package>-v*` tag and diffs the package directory against it. A path counts when the manifest's `files` selects it, when npm publishes it regardless (`package.json`, `README*`, `LICENSE*`), or — for a package whose `files` selects `lib/` — when it is a build input (`src/**`, `tsconfig*.json`, a build config). That last rule exists because a built payload is not tracked by git: without it, a real source change reads as "nothing changed" and the next publication fails on a version whose bytes moved.
 

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

@@ -50,7 +50,7 @@ vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。
 | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 |
 | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 |
 
-以「上次发布版本」为基线才扛得住重同步:本仓发过 `4.0.1` 之后上游把版本恢复成 `4.0.0-rc.8`,只看 manifest 会再算出 `4.0.1` 并撞上已发版本。
+以「上次发布版本」为基线才扛得住重同步:本仓发过 `4.0.1` 之后上游把版本恢复成 `4.0.0-rc.8`,只看 manifest 会再算出 `4.0.1` 并撞上已发版本。加 `--prerelease rc.1` 则发一次排练版:它进 `--tag next`,而且不占用那组数字——预发布的优先级低于它所先行的正式版,所以 `4.0.1` 仍然接在 `4.0.1-rc.1` 之后。这个次序由脚本自己算,不读 `git tag --sort=v:refname`——git 会把预发布排在正式版之前。
 
 只发改动过的包,而变更判据不引入新的状态文件:**每包一个 tag,tag 就是「上次发布到哪个 commit」的记录**。bump 对每个包取最新的 `vendor-<包名>-v*` tag,拿包目录与它做 diff。一条路径算命中的条件是:manifest 的 `files` 选中它,或 npm 无论如何都会发布它(`package.json`、`README*`、`LICENSE*`),或者——当该包的 `files` 选中 `lib/` 时——它是构建输入(`src/**`、`tsconfig*.json`、构建配置)。最后那条规则的存在理由是构建产物不在 git 里:没有它,真实的源码改动会读成「没变化」,而下一次发布会在一个字节已变的版本上失败。
 

+ 101 - 16
scripts/release/bump.ts

@@ -73,6 +73,55 @@ function compareReleaseNumbers(left: string, right: string): number {
   return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch
 }
 
+/**
+ * The prerelease segment of a version, or undefined when it has none.
+ * @param version - the version to read.
+ * @returns The segment after the first `-`.
+ */
+function prereleaseOf(version: string): string | undefined {
+  const index = version.indexOf('-')
+  return index === -1 ? undefined : version.slice(index + 1)
+}
+
+/**
+ * Order two versions by semver precedence.
+ *
+ * Git's version sort cannot stand in for this: `--sort=v:refname` places
+ * `4.0.1-rc.1` above `4.0.1`, while semver gives a prerelease lower precedence
+ * than the release it precedes. Prerelease identifiers compare field by field,
+ * numeric fields numerically, so `rc.10` outranks `rc.1`.
+ * @param left - one version.
+ * @param right - the other version.
+ * @returns Negative when `left` is lower, positive when higher, zero when equal.
+ */
+export function compareVersions(left: string, right: string): number {
+  const numbers = compareReleaseNumbers(left, right)
+  if (numbers !== 0) return numbers
+  const leftPre = prereleaseOf(left)
+  const rightPre = prereleaseOf(right)
+  if (leftPre === undefined || rightPre === undefined) {
+    if (leftPre === rightPre) return 0
+    return leftPre === undefined ? 1 : -1
+  }
+  const leftFields = leftPre.split('.')
+  const rightFields = rightPre.split('.')
+  for (let index = 0; index < Math.max(leftFields.length, rightFields.length); index += 1) {
+    const leftField = leftFields[index]
+    const rightField = rightFields[index]
+    // A shorter identifier list has lower precedence when all its fields match.
+    if (leftField === undefined) return -1
+    if (rightField === undefined) return 1
+    if (leftField === rightField) continue
+    const leftNumeric = /^\d+$/.test(leftField)
+    const rightNumeric = /^\d+$/.test(rightField)
+    if (leftNumeric && rightNumeric) return Number(leftField) - Number(rightField)
+    // Numeric fields have lower precedence than alphanumeric ones.
+    if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
+    return leftField < rightField ? -1 : 1
+  }
+  return 0
+}
+
 /**
  * The next dsh version.
  * @param current - the family's current shared version.
@@ -93,20 +142,36 @@ function nextSharedVersion(current: string, request: string): string {
 }
 
 /**
- * The version a vendored package publishes next: the higher of its manifest
- * version and its last published version, with the patch incremented.
+ * The version a vendored package publishes next.
  *
- * The manifest alone is not the baseline. A vendor re-sync restores upstream's
- * version, which is lower than what this repository already published, and
- * incrementing that would name a version the registry already carries.
+ * The baseline is the higher of the manifest version and the last published
+ * version: a vendor re-sync restores upstream's version, which is lower than
+ * what this repository already published, and incrementing that would name a
+ * version the registry already carries.
+ *
+ * A prerelease does not consume its own release numbers. Publishing
+ * `4.0.1-rc.1` leaves `4.0.1` free, so the next stable version is `4.0.1`
+ * rather than `4.0.2`, and a second prerelease keeps those numbers too.
  * @param current - the package's manifest version.
  * @param published - the version its newest tag names, when it has one.
+ * @param prerelease - prerelease identifier to append, for a rehearsal publication.
  * @returns The target version.
  */
-export function nextVendorVersion(current: string, published: string | undefined): string {
-  const baseline = published !== undefined && compareReleaseNumbers(published, current) > 0 ? published : current
+export function nextVendorVersion(
+  current: string,
+  published: string | undefined,
+  prerelease?: string,
+): string {
+  const ahead = published !== undefined && compareReleaseNumbers(published, current) > 0
+  const baseline = ahead ? published : current
   const [major, minor, patch] = releaseNumbers(baseline)
-  return `${String(major)}.${String(minor)}.${String(patch + 1)}`
+  // Reuse the numbers when the published version that set them is a prerelease
+  // of them; increment when a stable release already holds them.
+  const reuse = ahead && published.includes('-')
+  const numbers = reuse
+    ? `${String(major)}.${String(minor)}.${String(patch)}`
+    : `${String(major)}.${String(minor)}.${String(patch + 1)}`
+  return prerelease === undefined ? numbers : `${numbers}-${prerelease}`
 }
 
 /**
@@ -133,9 +198,10 @@ export function reachesPayload(member: ReleaseMember, path: string): boolean {
  */
 function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined {
   const prefix = family.tagPrefixFor(member)
-  const [newest] = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname'])
-    .split('\n').filter(line => line !== '')
-  return newest === undefined ? undefined : newest.slice(prefix.length)
+  const versions = capture('git', ['tag', '--list', `${prefix}*`])
+    .split('\n').filter(line => line !== '').map(tag => tag.slice(prefix.length))
+  if (versions.length === 0) return undefined
+  return versions.reduce((newest, candidate) => compareVersions(candidate, newest) > 0 ? candidate : newest)
 }
 
 /**
@@ -231,9 +297,14 @@ function planShared(
  * it last published.
  * @param family - the vendored family.
  * @param members - the family's members.
+ * @param prerelease - prerelease identifier to append, for a rehearsal publication.
  * @returns The manifests to rewrite.
  */
-function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[]): PlannedVersion[] {
+function planPerPackage(
+  family: ReleaseFamily,
+  members: readonly ReleaseMember[],
+  prerelease: string | undefined,
+): PlannedVersion[] {
   const planned: PlannedVersion[] = []
   for (const member of members) {
     const published = lastPublishedVersion(family, member)
@@ -244,7 +315,7 @@ function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[]
         .split('\n').filter(line => line !== '')
       if (!changed.some(path => reachesPayload(member, path))) continue
     }
-    const to = nextVendorVersion(member.version, published)
+    const to = nextVendorVersion(member.version, published, prerelease)
     planned.push({
       manifestPath: join(member.directory, 'package.json'),
       label: member.directory,
@@ -256,10 +327,18 @@ function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[]
   return planned
 }
 
-/** Bump the family named by `--family` and commit; `--dry-run` only reports the plan. */
+/**
+ * Bump the family named by `--family` and commit; `--dry-run` only reports the
+ * plan. `--prerelease rc.1` makes the vendored family publish a rehearsal
+ * version, which never takes the stable dist-tag.
+ */
 function main(): void {
   const { values, positionals } = parseArgs({
-    options: { family: { type: 'string' }, 'dry-run': { type: 'boolean', default: false } },
+    options: {
+      family: { type: 'string' },
+      prerelease: { type: 'string' },
+      'dry-run': { type: 'boolean', default: false },
+    },
     allowPositionals: true,
   })
   if (values.family === undefined) throw new Error('usage: bump.ts --family <dsh|vendor> [version]')
@@ -274,12 +353,18 @@ function main(): void {
   if (family.id === 'dsh') {
     const request = positionals[0]
     if (request === undefined) throw new Error('usage: release:dsh <major|minor|patch|x.y.z>')
+    if (values.prerelease !== undefined) {
+      throw new Error('release:dsh takes the prerelease in its version argument, as in 0.0.1-rc.1')
+    }
     const shared = planShared(family, root, members, request)
     planned = shared.planned
     sharedVersion = shared.version
   } else {
     if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch')
-    planned = planPerPackage(family, members)
+    if (values.prerelease !== undefined && !/^[0-9A-Za-z.-]+$/.test(values.prerelease)) {
+      throw new Error(`--prerelease must be a semver prerelease identifier, got ${values.prerelease}`)
+    }
+    planned = planPerPackage(family, members, values.prerelease)
   }
 
   if (planned.length === 0) {

+ 31 - 1
scripts/release/families.spec.ts

@@ -2,7 +2,7 @@
 
 import { describe, expect, it } from 'vitest'
 import { releaseFamily, type ReleaseMember } from './families.ts'
-import { nextVendorVersion, reachesPayload } from './bump.ts'
+import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts'
 
 /**
  * A release member standing in for a manifest on disk.
@@ -109,6 +109,36 @@ describe('vendored version baseline', () => {
     expect(nextVendorVersion('4.0.0-rc.8', '4.0.1')).toBe('4.0.2')
     expect(nextVendorVersion('4.1.0', '4.0.1')).toBe('4.1.1')
   })
+
+  it('appends a rehearsal prerelease without consuming its release numbers', () => {
+    // A rehearsal burns 4.0.1-rc.1 and leaves 4.0.1 free, so the stable release
+    // that follows takes those same numbers instead of skipping to 4.0.2.
+    expect(nextVendorVersion('4.0.0-rc.7', undefined, 'rc.1')).toBe('4.0.1-rc.1')
+    expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1', 'rc.2')).toBe('4.0.1-rc.2')
+    expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1')).toBe('4.0.1')
+    expect(nextVendorVersion('4.0.0-rc.7', '4.0.1')).toBe('4.0.2')
+  })
+})
+
+describe('version precedence', () => {
+  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.
+    expect(compareVersions('4.0.1', '4.0.1-rc.1')).toBeGreaterThan(0)
+    expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
+  })
+
+  it('compares numeric prerelease fields numerically', () => {
+    expect(compareVersions('4.0.1-rc.10', '4.0.1-rc.1')).toBeGreaterThan(0)
+    expect(compareVersions('4.0.1-rc.2', '4.0.1-rc.10')).toBeLessThan(0)
+  })
+
+  it('ranks a numeric field below an alphanumeric one, and a shorter list below a longer', () => {
+    expect(compareVersions('4.0.1-1', '4.0.1-alpha')).toBeLessThan(0)
+    expect(compareVersions('4.0.1-rc', '4.0.1-rc.1')).toBeLessThan(0)
+    expect(compareVersions('4.0.2', '4.0.1')).toBeGreaterThan(0)
+    expect(compareVersions('4.0.1-rc.1', '4.0.1-rc.1')).toBe(0)
+  })
 })
 
 describe('payload change judgement', () => {

+ 1 - 1
vendor/cordis/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cordis",
   "description": "Meta-Framework for Modern JavaScript Applications",
-  "version": "4.0.0-rc.7",
+  "version": "4.0.1-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/cosmokit/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cosmokit",
   "description": "A collection of common utilities",
-  "version": "1.8.1",
+  "version": "1.8.2-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/group/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cordis-plugin-group",
   "description": "Nested plugin group for cordis",
-  "version": "1.0.0",
+  "version": "1.0.1-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/hmr/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cordis-plugin-hmr",
   "description": "Hot Module Replacement Plugin for Cordis",
-  "version": "1.0.15",
+  "version": "1.0.16-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/include/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cordis-plugin-include",
   "description": "Include files in cordis configurations",
-  "version": "1.0.4",
+  "version": "1.0.5-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/loader/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cordis-plugin-loader",
   "description": "Plugin loader for cordis",
-  "version": "1.0.0-rc.5",
+  "version": "1.0.1-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/logger-console/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cordis-plugin-logger-console",
   "description": "Console logger exporter for cordis",
-  "version": "1.0.0",
+  "version": "1.0.1-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/schemastery/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/schemastery",
   "description": "Type driven schema validator",
-  "version": "3.18.0",
+  "version": "3.18.1-rc.1",
   "publishConfig": {
     "access": "restricted"
   },

+ 1 - 1
vendor/timer/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/cordis-plugin-timer",
   "description": "Timer service for cordis",
-  "version": "1.1.2",
+  "version": "1.1.3-rc.1",
   "publishConfig": {
     "access": "restricted"
   },