From 9840d39ba00729e2634a5093f9d11ea3c4b21555 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:36:28 +0800 Subject: [PATCH 1/2] feat(release): rehearse a vendored publication with a prerelease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release:vendor --prerelease rc.1 appends the identifier to the version each package would take, so a first publication can be thrown away instead of holding the stable dist-tag: publish already routes any version with a prerelease segment to --tag next. A prerelease does not consume its release numbers, so the stable release that follows takes the same ones rather than skipping a patch. Deciding that needs semver precedence, which git's version sort does not provide — it places 4.0.1-rc.1 above 4.0.1 — so the newest published version is chosen by comparing versions here, numeric prerelease fields numerically. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 2 +- .../2026-08-10-npm-release-sequences.zh.md | 2 +- scripts/release/bump.ts | 117 +++++++++++++++--- scripts/release/families.spec.ts | 32 ++++- 5 files changed, 136 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 61728f400d..52d911bb5f 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index d51054b90a..df81756ab8 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.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--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. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index 8c2b7b048a..03269aeb98 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.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 里:没有它,真实的源码改动会读成「没变化」,而下一次发布会在一个字节已变的版本上失败。 diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 0f50ea6a9c..e79b7a0e9c 100644 --- a/scripts/release/bump.ts +++ b/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 [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 ') + 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) { diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 054fd900ac..65ebd078ee 100644 --- a/scripts/release/families.spec.ts +++ b/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', () => { From 4cd77a5ad94bf9493203f896be141d0ff8391ab4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:46:10 +0800 Subject: [PATCH 2/2] release(vendor): cordis 4.0.1-rc.1, cosmokit 1.8.2-rc.1, group 1.0.1-rc.1, hmr 1.0.16-rc.1, include 1.0.5-rc.1, loader 1.0.1-rc.1, logger-console 1.0.1-rc.1, schemastery 3.18.1-rc.1, timer 1.1.3-rc.1 --- vendor/cordis/package.json | 2 +- vendor/cosmokit/package.json | 2 +- vendor/group/package.json | 2 +- vendor/hmr/package.json | 2 +- vendor/include/package.json | 2 +- vendor/loader/package.json | 2 +- vendor/logger-console/package.json | 2 +- vendor/schemastery/package.json | 2 +- vendor/timer/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index ef4df3eb1d..66a5f1593d 100644 --- a/vendor/cordis/package.json +++ b/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" }, diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index de1b3947bc..0016ec0bb4 100644 --- a/vendor/cosmokit/package.json +++ b/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" }, diff --git a/vendor/group/package.json b/vendor/group/package.json index b5e07fdb66..03baae1394 100644 --- a/vendor/group/package.json +++ b/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" }, diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 68617b430e..e231701881 100644 --- a/vendor/hmr/package.json +++ b/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" }, diff --git a/vendor/include/package.json b/vendor/include/package.json index de1509c92b..1105e13404 100644 --- a/vendor/include/package.json +++ b/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" }, diff --git a/vendor/loader/package.json b/vendor/loader/package.json index 62e88b2594..a40bebc668 100644 --- a/vendor/loader/package.json +++ b/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" }, diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index 29b4b423ab..ceb1c0bd75 100644 --- a/vendor/logger-console/package.json +++ b/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" }, diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 26cfd9b937..76f3748a5b 100644 --- a/vendor/schemastery/package.json +++ b/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" }, diff --git a/vendor/timer/package.json b/vendor/timer/package.json index a8138fae67..211dba0036 100644 --- a/vendor/timer/package.json +++ b/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" },