families.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. /** Release family discovery, publish order, tag naming, and the bump judgements. */
  2. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join, resolve } from 'node:path'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts'
  7. import { releaseFamily, type ReleaseMember } from './families.ts'
  8. import { compareVersions, nextVendorVersion, planShared, reachesPayload } from './bump.ts'
  9. /**
  10. * A release member standing in for a manifest on disk.
  11. * @param directory - repository-relative package directory.
  12. * @param name - package name.
  13. * @param manifest - manifest fields the subject reads.
  14. * @returns The member.
  15. */
  16. function member(directory: string, name: string, manifest: Record<string, unknown> = {}): ReleaseMember {
  17. return { directory, name, version: '0.0.1', manifest }
  18. }
  19. const roots: string[] = []
  20. function write(path: string, content: string): void {
  21. mkdirSync(dirname(path), { recursive: true })
  22. writeFileSync(path, content)
  23. }
  24. function buildFixture(environment: Record<string, string>): string {
  25. const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-'))
  26. roots.push(root)
  27. write(join(root, 'package.json'), `${JSON.stringify({ version: environment.DSH_CLIENT_VERSION ?? '0.0.1' })}\n`)
  28. write(join(root, 'apps/web/dist/index.html'), '<main></main>')
  29. write(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
  30. writeClientBuildRecord(root, environment)
  31. return root
  32. }
  33. afterEach(() => {
  34. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  35. vi.unstubAllEnvs()
  36. })
  37. describe('release families', () => {
  38. it('publishes Agent Teams while excluding private experimental packages', () => {
  39. const members = releaseFamily('dsh').members(resolve(import.meta.dirname, '../..'))
  40. expect(members
  41. .filter(member => member.directory.startsWith('packages/experimental/'))
  42. .map(member => member.name)).toEqual([
  43. '@deepseek-ai/dsh-experimental-agent-team-profile',
  44. '@deepseek-ai/dsh-experimental-agent-team-web-profile',
  45. '@deepseek-ai/dsh-experimental-agent-team',
  46. '@deepseek-ai/dsh-experimental-client-ui-agent-team',
  47. '@deepseek-ai/dsh-experimental-tool-agent-team',
  48. ])
  49. expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-inspector')
  50. })
  51. it('excludes private applications from the publish set', () => {
  52. const root = mkdtempSync(join(tmpdir(), 'dsh-release-private-'))
  53. roots.push(root)
  54. write(join(root, 'apps/public/package.json'), '{"name":"@deepseek-ai/dsh-public","version":"0.0.1"}\n')
  55. write(join(root, 'apps/private/package.json'), '{"name":"@deepseek-ai/dsh-private","version":"0.0.1","private":true}\n')
  56. expect(releaseFamily('dsh').members(root).map(entry => entry.name)).toEqual(['@deepseek-ai/dsh-public'])
  57. })
  58. it('bumps private dsh workspaces without adding release tags', () => {
  59. const root = mkdtempSync(join(tmpdir(), 'dsh-release-version-'))
  60. roots.push(root)
  61. write(join(root, 'package.json'), '{"version":"0.0.1"}\n')
  62. write(join(root, 'apps/desktop/package.json'), '{"version":"0.0.1","private":true}\n')
  63. write(join(root, 'packages/experimental/prototype/package.json'), '{"version":"0.0.1","private":true}\n')
  64. write(join(root, 'packages/core/unselected/package.json'), '{"version":"0.0.1"}\n')
  65. const dsh = releaseFamily('dsh')
  66. const published = member('packages/core/published', '@deepseek-ai/dsh-published')
  67. const { planned } = planShared(dsh, root, [published], '0.0.2')
  68. expect(planned.map(entry => ({ path: entry.manifestPath, tag: entry.tag }))).toEqual([
  69. { path: 'package.json', tag: undefined },
  70. { path: 'packages/core/published/package.json', tag: 'dsh-v0.0.2' },
  71. { path: 'apps/desktop/package.json', tag: undefined },
  72. { path: 'packages/experimental/prototype/package.json', tag: undefined },
  73. ])
  74. })
  75. it.each(['0.0.2-alpha.1', '0.0.2-canary.1', '0.0.2-rc.1'])(
  76. 'accepts the explicit dsh prerelease version %s',
  77. (version) => {
  78. const root = mkdtempSync(join(tmpdir(), 'dsh-release-prerelease-'))
  79. roots.push(root)
  80. write(join(root, 'package.json'), '{"version":"0.0.1"}\n')
  81. const dsh = releaseFamily('dsh')
  82. const published = member('packages/core/published', '@deepseek-ai/dsh-published')
  83. const plan = planShared(dsh, root, [published], version)
  84. expect(plan.version).toBe(version)
  85. expect(plan.planned[1]?.tag).toBe(`dsh-v${version}`)
  86. },
  87. )
  88. it('names one tag for the whole dsh family and one per vendored package', () => {
  89. const dsh = releaseFamily('dsh')
  90. const vendor = releaseFamily('vendor')
  91. const cli = member('apps/cli', '@deepseek-ai/dsh')
  92. const cordis = { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' }
  93. expect(dsh.tagFor(cli)).toBe('dsh-v0.0.1')
  94. expect(vendor.tagFor(cordis)).toBe('vendor-cordis-v4.0.1')
  95. // The prefix is constructed, not recovered from a tag: a version with a
  96. // hyphen would defeat any suffix-stripping.
  97. expect(vendor.tagPrefixFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v')
  98. expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7')
  99. })
  100. it('assigns alpha and canary dist-tags only to dsh releases', () => {
  101. const dsh = releaseFamily('dsh')
  102. const vendor = releaseFamily('vendor')
  103. expect(dsh.distTagForVersion('0.0.2-alpha.1')).toBe('alpha')
  104. expect(dsh.distTagForVersion('0.0.2-canary.1')).toBe('canary')
  105. expect(dsh.distTagForVersion('0.0.2-rc.1')).toBe('next')
  106. expect(dsh.distTagForVersion('0.0.2')).toBeUndefined()
  107. expect(vendor.distTagForVersion('4.0.1-alpha.1')).toBe('next')
  108. expect(vendor.distTagForVersion('4.0.1-canary.1')).toBe('next')
  109. })
  110. it('rejects a family whose members disagree on the shared version', () => {
  111. const dsh = releaseFamily('dsh')
  112. const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-web-frontend'), version: '0.0.2' }]
  113. expect(() => { dsh.verifyVersions(members) }).toThrow(/must share one version/)
  114. expect(() => { dsh.verifyVersions([members[0]!]) }).not.toThrow()
  115. })
  116. it('accepts independent vendored versions and rejects an unpublishable one', () => {
  117. const vendor = releaseFamily('vendor')
  118. const members = [
  119. { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' },
  120. { ...member('vendor/cosmokit', '@deepseek-ai/cosmokit'), version: '1.8.2' },
  121. ]
  122. expect(() => { vendor.verifyVersions(members) }).not.toThrow()
  123. expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/)
  124. })
  125. it('requires a current official client build only for dsh artifacts', () => {
  126. const dsh = releaseFamily('dsh')
  127. const vendor = releaseFamily('vendor')
  128. const officialEnvironment = officialClientBuildEnvironment(resolve(import.meta.dirname, '../..'))
  129. vi.stubEnv('DSH_CLIENT_COMMIT_HASH', officialEnvironment.DSH_CLIENT_COMMIT_HASH)
  130. const official = buildFixture(officialEnvironment)
  131. const defaultBuild = buildFixture({})
  132. const missing = join(defaultBuild, 'missing')
  133. write(join(missing, 'package.json'), `${JSON.stringify({ version: officialEnvironment.DSH_CLIENT_VERSION })}\n`)
  134. expect(() => { dsh.verifyBuildArtifacts(official) }).not.toThrow()
  135. expect(() => { dsh.verifyBuildArtifacts(defaultBuild) }).toThrow(/DSH_CLIENT_TITLE/)
  136. expect(() => { dsh.verifyBuildArtifacts(missing) }).toThrow(/record.*missing/)
  137. expect(() => { vendor.verifyBuildArtifacts(missing) }).not.toThrow()
  138. write(join(official, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n')
  139. expect(() => { dsh.verifyBuildArtifacts(official) }).toThrow(/artifacts differ/)
  140. })
  141. it('publishes a dependency before its consumer, and orders ties by name', () => {
  142. const dsh = releaseFamily('dsh')
  143. const members = [
  144. member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { dependencies: { '@deepseek-ai/dsh-library': 'workspace:^' } }),
  145. member('packages/a/library', '@deepseek-ai/dsh-library'),
  146. member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
  147. ]
  148. expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
  149. '@deepseek-ai/dsh-library',
  150. '@deepseek-ai/dsh-consumer',
  151. '@deepseek-ai/dsh-zebra',
  152. ])
  153. })
  154. it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => {
  155. const dsh = releaseFamily('dsh')
  156. const members = [
  157. member('packages/a/left', '@deepseek-ai/dsh-left', { dependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }),
  158. member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
  159. ]
  160. expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/)
  161. })
  162. it('publishes a peer before its consumer', () => {
  163. const dsh = releaseFamily('dsh')
  164. const members = [
  165. member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { peerDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }),
  166. member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
  167. ]
  168. // Name order alone would place the consumer first; the peer edge moves it.
  169. expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
  170. '@deepseek-ai/dsh-zebra',
  171. '@deepseek-ai/dsh-consumer',
  172. ])
  173. })
  174. it('orders around a peer cycle rather than refusing to publish, and reports the edge it dropped', () => {
  175. const dsh = releaseFamily('dsh')
  176. const members = [
  177. member('packages/a/left', '@deepseek-ai/dsh-left', { peerDependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }),
  178. member('packages/a/right', '@deepseek-ai/dsh-right', { peerDependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
  179. ]
  180. // Sibling packages declare each other as peers, and npm treats an unmet peer
  181. // as a warning, so this pair has to publish rather than fail the release.
  182. const plan = dsh.publishOrder(members)
  183. expect(plan.order.map(entry => entry.name)).toEqual([
  184. '@deepseek-ai/dsh-right',
  185. '@deepseek-ai/dsh-left',
  186. ])
  187. // One of the two edges has to give, and which one it is belongs in the log.
  188. expect(plan.droppedPeerEdges).toEqual([
  189. { consumer: '@deepseek-ai/dsh-right', peer: '@deepseek-ai/dsh-left' },
  190. ])
  191. })
  192. it('honours an install edge even when a peer cycle surrounds it', () => {
  193. const dsh = releaseFamily('dsh')
  194. const members = [
  195. member('packages/a/base', '@deepseek-ai/dsh-base', { peerDependencies: { '@deepseek-ai/dsh-consumer': 'workspace:^' } }),
  196. member('packages/a/consumer', '@deepseek-ai/dsh-consumer', {
  197. dependencies: { '@deepseek-ai/dsh-base': 'workspace:^' },
  198. peerDependencies: { '@deepseek-ai/dsh-base': 'workspace:^' },
  199. }),
  200. ]
  201. // The install edge is absolute: base publishes first, and the peer edge that
  202. // would reverse it is the one dropped.
  203. const plan = dsh.publishOrder(members)
  204. expect(plan.order.map(entry => entry.name)).toEqual([
  205. '@deepseek-ai/dsh-base',
  206. '@deepseek-ai/dsh-consumer',
  207. ])
  208. expect(plan.droppedPeerEdges).toEqual([
  209. { consumer: '@deepseek-ai/dsh-base', peer: '@deepseek-ai/dsh-consumer' },
  210. ])
  211. })
  212. it('refuses an order that would publish a consumer before a dependency it installs', () => {
  213. const dsh = releaseFamily('dsh')
  214. const members = [
  215. member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { peerDependencies: { '@deepseek-ai/dsh-bravo': 'workspace:^' } }),
  216. member('packages/a/bravo', '@deepseek-ai/dsh-bravo', { peerDependencies: { '@deepseek-ai/dsh-charlie': 'workspace:^' } }),
  217. member('packages/a/charlie', '@deepseek-ai/dsh-charlie', { dependencies: { '@deepseek-ai/dsh-alpha': 'workspace:^' } }),
  218. ]
  219. // A cycle of two peer edges closed by one install edge: dropping a peer edge
  220. // would order this, and the traversal drops the install edge instead. That
  221. // order would publish charlie before the alpha it installs, so it is refused
  222. // here rather than published.
  223. expect(() => { dsh.publishOrder(members) }).toThrow(/no publish order honours @deepseek-ai\/dsh-charlie -> @deepseek-ai\/dsh-alpha/)
  224. })
  225. it('ignores devDependencies when ordering', () => {
  226. const dsh = releaseFamily('dsh')
  227. const members = [
  228. member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { devDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }),
  229. member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
  230. ]
  231. // A dev dependency is absent from the published package, so it must not move
  232. // the consumer behind it.
  233. expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([
  234. '@deepseek-ai/dsh-alpha',
  235. '@deepseek-ai/dsh-zebra',
  236. ])
  237. })
  238. it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => {
  239. const dsh = releaseFamily('dsh')
  240. const vendor = releaseFamily('vendor')
  241. const harness = member('packages/a/library', '@deepseek-ai/dsh-library')
  242. const vendored = member('vendor/cordis', '@deepseek-ai/cordis')
  243. expect(() => { dsh.validatePayload(harness, ['package/lib/index.js', 'package/src/index.ts']) })
  244. .toThrow(/publishes source file/)
  245. expect(() => { vendor.validatePayload(vendored, ['package/lib/index.js', 'package/src/index.ts']) }).not.toThrow()
  246. expect(() => { vendor.validatePayload(vendored, []) }).toThrow(/empty tarball/)
  247. })
  248. it('drives the installed entry only for the family that publishes one', () => {
  249. expect(releaseFamily('dsh').installedEntry).toEqual({ packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' })
  250. expect(releaseFamily('vendor').installedEntry).toBeUndefined()
  251. })
  252. it('rejects an unknown family identifier', () => {
  253. expect(() => { releaseFamily('native') }).toThrow(/unknown release family/)
  254. })
  255. })
  256. describe('vendored version baseline', () => {
  257. it('drops an upstream prerelease segment and increments the patch', () => {
  258. expect(nextVendorVersion('4.0.0-rc.7', undefined)).toBe('4.0.1')
  259. expect(nextVendorVersion('1.0.0-rc.5', undefined)).toBe('1.0.1')
  260. expect(nextVendorVersion('1.8.1', undefined)).toBe('1.8.2')
  261. })
  262. it('increments from the last published version when a re-sync restored a lower one', () => {
  263. // Upstream moved rc.7 -> rc.8 after this repository published 4.0.1;
  264. // incrementing the manifest alone would name 4.0.1 a second time.
  265. expect(nextVendorVersion('4.0.0-rc.8', '4.0.1')).toBe('4.0.2')
  266. expect(nextVendorVersion('4.1.0', '4.0.1')).toBe('4.1.1')
  267. })
  268. it('appends a rehearsal prerelease without consuming its release numbers', () => {
  269. // A rehearsal burns 4.0.1-rc.1 and leaves 4.0.1 free, so the stable release
  270. // that follows takes those same numbers instead of skipping to 4.0.2.
  271. expect(nextVendorVersion('4.0.0-rc.7', undefined, 'rc.1')).toBe('4.0.1-rc.1')
  272. expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1', 'rc.2')).toBe('4.0.1-rc.2')
  273. expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1')).toBe('4.0.1')
  274. expect(nextVendorVersion('4.0.0-rc.7', '4.0.1')).toBe('4.0.2')
  275. })
  276. })
  277. describe('version precedence', () => {
  278. it('orders alpha, canary, and release-candidate versions by semver precedence', () => {
  279. expect(compareVersions('4.0.1-alpha.1', '4.0.1-canary.1')).toBeLessThan(0)
  280. expect(compareVersions('4.0.1-canary.1', '4.0.1-rc.1')).toBeLessThan(0)
  281. expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
  282. })
  283. it('ranks a release above the prerelease it follows', () => {
  284. // git --sort=v:refname disagrees, placing 4.0.1-rc.1 above 4.0.1, which is
  285. // why the newest published version is chosen here rather than by git.
  286. expect(compareVersions('4.0.1', '4.0.1-rc.1')).toBeGreaterThan(0)
  287. expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
  288. })
  289. it('compares numeric prerelease fields numerically', () => {
  290. expect(compareVersions('4.0.1-rc.10', '4.0.1-rc.1')).toBeGreaterThan(0)
  291. expect(compareVersions('4.0.1-rc.2', '4.0.1-rc.10')).toBeLessThan(0)
  292. })
  293. it('ranks a numeric field below an alphanumeric one, and a shorter list below a longer', () => {
  294. expect(compareVersions('4.0.1-1', '4.0.1-alpha')).toBeLessThan(0)
  295. expect(compareVersions('4.0.1-rc', '4.0.1-rc.1')).toBeLessThan(0)
  296. expect(compareVersions('4.0.2', '4.0.1')).toBeGreaterThan(0)
  297. expect(compareVersions('4.0.1-rc.1', '4.0.1-rc.1')).toBe(0)
  298. })
  299. })
  300. describe('payload change judgement', () => {
  301. const sourceShipping = member('vendor/cosmokit', '@deepseek-ai/cosmokit', {
  302. files: ['lib/index.js', 'lib/types/**/*.d.ts', 'src'],
  303. })
  304. const buildOutputOnly = member('vendor/cordis', '@deepseek-ai/cordis', {
  305. files: ['lib/index.js', 'lib/types/**/*.d.ts', 'bin.js'],
  306. })
  307. it('counts the manifest and the files npm always publishes', () => {
  308. expect(reachesPayload(sourceShipping, 'vendor/cosmokit/package.json')).toBe(true)
  309. expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.md')).toBe(true)
  310. expect(reachesPayload(sourceShipping, 'vendor/cosmokit/src/index.ts')).toBe(true)
  311. })
  312. it('counts build inputs for a package whose payload is build output', () => {
  313. // cordis publishes lib/ only, and lib/ is not tracked: without this, a real
  314. // source change reads as "nothing changed" and the next publish fails on a
  315. // version whose bytes moved.
  316. expect(reachesPayload(buildOutputOnly, 'vendor/cordis/src/context.ts')).toBe(true)
  317. expect(reachesPayload(buildOutputOnly, 'vendor/cordis/tsconfig.json')).toBe(true)
  318. })
  319. it('ignores paths no tarball carries', () => {
  320. expect(reachesPayload(sourceShipping, 'vendor/cosmokit/tests/unit.spec.ts')).toBe(false)
  321. expect(reachesPayload(sourceShipping, 'vendor/cosmokit/CHANGELOG.md')).toBe(false)
  322. // The README pattern is deliberately loose: over-reporting a change costs one
  323. // unnecessary patch bump, while under-reporting fails the next publish on a
  324. // version whose bytes moved.
  325. expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.i18n.yaml')).toBe(true)
  326. expect(reachesPayload(member('packages/a/library', '@deepseek-ai/dsh-library', { files: ['lib/index.js'] }),
  327. 'packages/a/library/tests/library.spec.ts')).toBe(false)
  328. })
  329. })