bump.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. /**
  2. * Bump one release family's version and commit it, so the published version is
  3. * readable from the repository rather than derived inside CI
  4. * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)).
  5. *
  6. * The dsh family shares one version: `major`, `minor`, `patch`, or an explicit
  7. * `x.y.z` (including a prerelease such as `0.0.1-rc.1`). The vendored family
  8. * has one version line per package and publishes only what changed since that
  9. * package's own `vendor-<package>-v*` tag, which is the record of the commit it
  10. * last published from.
  11. *
  12. * The version lands in the manifests, the lockfile follows, and a human creates
  13. * the tag after the commit merges. CI never writes to the repository.
  14. */
  15. import { readFileSync, writeFileSync } from 'node:fs'
  16. import { join, matchesGlob } from 'node:path'
  17. import { parseArgs } from 'node:util'
  18. import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
  19. import { capture } from './process.ts'
  20. /** Files npm publishes whether or not `files` lists them. */
  21. const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const
  22. /** Release types the dsh family accepts besides an explicit version. */
  23. const RELEASE_TYPES = ['major', 'minor', 'patch'] as const
  24. /**
  25. * Split a version into its release numbers, discarding any prerelease segment.
  26. * @param version - the current version.
  27. * @returns Major, minor, and patch.
  28. */
  29. function releaseNumbers(version: string): [number, number, number] {
  30. const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version)
  31. if (match === null) throw new Error(`cannot read release numbers from version ${version}`)
  32. return [Number(match[1]), Number(match[2]), Number(match[3])]
  33. }
  34. /**
  35. * The next dsh version.
  36. * @param current - the family's current shared version.
  37. * @param request - `major`, `minor`, `patch`, or an explicit version.
  38. * @returns The target version.
  39. */
  40. function nextSharedVersion(current: string, request: string): string {
  41. if (!RELEASE_TYPES.includes(request as typeof RELEASE_TYPES[number])) {
  42. if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request)) {
  43. throw new Error(`usage: release:dsh <major|minor|patch|x.y.z>, got ${request}`)
  44. }
  45. return request
  46. }
  47. const [major, minor, patch] = releaseNumbers(current)
  48. if (request === 'major') return `${String(major + 1)}.0.0`
  49. if (request === 'minor') return `${String(major)}.${String(minor + 1)}.0`
  50. return `${String(major)}.${String(minor)}.${String(patch + 1)}`
  51. }
  52. /**
  53. * The version a vendored package publishes next: its release numbers with the
  54. * patch incremented, which also drops an upstream prerelease segment.
  55. * @param current - the package's current version.
  56. * @returns The target version.
  57. */
  58. function nextVendorVersion(current: string): string {
  59. const [major, minor, patch] = releaseNumbers(current)
  60. return `${String(major)}.${String(minor)}.${String(patch + 1)}`
  61. }
  62. /**
  63. * Whether a repository-relative path reaches the member's published payload.
  64. * @param member - the member the path belongs to.
  65. * @param path - repository-relative path.
  66. * @returns True when `files` (or npm's always-published set) selects it.
  67. */
  68. function reachesPayload(member: ReleaseMember, path: string): boolean {
  69. const relative = path.slice(member.directory.length + 1)
  70. const files = member.manifest.files
  71. const patterns = [
  72. ...ALWAYS_PUBLISHED,
  73. ...Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : [],
  74. ]
  75. return patterns.some(pattern =>
  76. matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern)
  77. }
  78. /**
  79. * The newest tag a member published from, or undefined when it never published.
  80. * @param family - the member's family.
  81. * @param member - the member.
  82. * @returns The tag name.
  83. */
  84. function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string | undefined {
  85. const prefix = family.tagFor(member).replace(/-v[^-]*$/, '-v')
  86. const tags = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']).split('\n').filter(line => line !== '')
  87. return tags[0]
  88. }
  89. /**
  90. * Whether a member's published payload changed since it last published.
  91. * @param family - the member's family.
  92. * @param member - the member.
  93. * @returns True when the member needs a new version.
  94. */
  95. function changedSincePublication(family: ReleaseFamily, member: ReleaseMember): boolean {
  96. const tag = lastPublishedTag(family, member)
  97. if (tag === undefined) return true
  98. const changed = capture('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory])
  99. .split('\n').filter(line => line !== '')
  100. return changed.some(path => reachesPayload(member, path))
  101. }
  102. /**
  103. * Write a version into a member's manifest, preserving formatting and key order.
  104. * @param root - repository root.
  105. * @param member - the member to rewrite.
  106. * @param version - the target version.
  107. */
  108. function writeVersion(root: string, member: ReleaseMember, version: string): void {
  109. const path = join(root, member.directory, 'package.json')
  110. const text = readFileSync(path, 'utf8')
  111. const line = `"version": "${member.version}"`
  112. if (!text.includes(line)) throw new Error(`${member.directory}: cannot locate ${line}`)
  113. writeFileSync(path, text.replace(line, `"version": "${version}"`))
  114. }
  115. /** Bump the family named by `--family` and commit; `--dry-run` only reports the plan. */
  116. function main(): void {
  117. const { values, positionals } = parseArgs({
  118. options: { family: { type: 'string' }, 'dry-run': { type: 'boolean', default: false } },
  119. allowPositionals: true,
  120. })
  121. if (values.family === undefined) throw new Error('usage: bump.ts --family <dsh|vendor> [version]')
  122. const family = releaseFamily(values.family)
  123. const root = process.cwd()
  124. const members = family.members(root)
  125. family.verifyVersions(members)
  126. const planned: { member: ReleaseMember; version: string }[] = []
  127. let sharedVersion: string | undefined
  128. if (family.id === 'dsh') {
  129. const request = positionals[0]
  130. if (request === undefined) throw new Error('usage: release:dsh <major|minor|patch|x.y.z>')
  131. const [first] = members
  132. if (first === undefined) throw new Error(`release family ${family.id} has no members`)
  133. sharedVersion = nextSharedVersion(first.version, request)
  134. for (const member of members) planned.push({ member, version: sharedVersion })
  135. } else {
  136. if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch')
  137. for (const member of members) {
  138. if (!changedSincePublication(family, member)) continue
  139. planned.push({ member, version: nextVendorVersion(member.version) })
  140. }
  141. }
  142. if (planned.length === 0) {
  143. console.log(`release bump: family ${family.id}, nothing changed since publication`)
  144. return
  145. }
  146. const dryRun = values['dry-run']
  147. if (!dryRun) {
  148. for (const { member, version } of planned) writeVersion(root, member, version)
  149. capture('pnpm', ['install', '--lockfile-only'])
  150. }
  151. const summary = sharedVersion
  152. ?? planned.map(entry => `${entry.member.name.replace('@deepseek-ai/', '')} ${entry.version}`).join(', ')
  153. console.log(`release bump: family ${family.id} -> ${summary}`)
  154. for (const { member, version } of planned) console.log(` ${member.directory}: ${member.version} -> ${version}`)
  155. if (dryRun) {
  156. console.log('release bump: dry run, nothing written')
  157. return
  158. }
  159. capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => join(entry.member.directory, 'package.json'))])
  160. capture('git', ['commit', '-m', `release(${family.id}): ${summary}`])
  161. // The dsh family tags once for its shared version; vendor tags each package.
  162. const tags = [...new Set(planned.map(entry => family.tagFor({ ...entry.member, version: entry.version })))]
  163. console.log('release bump: committed. After this merges to master, tag it:')
  164. for (const tag of tags) console.log(` git tag ${tag} <merge commit> && git push origin ${tag}`)
  165. }
  166. main()