Files
deepseek-harness/scripts/release/families.spec.ts
imccyu 47399764c5 fix(release): order publication by every installed dependency section
Publish order exists to make a partial publication self-consistent: an
interrupted run should leave a prefix whose packages never point at a version
absent from the registry. It read only dependencies and optionalDependencies, so
peer declarations — how sibling harness packages reference each other, 1088 edges
in the dsh family — constrained nothing.

Peer edges now order the publication too. devDependencies still do not: a dev
dependency is absent from the published package.

Peers cannot constrain it absolutely. Sibling packages declare each other as
peers, which is what closes the two cycles here, and npm treats an unmet peer as
a warning rather than a resolution failure. Install edges therefore win: a peer
edge is dropped where the peer installs the member declaring it, or where
following it would revisit a member already being visited. One peer edge is
dropped in the dsh family and two in the vendored family; every install edge is
honoured.

A cycle among install edges stays a defect rather than something to order
around, and release:verify now reports it before the build instead of letting it
surface once pack is already writing tarballs. Install-edge acyclicity is checked
on its own graph, because a peer edge leading into an install edge otherwise
reads as a cycle where the install edges are perfectly orderable.
2026-08-14 16:10:06 +08:00

239 lines
11 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).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).map(entry => entry.name)).toEqual([
'@deepseek-ai/dsh-zebra',
'@deepseek-ai/dsh-consumer',
])
})
it('orders around a peer cycle rather than refusing to publish', () => {
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.
expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([
'@deepseek-ai/dsh-right',
'@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.
expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([
'@deepseek-ai/dsh-base',
'@deepseek-ai/dsh-consumer',
])
})
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).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)
})
})