Files
deepseek-harness/scripts/release/families.spec.ts
imccyu 9fa0575ccc 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-14 16:10:06 +08:00

263 lines
12 KiB
TypeScript

/** Release family discovery, publish order, tag naming, and the bump judgements. */
import { describe, expect, it } from 'vitest'
import { releaseFamily, type ReleaseMember } from './families.ts'
import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts'
/**
* A release member standing in for a manifest on disk.
* @param directory - repository-relative package directory.
* @param name - package name.
* @param manifest - manifest fields the subject reads.
* @returns The member.
*/
function member(directory: string, name: string, manifest: Record<string, unknown> = {}): ReleaseMember {
return { directory, name, version: '0.0.1', manifest }
}
describe('release families', () => {
it('names one tag for the whole dsh family and one per vendored package', () => {
const dsh = releaseFamily('dsh')
const vendor = releaseFamily('vendor')
const cli = member('apps/cli', '@deepseek-ai/dsh')
const cordis = { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' }
expect(dsh.tagFor(cli)).toBe('dsh-v0.0.1')
expect(vendor.tagFor(cordis)).toBe('vendor-cordis-v4.0.1')
// The prefix is constructed, not recovered from a tag: a version with a
// hyphen would defeat any suffix-stripping.
expect(vendor.tagPrefixFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v')
expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7')
})
it('rejects a family whose members disagree on the shared version', () => {
const dsh = releaseFamily('dsh')
const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-web-frontend'), version: '0.0.2' }]
expect(() => { dsh.verifyVersions(members) }).toThrow(/must share one version/)
expect(() => { dsh.verifyVersions([members[0]!]) }).not.toThrow()
})
it('accepts independent vendored versions and rejects an unpublishable one', () => {
const vendor = releaseFamily('vendor')
const members = [
{ ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' },
{ ...member('vendor/cosmokit', '@deepseek-ai/cosmokit'), version: '1.8.2' },
]
expect(() => { vendor.verifyVersions(members) }).not.toThrow()
expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/)
})
it('publishes a dependency before its consumer, and orders ties by name', () => {
const dsh = releaseFamily('dsh')
const members = [
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { dependencies: { '@deepseek-ai/dsh-library': 'workspace:^' } }),
member('packages/a/library', '@deepseek-ai/dsh-library'),
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
]
expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
'@deepseek-ai/dsh-library',
'@deepseek-ai/dsh-consumer',
'@deepseek-ai/dsh-zebra',
])
})
it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => {
const dsh = releaseFamily('dsh')
const members = [
member('packages/a/left', '@deepseek-ai/dsh-left', { dependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }),
member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
]
expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/)
})
it('publishes a peer before its consumer', () => {
const dsh = releaseFamily('dsh')
const members = [
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { peerDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }),
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
]
// Name order alone would place the consumer first; the peer edge moves it.
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, 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:^' } }),
member('packages/a/right', '@deepseek-ai/dsh-right', { peerDependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
]
// 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.
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', () => {
const dsh = releaseFamily('dsh')
const members = [
member('packages/a/base', '@deepseek-ai/dsh-base', { peerDependencies: { '@deepseek-ai/dsh-consumer': 'workspace:^' } }),
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', {
dependencies: { '@deepseek-ai/dsh-base': 'workspace:^' },
peerDependencies: { '@deepseek-ai/dsh-base': 'workspace:^' },
}),
]
// The install edge is absolute: base publishes first, and the peer edge that
// would reverse it is the one dropped.
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', () => {
const dsh = releaseFamily('dsh')
const members = [
member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { devDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }),
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
]
// A dev dependency is absent from the published package, so it must not move
// the consumer behind it.
expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
'@deepseek-ai/dsh-alpha',
'@deepseek-ai/dsh-zebra',
])
})
it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => {
const dsh = releaseFamily('dsh')
const vendor = releaseFamily('vendor')
const harness = member('packages/a/library', '@deepseek-ai/dsh-library')
const vendored = member('vendor/cordis', '@deepseek-ai/cordis')
expect(() => { dsh.validatePayload(harness, ['package/lib/index.js', 'package/src/index.ts']) })
.toThrow(/publishes source file/)
expect(() => { vendor.validatePayload(vendored, ['package/lib/index.js', 'package/src/index.ts']) }).not.toThrow()
expect(() => { vendor.validatePayload(vendored, []) }).toThrow(/empty tarball/)
})
it('drives the installed entry only for the family that publishes one', () => {
expect(releaseFamily('dsh').installedEntry).toEqual({ packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' })
expect(releaseFamily('vendor').installedEntry).toBeUndefined()
})
it('rejects an unknown family identifier', () => {
expect(() => { releaseFamily('native') }).toThrow(/unknown release family/)
})
})
describe('vendored version baseline', () => {
it('drops an upstream prerelease segment and increments the patch', () => {
expect(nextVendorVersion('4.0.0-rc.7', undefined)).toBe('4.0.1')
expect(nextVendorVersion('1.0.0-rc.5', undefined)).toBe('1.0.1')
expect(nextVendorVersion('1.8.1', undefined)).toBe('1.8.2')
})
it('increments from the last published version when a re-sync restored a lower one', () => {
// Upstream moved rc.7 -> rc.8 after this repository published 4.0.1;
// incrementing the manifest alone would name 4.0.1 a second time.
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', () => {
const sourceShipping = member('vendor/cosmokit', '@deepseek-ai/cosmokit', {
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'src'],
})
const buildOutputOnly = member('vendor/cordis', '@deepseek-ai/cordis', {
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'bin.js'],
})
it('counts the manifest and the files npm always publishes', () => {
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/package.json')).toBe(true)
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.md')).toBe(true)
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/src/index.ts')).toBe(true)
})
it('counts build inputs for a package whose payload is build output', () => {
// cordis publishes lib/ only, and lib/ is not tracked: without this, a real
// source change reads as "nothing changed" and the next publish fails on a
// version whose bytes moved.
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/src/context.ts')).toBe(true)
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/tsconfig.json')).toBe(true)
})
it('ignores paths no tarball carries', () => {
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/tests/unit.spec.ts')).toBe(false)
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/CHANGELOG.md')).toBe(false)
// The README pattern is deliberately loose: over-reporting a change costs one
// unnecessary patch bump, while under-reporting fails the next publish on a
// version whose bytes moved.
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.i18n.yaml')).toBe(true)
expect(reachesPayload(member('packages/a/library', '@deepseek-ai/dsh-library', { files: ['lib/index.js'] }),
'packages/a/library/tests/library.spec.ts')).toBe(false)
})
})