From 9fa0575ccc05970974a4cdbb29b096fc70848cba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:38:35 +0800 Subject: [PATCH] fix(release): print the publish order and the peer edges it drops The verify step resolved the publish order and said only that it had: the order a release actually follows, and the ordering it could not honour, stayed invisible until a publication was already running. publishOrder now returns that order together with the peer edges it dropped, verify prints both, and pack reads the order off the plan. The dropped edges are part of the result rather than a detail of forming it: the dsh family drops one (dsh-api-remotes -> dsh-api-gateway) and the vendored family drops two (cordis-plugin-include and cordis-plugin-loader, which cordis declares as peers in return), and only whoever reads the log can judge whether a newly dropped edge is expected. Because pack runs on every pull request and master push, a change to the order is now reviewable there rather than observable only at publish time. The order is also checked against the edges it exists to honour. A cycle mixing peer and dependency declarations can put a dependency on the traversal stack, where it is skipped like a peer edge, emitting a consumer before something it installs; no later step can detect that, and it would surface as an unresolvable install for a consumer of the published packages. No family has that shape today, and the new test pins the three-package case that would. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 4 +- .../2026-08-10-npm-release-sequences.zh.md | 4 +- scripts/release/families.spec.ts | 36 ++++++++++-- scripts/release/families.ts | 57 +++++++++++++++++-- scripts/release/pack.ts | 2 +- scripts/release/verify.ts | 41 +++++++++++-- 7 files changed, 126 insertions(+), 22 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 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()