families.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. /**
  2. * The three independent publish sequences this repository releases from
  3. * (`packages/` + `apps/`, `vendor/`, and `native/`) and the two this module
  4. * owns: `dsh` and `vendor`. Each family carries its own version baseline, tag
  5. * naming, and publish set, so releasing one never republishes another
  6. * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
  7. *
  8. * The family dimension lives here only. A new sequence adds a subclass and a
  9. * `releaseFamilies()` entry; nothing else in the release scripts branches on it.
  10. */
  11. import { globSync, readFileSync } from 'node:fs'
  12. import { resolve } from 'node:path'
  13. import {
  14. officialClientBuildEnvironment,
  15. readClientBuildRecord,
  16. } from '../client-build-environment.ts'
  17. import { PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES } from '../experimental-package-policy.ts'
  18. import { validateTarballPayload } from '../publication-payload.ts'
  19. /**
  20. * Dependency sections a consumer must publish after, because npm resolves them
  21. * when the package is installed: publishing a consumer first would leave a
  22. * window where its own tree cannot be assembled.
  23. */
  24. const INSTALL_SECTIONS = ['dependencies', 'optionalDependencies'] as const
  25. /**
  26. * Peer declarations also order the publication, but they cannot constrain it.
  27. * npm never installs a peer on the package's behalf — an unmet peer is a
  28. * warning, not a resolution failure — and sibling packages legitimately declare
  29. * each other as peers, which makes these edges the ones that close cycles. They
  30. * order what they can and are dropped where they would deadlock.
  31. */
  32. const PEER_SECTIONS = ['peerDependencies'] as const
  33. /** The workspace root manifest, which is never a release member. */
  34. const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root'
  35. /** One peer declaration the publish order leaves unordered. */
  36. interface DroppedPeerEdge {
  37. readonly consumer: string
  38. /** The declared peer, which publishes after `consumer` or alongside it in a cycle. */
  39. readonly peer: string
  40. }
  41. /**
  42. * A family's publish order together with the ordering it could not honour.
  43. *
  44. * The dropped edges are part of the result rather than a detail of forming it:
  45. * a release drops real ordering constraints, and the operator reading the pack
  46. * log is the only one who can judge whether a newly dropped edge is expected.
  47. */
  48. export interface PublishPlan {
  49. readonly order: readonly ReleaseMember[]
  50. /** Peer declarations left unordered, in the order the traversal reached them. */
  51. readonly droppedPeerEdges: readonly DroppedPeerEdge[]
  52. }
  53. /** One publishable package of a release family. */
  54. export interface ReleaseMember {
  55. readonly directory: string
  56. readonly name: string
  57. readonly version: string
  58. readonly manifest: Readonly<Record<string, unknown>>
  59. }
  60. /**
  61. * Read and parse a JSON file.
  62. * @param path - absolute file path.
  63. * @returns The parsed object.
  64. */
  65. function readManifest(path: string): Record<string, unknown> {
  66. const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
  67. if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
  68. throw new Error(`${path} is not a JSON object`)
  69. }
  70. return parsed as Record<string, unknown>
  71. }
  72. /**
  73. * Read a required string field.
  74. * @param manifest - parsed manifest.
  75. * @param field - field name.
  76. * @param context - manifest path for the error message.
  77. * @returns The field value.
  78. */
  79. function requireString(manifest: Record<string, unknown>, field: string, context: string): string {
  80. const value = manifest[field]
  81. if (typeof value !== 'string' || value === '') throw new Error(`${context} must declare a string ${field}`)
  82. return value
  83. }
  84. /** The executable a family's installed artifacts are driven through. */
  85. export interface InstalledEntry {
  86. readonly packageName: string
  87. readonly binPath: string
  88. }
  89. /** A release sequence: its members, its version baseline, and its tag naming. */
  90. export abstract class ReleaseFamily {
  91. /** Workflow-facing `--family` identifier. */
  92. abstract readonly id: string
  93. /** Repository-relative glob patterns selecting this family's manifests. */
  94. abstract readonly patterns: readonly string[]
  95. /** Git tag prefix this family publishes from. */
  96. abstract readonly tagPrefix: string
  97. /**
  98. * Assert that built artifacts match this release family's required profile.
  99. * Families without environment-selected artifacts accept every build tree.
  100. * @param _root - repository root containing generated artifacts.
  101. */
  102. verifyBuildArtifacts(_root: string): void {}
  103. /**
  104. * Discover this family's members.
  105. * @param root - repository root.
  106. * @returns Publishable members sorted by directory, with names validated and deduplicated.
  107. */
  108. members(root: string): ReleaseMember[] {
  109. const manifestPaths = globSync([...this.patterns], { cwd: root }).sort()
  110. if (manifestPaths.length === 0) throw new Error(`release family ${this.id} matched no manifests`)
  111. const members: ReleaseMember[] = []
  112. const seen = new Set<string>()
  113. for (const manifestPath of manifestPaths) {
  114. const normalized = manifestPath.replaceAll('\\', '/')
  115. const manifest = readManifest(resolve(root, manifestPath))
  116. if (manifest.private === true) continue
  117. const name = requireString(manifest, 'name', normalized)
  118. const version = requireString(manifest, 'version', normalized)
  119. if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`)
  120. if (!name.startsWith('@deepseek-ai/')) throw new Error(`${normalized} must name an @deepseek-ai package`)
  121. if (seen.has(name)) throw new Error(`${name} appears twice in release family ${this.id}`)
  122. seen.add(name)
  123. members.push({
  124. directory: normalized.slice(0, normalized.length - '/package.json'.length),
  125. name,
  126. version,
  127. manifest,
  128. })
  129. }
  130. return members
  131. }
  132. /**
  133. * Order members so every package publishes after the family members it
  134. * depends on, which is what makes a partial publication self-consistent: an
  135. * interrupted run leaves a prefix whose packages never point at something
  136. * absent from the registry.
  137. *
  138. * Install edges are honoured absolutely — a cycle among them is a defect this
  139. * reports rather than works around. Peer edges order what they can and are
  140. * dropped where honouring one would deadlock: sibling packages declare each
  141. * other as peers, and npm treats an unmet peer as a warning rather than a
  142. * resolution failure ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
  143. * Every dropped edge is reported, because dropping one is a decision about a
  144. * real release rather than an implementation detail.
  145. * @param members - this family's members.
  146. * @returns The order, ties broken by name for determinism, and the peer edges it left unordered.
  147. */
  148. publishOrder(members: readonly ReleaseMember[]): PublishPlan {
  149. const byName = new Map(members.map(member => [member.name, member]))
  150. const byNameSorted = [...members].sort((left, right) => left.name.localeCompare(right.name))
  151. const edges = (member: ReleaseMember, sections: readonly string[]): ReleaseMember[] =>
  152. this.orderEdges(member, byName, sections)
  153. // Install edges alone must be acyclic, and that is checked on its own graph:
  154. // a peer edge leading into an install edge would otherwise read as a cycle
  155. // where the install edges are perfectly orderable.
  156. const installVisiting = new Set<string>()
  157. const installDone = new Set<string>()
  158. const checkInstall = (member: ReleaseMember, path: readonly string[]): void => {
  159. if (installDone.has(member.name)) return
  160. if (installVisiting.has(member.name)) {
  161. throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`)
  162. }
  163. installVisiting.add(member.name)
  164. for (const dependency of edges(member, INSTALL_SECTIONS)) checkInstall(dependency, [...path, member.name])
  165. installVisiting.delete(member.name)
  166. installDone.add(member.name)
  167. }
  168. for (const member of byNameSorted) checkInstall(member, [])
  169. // Emit the order over both kinds of edge. A node already on the stack closes
  170. // a cycle, and that cycle carries at least one peer edge because the install
  171. // edges were just proved acyclic — but the back edge that reaches the stacked
  172. // node is not necessarily the peer one, so the post-condition below decides
  173. // whether the emitted order survived.
  174. const ordered: ReleaseMember[] = []
  175. const droppedPeerEdges: DroppedPeerEdge[] = []
  176. const placed = new Set<string>()
  177. const onStack = new Set<string>()
  178. // Members reachable from one member through install edges. A peer edge is
  179. // dropped when the peer installs the member declaring it: honouring it would
  180. // emit a package before something it installs, and the install edge wins.
  181. const installClosure = (member: ReleaseMember): Set<string> => {
  182. const reached = new Set<string>()
  183. const walk = (current: ReleaseMember): void => {
  184. for (const dependency of edges(current, INSTALL_SECTIONS)) {
  185. if (reached.has(dependency.name)) continue
  186. reached.add(dependency.name)
  187. walk(dependency)
  188. }
  189. }
  190. walk(member)
  191. return reached
  192. }
  193. const visit = (member: ReleaseMember): void => {
  194. if (placed.has(member.name) || onStack.has(member.name)) return
  195. onStack.add(member.name)
  196. for (const dependency of edges(member, INSTALL_SECTIONS)) visit(dependency)
  197. for (const peer of edges(member, PEER_SECTIONS)) {
  198. if (installClosure(peer).has(member.name)) {
  199. droppedPeerEdges.push({ consumer: member.name, peer: peer.name })
  200. continue
  201. }
  202. // A peer already on the stack is an ancestor, so it publishes after this
  203. // member rather than before it: the edge is dropped, not honoured.
  204. if (onStack.has(peer.name)) droppedPeerEdges.push({ consumer: member.name, peer: peer.name })
  205. visit(peer)
  206. }
  207. onStack.delete(member.name)
  208. placed.add(member.name)
  209. ordered.push(member)
  210. }
  211. for (const member of byNameSorted) visit(member)
  212. // A cycle mixing both kinds of edge can put an install edge's target on the
  213. // stack, where the traversal skips it like a peer edge and emits a consumer
  214. // before something it installs. Nothing downstream can detect that, and it
  215. // would only surface as an unresolvable install for whoever consumes the
  216. // published packages, so the emitted order is checked against the edges it
  217. // exists to honour.
  218. const position = new Map(ordered.map((entry, index) => [entry.name, index]))
  219. for (const [index, member] of ordered.entries()) {
  220. for (const dependency of edges(member, INSTALL_SECTIONS)) {
  221. const dependencyIndex = position.get(dependency.name)
  222. if (dependencyIndex !== undefined && dependencyIndex < index) continue
  223. throw new Error(
  224. `release family ${this.id}: no publish order honours ${member.name} -> ${dependency.name};`
  225. + ' a cycle mixing peer and dependency declarations reaches this dependency through a peer edge',
  226. )
  227. }
  228. }
  229. return { order: ordered, droppedPeerEdges }
  230. }
  231. /**
  232. * The family members one member declares in the given sections.
  233. * @param member - the dependent member.
  234. * @param byName - every family member by package name.
  235. * @param sections - manifest sections to read.
  236. * @returns Members of this family named there, sorted by name.
  237. */
  238. private orderEdges(
  239. member: ReleaseMember,
  240. byName: ReadonlyMap<string, ReleaseMember>,
  241. sections: readonly string[],
  242. ): ReleaseMember[] {
  243. const edges: ReleaseMember[] = []
  244. for (const section of sections) {
  245. const dependencies = member.manifest[section]
  246. if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue
  247. for (const name of Object.keys(dependencies)) {
  248. const dependency = byName.get(name)
  249. if (dependency !== undefined && dependency.name !== member.name) edges.push(dependency)
  250. }
  251. }
  252. return edges.sort((left, right) => left.name.localeCompare(right.name))
  253. }
  254. /**
  255. * Assert this family's version baseline holds across its members.
  256. * @param members - this family's members.
  257. */
  258. abstract verifyVersions(members: readonly ReleaseMember[]): void
  259. /**
  260. * The tag prefix a member's versions are tagged under. Every tag for that
  261. * member starts with it, which is how the last published version is found.
  262. * @param member - the member being published.
  263. * @returns The prefix, ending in `-v`.
  264. */
  265. abstract tagPrefixFor(member: ReleaseMember): string
  266. /**
  267. * The npm dist-tag assigned while publishing a version.
  268. * @param version - package version from the packed manifest.
  269. * @returns `next` for a prerelease, or undefined so npm uses `latest`.
  270. */
  271. distTagForVersion(version: string): string | undefined {
  272. return version.includes('-') ? 'next' : undefined
  273. }
  274. /**
  275. * The tag a member publishes from.
  276. * @param member - the member being published.
  277. * @returns The full tag name, without `refs/tags/`.
  278. */
  279. tagFor(member: ReleaseMember): string {
  280. return `${this.tagPrefixFor(member)}${member.version}`
  281. }
  282. /**
  283. * Check what a member's packed tarball carries.
  284. * @param member - the packed member.
  285. * @param files - every path inside its tarball.
  286. */
  287. abstract validatePayload(member: ReleaseMember, files: readonly string[]): void
  288. /**
  289. * The executable that proves this family's artifacts install and run, or
  290. * `undefined` for a family that publishes no executable.
  291. */
  292. abstract readonly installedEntry: InstalledEntry | undefined
  293. }
  294. /** Release packages and apps: one shared version across the whole family. */
  295. class DshFamily extends ReleaseFamily {
  296. readonly id = 'dsh'
  297. readonly patterns = [
  298. 'packages/!(experimental)/*/package.json',
  299. 'apps/*/package.json',
  300. ...PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES.map(directory => `${directory}/package.json`),
  301. ] as const
  302. readonly tagPrefix = 'dsh-v'
  303. /** Require current artifacts from a complete official client build. */
  304. override verifyBuildArtifacts(root: string): void {
  305. readClientBuildRecord(root, officialClientBuildEnvironment(root))
  306. }
  307. /**
  308. * Require one version across the family, the way a single tag can name it.
  309. * @param members - this family's members.
  310. */
  311. verifyVersions(members: readonly ReleaseMember[]): void {
  312. const versions = new Set(members.map(member => member.version))
  313. if (versions.size !== 1) {
  314. const detail = members.map(member => `${member.directory}: ${member.version}`).join('\n')
  315. throw new Error(`dsh release members must share one version:\n${detail}`)
  316. }
  317. }
  318. /**
  319. * The single family prefix: every member shares one version, so one tag names it.
  320. * @returns `dsh-v`.
  321. */
  322. tagPrefixFor(): string {
  323. return this.tagPrefix
  324. }
  325. override distTagForVersion(version: string): string | undefined {
  326. const separator = version.indexOf('-')
  327. if (separator === -1) return undefined
  328. const [channel] = version.slice(separator + 1).split('.')
  329. if (channel === 'alpha' || channel === 'canary') return channel
  330. return 'next'
  331. }
  332. /**
  333. * Reject source and declaration-map members, the repository's publication policy.
  334. * @param member - the packed member.
  335. * @param files - every path inside its tarball.
  336. */
  337. validatePayload(member: ReleaseMember, files: readonly string[]): void {
  338. validateTarballPayload(files, member.name)
  339. }
  340. readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' }
  341. }
  342. /** `vendor/*`: every package keeps its own version line, so every package has its own tag. */
  343. class VendorFamily extends ReleaseFamily {
  344. readonly id = 'vendor'
  345. readonly patterns = ['vendor/*/package.json'] as const
  346. readonly tagPrefix = 'vendor-'
  347. /**
  348. * Accept independent versions; only reject a version this repository cannot publish.
  349. * @param members - this family's members.
  350. */
  351. verifyVersions(members: readonly ReleaseMember[]): void {
  352. for (const member of members) {
  353. if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(member.version)) {
  354. throw new Error(`${member.directory} has an unpublishable version: ${member.version}`)
  355. }
  356. }
  357. }
  358. /**
  359. * A prefix per member, because one vendor release can carry several versions.
  360. * @param member - the member being published.
  361. * @returns `vendor-<unscoped name>-v`.
  362. */
  363. tagPrefixFor(member: ReleaseMember): string {
  364. return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v`
  365. }
  366. /**
  367. * Require the payload the vendored manifest declares, including upstream's
  368. * `src` tree and declaration maps.
  369. *
  370. * The harness policy that rejects both does not apply here: these manifests
  371. * export `./src/*` for source navigation, so dropping `src` would publish a
  372. * package whose export map points at absent files. What must hold instead is
  373. * that every path the manifest selects is present, which `files` already
  374. * decides and `pnpm pack` already enforces.
  375. * @param member - the packed member.
  376. * @param files - every path inside its tarball.
  377. */
  378. validatePayload(member: ReleaseMember, files: readonly string[]): void {
  379. if (files.length === 0) throw new Error(`${member.name} packed an empty tarball`)
  380. }
  381. /** No installed-entry probe: these are libraries a consumer imports, with no executable. */
  382. readonly installedEntry = undefined
  383. }
  384. /** Every release family this module owns, in workflow order. */
  385. function releaseFamilies(): readonly ReleaseFamily[] {
  386. return [new DshFamily(), new VendorFamily()]
  387. }
  388. /**
  389. * Resolve a family by its `--family` identifier.
  390. * @param id - family identifier.
  391. * @returns The family.
  392. */
  393. export function releaseFamily(id: string): ReleaseFamily {
  394. const family = releaseFamilies().find(candidate => candidate.id === id)
  395. if (family === undefined) {
  396. const known = releaseFamilies().map(candidate => candidate.id).join(', ')
  397. throw new Error(`unknown release family ${id}; expected one of ${known}`)
  398. }
  399. return family
  400. }
  401. /**
  402. * The npm tarball filename `pnpm pack` writes for a member.
  403. * @param member - the packed member.
  404. * @returns The tarball filename.
  405. */
  406. export function tarballName(member: ReleaseMember): string {
  407. const unscoped = member.name.startsWith('@') ? member.name.slice(1).replace('/', '-') : member.name
  408. return `${unscoped}-${member.version}.tgz`
  409. }