mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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.
This commit is contained in:
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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<string>()
|
||||
const onStack = new Set<string>()
|
||||
// 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user