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 bc4017cc58..1158ff8785 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 @@ -21,8 +21,8 @@ en: 321f48c7e4afac24 zh: 974a7fa1a75c810d /agent-note-private-npm-publication-as-three-independent-sequences/decision/publication-runs-only-on-github-and-the-registry-decides-what-goes-out: - en: 8b255f6df8bd762f - zh: 2ed6d1a84efe3e44 + en: 8c8cd90e6eabe07d + zh: 7035b71a6eeeb379 /agent-note-private-npm-publication-as-three-independent-sequences/decision/workspace-internal-references-use-the-workspace-protocol: en: 82a7925d449fc283 zh: 653358a1ffbad80a @@ -42,8 +42,8 @@ en: c505237cad2c4128 zh: 55364ba462536680 /agent-note-private-npm-publication-as-three-independent-sequences/alternatives-considered: - en: b98c53537c33ba02 - zh: b9e3bddadff0d6d6 + en: ed22fa8a47b5c4b6 + zh: 7b367971a51a67ff /agent-note-private-npm-publication-as-three-independent-sequences/consequences: - en: 72c2be77f220dcda - zh: 64312d7065abe25e + en: 46f82110175ea3f7 + zh: 1cdd1c99bfb88440 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 35be8f9b3e..711b01154c 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 @@ -76,7 +76,7 @@ All three sequences decide this way, including the native one: it publishes thro Two registry behaviours shape how a publish is attempted. Writes are spaced by at least two seconds and retried with a backoff, because publishing several packages back to back outruns the registry's own processing and earns `E409 Failed to save packument`. And every retry re-reads the registry first: a reported failure can answer a write that landed anyway, so a version that now exists with this tarball's integrity counts as published rather than as a version to place again. -For dsh and vendor, `release:publish --dist-tag ` overrides the family's default publication channel. The vendor publish workflow accepts the optional `dist-tag` input; omitting it preserves the family default. An override must be a valid npm dist-tag. Before publishing any tarball, the publisher checks that tag for every packed member: an absent binding or one already naming the intended version is accepted; a binding to another version rejects the entire attempt. Existing integrity checks and retries still apply. Skipped versions are never retagged. +For dsh and vendor, `release:publish --dist-tag ` overrides the family's default publication channel. The vendor publish workflow accepts the optional `dist-tag` input; omitting it preserves the family default. An override must be a valid npm dist-tag. Before publishing any tarball, the publisher checks every packed member: the selected tag must already name the intended version, or both that tag and the version must be absent. A tag bound elsewhere, or an already published version without the selected tag, rejects the entire attempt before any upload. Existing integrity checks and retries still apply. Skipped versions are never retagged. The preflight does not reserve npm tags atomically. The release manager coordinates external publishers that could change a binding after the check. A dedicated dist-tag also does not exclude stable versions from existing dependency ranges: isolation requires prerelease versions and an audit of the actual packed dependency ranges. @@ -150,7 +150,7 @@ The installed-consumer probe captures npm's HTTP diagnostics and includes them w **Event-level tags (`vendor-r1`, `vendor-r2`).** Prepared for one release event carrying several package versions. Once the registry decides what publishes, the workflow no longer infers the set from the tag, so per-package tags suffice — and each one names its own package's real version. -**Putting the nine vendored packages on one `4.0.x` line.** It removes change detection, but cosmokit would jump from `1.8.1` to `4.0.1` and lose its upstream lineage; the upstream ranges inside the nine (`^1.8.1` and friends) would stop matching immediately, forcing a rewrite of the vendored manifests. +**Putting the nine vendored packages on one `4.0.x` line.** Cosmokit would jump from `1.8.1` to `4.0.1` and lose its upstream lineage; the upstream ranges inside the nine (`^1.8.1` and friends) would stop matching immediately, forcing a rewrite of the vendored manifests. **Publishing only changed vendor directories.** A directory diff does not establish that repacking from another repository state produces identical bytes. Advancing all nine packages avoids integrity collisions for unchanged directories at the cost of additional versions. @@ -170,7 +170,7 @@ The installed-consumer probe captures npm's HTTP diagnostics and includes them w ## Consequences -The release scripts are importable modules behind a guarded entry point, and their judgements carry unit tests: tag naming, publish order and cycle reporting, version-baseline arithmetic, the payload change judgement, and each family's payload policy. Two defects the first draft carried — a publish command that ran the pack command on import, and a change judgement blind to `vendor/cordis` source edits — are exactly what a test at that seam catches. +The release scripts are importable modules behind a guarded entry point, and their judgements carry unit tests: tag naming, publish order and cycle reporting, version-baseline arithmetic, and each family's payload policy. The entry guard prevents an import from executing a release command. A pull request runs the full pack for both sequences without credentials and installs the packed dsh tarballs into a throwaway consumer, where plain Node drives `dsh --version`. That probe is deliberately one command: it proves `files` selected a complete payload and that the published ranges resolve, and says nothing about interactive behavior. 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 9a797c3b97..871221f86e 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 @@ -76,7 +76,7 @@ tag 预留包版本并标识其 commit,不代表发布成功。即使对应发 registry 的两个行为决定了「怎么尝试一次发布」。写入之间至少间隔两秒并带退避重试,因为连续背靠背发多个包会超出 registry 自身的处理速度,换来 `E409 Failed to save packument`。而每次重试都先重查 registry:报出来的失败可能对应一次其实已经落地的写入,所以「该版本现在存在且 integrity 与本 tarball 相同」算作已发布,而不是又一个待放置的版本。 -对 dsh 和 vendor,`release:publish --dist-tag ` 覆盖该族默认发布通道。vendor 发布 workflow 接受可选的 `dist-tag` 输入;省略时保留该族默认值。覆盖值必须是合法的 npm dist-tag。发布任何 tarball 前,publisher 检查每个打包成员的该 tag:允许尚未绑定或已指向预期版本;若指向其他版本,则拒绝整次发布。原有 integrity 检查与重试继续生效。跳过的版本不会重新绑定 tag。 +对 dsh 和 vendor,`release:publish --dist-tag ` 覆盖该族默认发布通道。vendor 发布 workflow 接受可选的 `dist-tag` 输入;省略时保留该族默认值。覆盖值必须是合法的 npm dist-tag。发布任何 tarball 前,publisher 检查每个打包成员:所选 tag 必须已指向预期版本,或者该 tag 和版本都不存在。tag 指向其他版本,或版本已经发布但缺少所选 tag,都会在上传前拒绝整次发布。原有 integrity 检查与重试继续生效。跳过的版本不会重新绑定 tag。 预检不会原子性地预留 npm tag。发布负责人协调可能在检查后更改绑定的外部发布者。专用 dist-tag 也不会让稳定版本被已有依赖范围排除:隔离需要预发布版本,并核查实际打包产物中的依赖范围。 @@ -150,7 +150,7 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven **事件级 tag(`vendor-r1`、`vendor-r2`)。** 为「一次发布事件携带多个包版本」准备。既然由 registry 决定发什么,workflow 就不再从 tag 推断集合,per-package tag 够用,而且每个 tag 携带的是它自己那个包的真实版本。 -**把九个 vendored 包统一到一条 `4.0.x` 版本线。** 省掉变更检测,但 cosmokit 会从 `1.8.1` 跳到 `4.0.1`、丢失上游血缘;九包内部的上游范围(`^1.8.1` 之类)会立刻失配,必须改写 vendored manifest。 +**把九个 vendored 包统一到一条 `4.0.x` 版本线。** Cosmokit 会从 `1.8.1` 跳到 `4.0.1`、丢失上游血缘;九包内部的上游范围(`^1.8.1` 之类)会立刻失配,必须改写 vendored manifest。 **只发布目录有变更的 vendor 包。** 目录 diff 不能证明从不同仓库状态重新打包会产出相同字节。递增全部九个包可避免未改目录的 integrity 冲突,代价是增加版本号。 @@ -170,7 +170,7 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven ## 后果 -发布脚本是带入口守卫的可 import 模块,其判断都有单测覆盖:tag 命名、发布顺序与环报告、版本基线运算、payload 变更判据,以及各族的 payload 策略。第一版带过的两个缺陷——publish 命令在 import 时执行了 pack 命令、变更判据对 `vendor/cordis` 的源码改动失明——正是这类测试在对应接缝上能抓住的。 +发布脚本是带入口守卫的可 import 模块,其判断都有单测覆盖:tag 命名、发布顺序与环报告、版本基线运算,以及各族的 payload 策略。入口守卫阻止 import 执行发布命令。 一个 pull request 会为两条序列跑完整的 pack(无凭据),并把打包好的 dsh tarball 装进一次性 consumer,用普通 Node 驱动 `dsh --version`。这个探针刻意只有一条命令:它证明 `files` 选出了完整 payload、发布出去的范围可解析,不涉及任何交互行为。 diff --git a/scripts/release/publish.spec.ts b/scripts/release/publish.spec.ts index 00cf72e623..338df6aa7b 100644 --- a/scripts/release/publish.spec.ts +++ b/scripts/release/publish.spec.ts @@ -31,6 +31,7 @@ vi.mock('node:timers/promises', async importOriginal => ({ const CHANNEL = 'dsh-0-2-1-alpha-1' const ABSENT: CommandResult = { status: 1, stdout: '', stderr: 'npm error code E404' } const SUCCESS: CommandResult = { status: 0, stdout: '', stderr: '' } +const CONFLICT: CommandResult = { status: 1, stdout: '', stderr: 'npm error code E409 Failed to save packument' } interface PackedFixture { readonly name: string @@ -69,15 +70,23 @@ function registry( ): void { vi.mocked(attempt).mockImplementation((command, args) => { expect(command).toBe('npm') + if (args[0] === 'dist-tag') { + expect(args).toHaveLength(3) + expect(args[1]).toBe('ls') + const name = args[2] + if (name === undefined) throw new Error('Missing npm dist-tag package') + if (!tags.has(name)) return ABSENT + const payload = tags.get(name) + const stdout = payload !== null && typeof payload === 'object' && !Array.isArray(payload) + ? Object.entries(payload).map(([tag, version]) => `${tag}: ${String(version)}`).join('\n') + : JSON.stringify(payload) ?? 'undefined' + return { ...SUCCESS, stdout } + } expect(args).toHaveLength(4) expect(args[0]).toBe('view') expect(args[3]).toBe('--json') const selector = args[1] if (selector === undefined) throw new Error('Missing npm view selector') - if (args[2] === 'dist-tags') { - if (!tags.has(selector)) return ABSENT - return { ...SUCCESS, stdout: JSON.stringify(tags.get(selector)) } - } if (args[2] === 'dist.integrity') { const integrity = integrities.get(selector) return integrity === undefined ? ABSENT : { ...SUCCESS, stdout: JSON.stringify(integrity) } @@ -127,9 +136,9 @@ describe('release publication channels', () => { expect(second).toBeDefined() registry(new Map([[first!.name, { latest: '1.0.5', next: '1.0.5-rc.1' }]])) vi.mocked(attemptEchoed).mockImplementation(() => { - expect(vi.mocked(attempt).mock.calls.filter(([, args]) => args[2] === 'dist-tags')).toEqual([ - ['npm', ['view', first!.name, 'dist-tags', '--json']], - ['npm', ['view', second!.name, 'dist-tags', '--json']], + expect(vi.mocked(attempt).mock.calls.filter(([, args]) => args[0] === 'dist-tag')).toEqual([ + ['npm', ['dist-tag', 'ls', first!.name]], + ['npm', ['dist-tag', 'ls', second!.name]], ]) return SUCCESS }) @@ -166,20 +175,20 @@ describe('release publication channels', () => { const fixture = packedRelease(['1.0.6-alpha.1', '4.0.5-alpha.1']) const [first, second] = fixture.packages registry(new Map([ - [first!.name, {}], + [first!.name, { latest: '1.0.5' }], [second!.name, { [CHANNEL]: '4.0.4' }], ])) await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow() - expect(vi.mocked(attempt).mock.calls).toEqual([ - ['npm', ['view', first!.name, 'dist-tags', '--json']], - ['npm', ['view', second!.name, 'dist-tags', '--json']], + expect(vi.mocked(attempt).mock.calls.filter(([, args]) => args[0] === 'dist-tag')).toEqual([ + ['npm', ['dist-tag', 'ls', first!.name]], + ['npm', ['dist-tag', 'ls', second!.name]], ]) expect(attemptEchoed).not.toHaveBeenCalled() }) - it.each([null, [], 'not an object', { [CHANNEL]: 42 }])('rejects malformed registry channels %j before publishing', async (payload) => { + it.each([null, [], {}, 'not an object', { [CHANNEL]: 42 }])('rejects malformed registry channels %j before publishing', async (payload) => { const fixture = packedRelease(['1.0.6-alpha.1']) registry(new Map([[fixture.packages[0]!.name, payload]])) @@ -197,7 +206,19 @@ describe('release publication channels', () => { expect(attemptEchoed).not.toHaveBeenCalled() }) - it('skips an identical artifact when the custom channel already names its version', async () => { + it('rejects repeated channel bindings before publishing', async () => { + const fixture = packedRelease(['1.0.6-alpha.1']) + vi.mocked(attempt).mockReturnValue({ + ...SUCCESS, + stdout: `${CHANNEL}: 1.0.6-alpha.1\n${CHANNEL}: 1.0.6-alpha.1\n`, + }) + + await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow('invalid dist-tags') + + expect(attemptEchoed).not.toHaveBeenCalled() + }) + + it('skips an identical artifact with its custom channel and no latest tag', async () => { const fixture = packedRelease(['1.0.6-alpha.1']) const entry = fixture.packages[0]! registry( @@ -207,19 +228,73 @@ describe('release publication channels', () => { await publishRelease('vendor', fixture.directory, CHANNEL) + expect(attempt).toHaveBeenCalledWith('npm', ['dist-tag', 'ls', entry.name]) expect(attemptEchoed).not.toHaveBeenCalled() expect(sleep).not.toHaveBeenCalled() }) - it('leaves an absent custom channel unchanged when the identical version is already published', async () => { + it('rejects an already published version whose custom channel is absent', async () => { const fixture = packedRelease(['1.0.6-alpha.1']) const entry = fixture.packages[0]! - registry(new Map([[entry.name, {}]]), new Map([[`${entry.name}@${entry.version}`, entry.integrity]])) + registry(new Map([[entry.name, { latest: '1.0.5' }]]), new Map([[`${entry.name}@${entry.version}`, entry.integrity]])) + + await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow() + + expect(attemptEchoed).not.toHaveBeenCalled() + expect(attempt).toHaveBeenCalledWith('npm', ['view', `${entry.name}@${entry.version}`, 'dist.integrity', '--json']) + }) + + it('stops before the first upload when a later published version has no custom channel', async () => { + const fixture = packedRelease(['1.0.6-alpha.1', '4.0.5-alpha.1']) + const [first, second] = fixture.packages + registry( + new Map([[first!.name, { latest: '1.0.5' }], [second!.name, { latest: '4.0.4' }]]), + new Map([[`${second!.name}@${second!.version}`, second!.integrity]]), + ) + + await expect(publishRelease('vendor', fixture.directory, CHANNEL)).rejects.toThrow() + + expect(attempt).toHaveBeenCalledWith('npm', ['view', `${second!.name}@${second!.version}`, 'dist.integrity', '--json']) + expect(attemptEchoed).not.toHaveBeenCalled() + expect(sleep).not.toHaveBeenCalled() + }) + + it('preserves the explicit channel when retrying an E409 publication', async () => { + const fixture = packedRelease(['1.0.6-alpha.1']) + const entry = fixture.packages[0]! + registry(new Map([[entry.name, { latest: '1.0.5', next: '1.0.5-rc.1' }]])) + vi.mocked(attemptEchoed).mockReturnValueOnce(CONFLICT).mockReturnValueOnce(SUCCESS) await publishRelease('vendor', fixture.directory, CHANNEL) - expect(attemptEchoed).not.toHaveBeenCalled() - expect(attempt).toHaveBeenCalledTimes(2) + expect(vi.mocked(attemptEchoed).mock.calls).toEqual([ + ['npm', ['publish', entry.path, '--tag', CHANNEL]], + ['npm', ['publish', entry.path, '--tag', CHANNEL]], + ]) + expect(sleep).toHaveBeenCalledExactlyOnceWith(2_000) + }) + + it('keeps the explicit channel when an E409 publication already landed', async () => { + const fixture = packedRelease(['1.0.6-alpha.1']) + const entry = fixture.packages[0]! + const tags = new Map>([[entry.name, { latest: '1.0.5', next: '1.0.5-rc.1' }]]) + const integrities = new Map() + registry(tags, integrities) + vi.mocked(attemptEchoed).mockImplementationOnce(() => { + integrities.set(`${entry.name}@${entry.version}`, entry.integrity) + tags.set(entry.name, { ...tags.get(entry.name)!, [CHANNEL]: entry.version }) + return CONFLICT + }) + + await publishRelease('vendor', fixture.directory, CHANNEL) + + expect(attemptEchoed).toHaveBeenCalledExactlyOnceWith('npm', ['publish', entry.path, '--tag', CHANNEL]) + const lookupIndex = vi.mocked(attempt).mock.calls.findLastIndex(([, args]) => args[2] === 'dist.integrity') + expect(lookupIndex).toBeGreaterThanOrEqual(0) + expect(vi.mocked(attempt).mock.invocationCallOrder[lookupIndex]).toBeGreaterThan( + vi.mocked(attemptEchoed).mock.invocationCallOrder[0]!, + ) + expect(sleep).not.toHaveBeenCalled() }) it('rejects different bytes already published at the requested version', async () => { diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 985588d6a9..732ff46943 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -17,7 +17,7 @@ import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' import { parseArgs } from 'node:util' -import { validRange } from 'semver' +import { valid, validRange } from 'semver' import { releaseFamily } from './families.ts' import { attempt, attemptEchoed, isEntry } from './process.ts' import { packedIdentity, readPublishOrder, type PackedIdentity } from './tarball.ts' @@ -85,29 +85,38 @@ function registryState(name: string, version: string): RegistryState { return { kind: 'present', integrity: parsed } } -/** Reject empty tags, npm option prefixes, URI-encoded characters, and version ranges. */ +/** Reject empty tags, npm option prefixes, characters requiring URI encoding, and version ranges. */ function validateDistTag(distTag: string): void { if (distTag === '' || distTag.startsWith('-') || encodeURIComponent(distTag) !== distTag || validRange(distTag) !== null) { - throw new Error(`Invalid npm dist-tag ${JSON.stringify(distTag)}: use a non-empty tag without a leading hyphen, URI-encoded characters, or a version range.`) + throw new Error(`Invalid npm dist-tag ${JSON.stringify(distTag)}: use a non-empty tag without a leading hyphen, characters requiring URI encoding, or a version range.`) } } /** An explicit channel may be new or already name the same version after a partial publication. */ function verifyDistTag(member: PackedIdentity, distTag: string): void { - const result = attempt('npm', ['view', member.name, 'dist-tags', '--json']) + const result = attempt('npm', ['dist-tag', 'ls', member.name]) if (result.status !== 0) { const output = `${result.stdout}${result.stderr}` if (output.includes('E404') || output.includes('404 Not Found')) return - throw new Error(`npm view ${member.name} dist-tags failed:\n${output}`) + throw new Error(`npm dist-tag ls ${member.name} failed:\n${output}`) } - const parsed: unknown = JSON.parse(result.stdout) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`registry reported invalid dist-tags for ${member.name}`) + const tags = new Map() + for (const line of result.stdout.split(/\r?\n/u).filter(line => line !== '')) { + const match = /^(\S+): (\S+)$/u.exec(line) + const tag = match?.[1] + const version = match?.[2] + if (tag === undefined || version === undefined || valid(version) === null || tags.has(tag)) { + throw new Error(`npm reported invalid dist-tags for ${member.name}: ${JSON.stringify(line)}`) + } + tags.set(tag, version) } - const version: unknown = Object.hasOwn(parsed, distTag) ? Reflect.get(parsed, distTag) : undefined - if (version === undefined) return - if (typeof version !== 'string' || version === '') { - throw new Error(`registry reported an invalid ${distTag} dist-tag for ${member.name}`) + if (tags.size === 0) throw new Error(`npm reported no dist-tags for ${member.name}`) + const version = tags.get(distTag) + if (version === undefined) { + if (registryState(member.name, member.version).kind === 'present') { + throw new Error(`${member.name}@${member.version} is already published without dist-tag ${distTag}; this run cannot bind an existing version to a new channel.`) + } + return } if (version !== member.version) { throw new Error(`${member.name}@${distTag} already points to ${version}; choose an unused dist-tag instead of replacing it with ${member.version}.`)