families.spec.ts 16 KB

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