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 59b51bbe6b..4b14d3b8aa 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: e74a4ac8f2aadd8665ec0db198c6a317a0c201bc -2026-08-10-npm-release-sequences.zh.md: e152163976f945224f2524fccd7f831ba98e8161 +2026-08-10-npm-release-sequences.md: 2c46fb9b3e3fb8ddd90131e3c3113166580608d4 +2026-08-10-npm-release-sequences.zh.md: edbb2a8884f658b6c87ceb5762c01551a81a249d 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 e74a4ac8f2..2c46fb9b3e 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 @@ -88,9 +88,9 @@ The entity in this domain is a **release family**: a set of packages sharing one |---|---| | `ReleaseFamily` | a family's identity: member discovery, version baseline, tag prefix, packed-payload rule, installed entry | | `ReleaseMember` | one publishable package: directory, name, version, manifest | -| `publishOrder` | topological order over runtime dependencies, ties broken by package name; a cycle is reported rather than resolved arbitrarily | +| `publishOrder` | topological order over the sections npm installs plus peer declarations, ties broken by package name; a cycle among installed dependencies is reported rather than resolved arbitrarily, and a peer edge no order can honour is dropped and named | | `pack` | packs a whole family into one directory and records the upload order | -| `verify` | the family's version baseline, and — when publishing — that the run comes from that family's tag and its members are publishable | +| `verify` | the family's version baseline, the publish order it prints in full, and — when publishing — that the run comes from that family's tag and its members are publishable | | `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer and drives the installed executable | | `publish` | the three registry states above | | `process` / `tarball` | the one home for spawning commands and for reading a packed tarball, including the entry guard that keeps every script importable | 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 e152163976..edbb2a8884 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 @@ -88,9 +88,9 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 |---|---| | `ReleaseFamily` | 一族的身份:成员发现、版本基线、tag 前缀、打包 payload 规则、已安装入口 | | `ReleaseMember` | 一个可发布包:目录、包名、版本、manifest | -| `publishOrder` | 按运行时依赖的拓扑序,同层按包名排;遇到环是报错而不是随意定序 | +| `publishOrder` | 按 npm 会安装的依赖段加 peer 声明做拓扑序,同层按包名排;安装依赖成环是报错而不是随意定序,任何排不进去的 peer 边被丢弃并点名 | | `pack` | 把整族打进一个目录并记录上传顺序 | -| `verify` | 族的版本基线;发布时还要求本次运行来自该族的 tag、且成员可发布 | +| `verify` | 族的版本基线、完整打印出来的发布顺序;发布时还要求本次运行来自该族的 tag、且成员可发布 | | `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进一次性 consumer,并驱动已安装的可执行入口 | | `publish` | 上面那三态 | | `process` / `tarball` | 启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import | diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 2c872d4487..66c3daf83f 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -57,7 +57,7 @@ describe('release families', () => { member('packages/a/zebra', '@deepseek-ai/dsh-zebra'), ] - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-library', '@deepseek-ai/dsh-consumer', '@deepseek-ai/dsh-zebra', @@ -82,13 +82,13 @@ describe('release families', () => { ] // Name order alone would place the consumer first; the peer edge moves it. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-zebra', '@deepseek-ai/dsh-consumer', ]) }) - it('orders around a peer cycle rather than refusing to publish', () => { + it('orders around a peer cycle rather than refusing to publish, and reports the edge it dropped', () => { const dsh = releaseFamily('dsh') const members = [ member('packages/a/left', '@deepseek-ai/dsh-left', { peerDependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }), @@ -97,10 +97,15 @@ describe('release families', () => { // Sibling packages declare each other as peers, and npm treats an unmet peer // as a warning, so this pair has to publish rather than fail the release. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + const plan = dsh.publishOrder(members) + expect(plan.order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-right', '@deepseek-ai/dsh-left', ]) + // One of the two edges has to give, and which one it is belongs in the log. + expect(plan.droppedPeerEdges).toEqual([ + { consumer: '@deepseek-ai/dsh-right', peer: '@deepseek-ai/dsh-left' }, + ]) }) it('honours an install edge even when a peer cycle surrounds it', () => { @@ -115,10 +120,29 @@ describe('release families', () => { // The install edge is absolute: base publishes first, and the peer edge that // would reverse it is the one dropped. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + const plan = dsh.publishOrder(members) + expect(plan.order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-consumer', ]) + expect(plan.droppedPeerEdges).toEqual([ + { consumer: '@deepseek-ai/dsh-base', peer: '@deepseek-ai/dsh-consumer' }, + ]) + }) + + it('refuses an order that would publish a consumer before a dependency it installs', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { peerDependencies: { '@deepseek-ai/dsh-bravo': 'workspace:^' } }), + member('packages/a/bravo', '@deepseek-ai/dsh-bravo', { peerDependencies: { '@deepseek-ai/dsh-charlie': 'workspace:^' } }), + member('packages/a/charlie', '@deepseek-ai/dsh-charlie', { dependencies: { '@deepseek-ai/dsh-alpha': 'workspace:^' } }), + ] + + // A cycle of two peer edges closed by one install edge: dropping a peer edge + // would order this, and the traversal drops the install edge instead. That + // order would publish charlie before the alpha it installs, so it is refused + // here rather than published. + expect(() => { dsh.publishOrder(members) }).toThrow(/no publish order honours @deepseek-ai\/dsh-charlie -> @deepseek-ai\/dsh-alpha/) }) it('ignores devDependencies when ordering', () => { @@ -130,7 +154,7 @@ describe('release families', () => { // A dev dependency is absent from the published package, so it must not move // the consumer behind it. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-alpha', '@deepseek-ai/dsh-zebra', ]) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 2135920047..09acbe2da9 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -32,6 +32,28 @@ const PEER_SECTIONS = ['peerDependencies'] as const /** The workspace root manifest, which is never a release member. */ const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root' +/** One peer declaration the publish order leaves unordered. */ +interface DroppedPeerEdge { + /** Package declaring the peer. */ + readonly consumer: string + /** The declared peer, which publishes after `consumer` or alongside it in a cycle. */ + readonly peer: string +} + +/** + * A family's publish order together with the ordering it could not honour. + * + * The dropped edges are part of the result rather than a detail of forming it: + * a release drops real ordering constraints, and the operator reading the pack + * log is the only one who can judge whether a newly dropped edge is expected. + */ +export interface PublishPlan { + /** Members in publish order. */ + readonly order: readonly ReleaseMember[] + /** Peer declarations left unordered, in the order the traversal reached them. */ + readonly droppedPeerEdges: readonly DroppedPeerEdge[] +} + /** One publishable package of a release family. */ export interface ReleaseMember { /** Repository-relative package directory, for example `packages/core/session`. */ @@ -130,10 +152,12 @@ export abstract class ReleaseFamily { * dropped where honouring one would deadlock: sibling packages declare each * other as peers, and npm treats an unmet peer as a warning rather than a * resolution failure ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + * Every dropped edge is reported, because dropping one is a decision about a + * real release rather than an implementation detail. * @param members - this family's members. - * @returns The same members in publish order; ties break by name for determinism. + * @returns The order, ties broken by name for determinism, and the peer edges it left unordered. */ - publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] { + publishOrder(members: readonly ReleaseMember[]): PublishPlan { const byName = new Map(members.map(member => [member.name, member])) const byNameSorted = [...members].sort((left, right) => left.name.localeCompare(right.name)) const edges = (member: ReleaseMember, sections: readonly string[]): ReleaseMember[] => @@ -159,6 +183,7 @@ export abstract class ReleaseFamily { // Emit the order over both kinds of edge. A node already on the stack is a // cycle only peer edges can form, and skipping it drops just that edge. const ordered: ReleaseMember[] = [] + const droppedPeerEdges: DroppedPeerEdge[] = [] const placed = new Set() const onStack = new Set() // Members reachable from one member through install edges. A peer edge is @@ -181,7 +206,13 @@ export abstract class ReleaseFamily { onStack.add(member.name) for (const dependency of edges(member, INSTALL_SECTIONS)) visit(dependency) for (const peer of edges(member, PEER_SECTIONS)) { - if (installClosure(peer).has(member.name)) continue + if (installClosure(peer).has(member.name)) { + droppedPeerEdges.push({ consumer: member.name, peer: peer.name }) + continue + } + // A peer already on the stack is an ancestor, so it publishes after this + // member rather than before it: the edge is dropped, not honoured. + if (onStack.has(peer.name)) droppedPeerEdges.push({ consumer: member.name, peer: peer.name }) visit(peer) } onStack.delete(member.name) @@ -190,7 +221,25 @@ export abstract class ReleaseFamily { ordered.push(member) } for (const member of byNameSorted) visit(member) - return ordered + + // A cycle mixing both kinds of edge can put an install edge's target on the + // stack, where the traversal skips it like a peer edge and emits a consumer + // before something it installs. Nothing downstream can detect that, and it + // would only surface as an unresolvable install for whoever consumes the + // published packages, so the emitted order is checked against the edges it + // exists to honour. + const position = new Map(ordered.map((entry, index) => [entry.name, index])) + for (const [index, member] of ordered.entries()) { + for (const dependency of edges(member, INSTALL_SECTIONS)) { + const dependencyIndex = position.get(dependency.name) + if (dependencyIndex !== undefined && dependencyIndex < index) continue + throw new Error( + `release family ${this.id}: no publish order honours ${member.name} -> ${dependency.name};` + + ' a cycle mixing peer and dependency declarations reaches this dependency through a peer edge', + ) + } + } + return { order: ordered, droppedPeerEdges } } /** diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 47a33a26ac..5d2b9b4e64 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -45,7 +45,7 @@ function main(): void { const family = releaseFamily(values.family) const root = process.cwd() const destination = resolve(root, values.out ?? DEFAULT_OUTPUT) - const members = family.publishOrder(family.members(root)) + const members = family.publishOrder(family.members(root)).order family.verifyVersions(members) rmSync(destination, { recursive: true, force: true }) diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts index bd906bcc9e..5829087f97 100644 --- a/scripts/release/verify.ts +++ b/scripts/release/verify.ts @@ -9,7 +9,33 @@ import { parseArgs } from 'node:util' import { isEntry } from './process.ts' -import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' +import { releaseFamily, type PublishPlan, type ReleaseFamily, type ReleaseMember } from './families.ts' + +/** + * Print the publish order the release will follow, and the peer declarations it + * leaves unordered. + * + * The order is the release's own plan: an interrupted publication leaves exactly + * a prefix of it, so reading it is how anyone judges what a partial run left on + * the registry, and printing it on every pull request is what makes a change to + * the order reviewable rather than only observable during a publication. + * @param family - the release family. + * @param plan - the resolved order and its dropped edges. + */ +function reportPublishOrder(family: ReleaseFamily, plan: PublishPlan): void { + console.log(`release verify: publish order for family ${family.id}, ${String(plan.order.length)} member(s):`) + const width = String(plan.order.length).length + for (const [index, member] of plan.order.entries()) { + console.log(` ${String(index + 1).padStart(width, ' ')} ${member.name}@${member.version}`) + } + if (plan.droppedPeerEdges.length === 0) return + console.log( + `release verify: ${String(plan.droppedPeerEdges.length)} peer declaration(s) publish unordered,` + + ' because the peer cannot precede the package declaring it without contradicting a dependency edge' + + ' or its own cycle. npm treats an unmet peer as a warning, so this orders nothing and blocks nothing:', + ) + for (const edge of plan.droppedPeerEdges) console.log(` ${edge.consumer} -> ${edge.peer}`) +} /** * Assert every member may be published: npm refuses a `private` package. @@ -58,12 +84,13 @@ function main(): void { // Resolve the publish order here, before the build: an install-edge cycle // makes the order unrepresentable, and that has to surface at the first gate // rather than when pack is already writing tarballs. - const ordered = family.publishOrder(members) - if (ordered.length !== members.length) { + const plan = family.publishOrder(members) + if (plan.order.length !== members.length) { throw new Error( - `release family ${family.id}: publish order covers ${String(ordered.length)} of ${String(members.length)} members`, + `release family ${family.id}: publish order covers ${String(plan.order.length)} of ${String(members.length)} members`, ) } + reportPublishOrder(family, plan) const publishing = process.env.RELEASE_PUBLISH === 'true' if (publishing) { @@ -73,7 +100,11 @@ function main(): void { const versions = [...new Set(members.map(member => member.version))] const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions` - console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}, publish order resolved${publishing ? ', publish gates passed' : ''}`) + console.log( + `release verify: family ${family.id}, ${String(members.length)} member(s), ${summary},` + + ` publish order resolved, ${String(plan.droppedPeerEdges.length)} peer declaration(s) unordered` + + (publishing ? ', publish gates passed' : ''), + ) } if (isEntry(import.meta.url)) main()