bump.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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/implemented/process/2026-08-10-npm-release-sequences.md)).
  5. *
  6. * The dsh family shares one version across its publishable members, private
  7. * package manifests, and the workspace root:
  8. * `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such
  9. * as `0.0.1-rc.1`). The vendored family has one version line per package, but
  10. * every release advances and publishes the complete family so the next release
  11. * never reuses an unchanged member's existing version from a different
  12. * repository state.
  13. *
  14. * The version lands in the manifests, the lockfile follows, and a human creates
  15. * the tag after the commit merges. CI never writes to the repository.
  16. */
  17. import { globSync, readFileSync, writeFileSync } from 'node:fs'
  18. import { join, matchesGlob } from 'node:path'
  19. import { parseArgs } from 'node:util'
  20. import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
  21. import { capture, isEntry } from './process.ts'
  22. /** Files npm publishes whether or not `files` lists them. */
  23. const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const
  24. /**
  25. * Inputs that decide what a built payload contains. A package whose `files`
  26. * selects `lib/` publishes build output that git does not track, so a change to
  27. * the sources or the build configuration changes the tarball while no published
  28. * path appears in the diff.
  29. */
  30. const BUILD_INPUTS = ['src/**', 'tsconfig*.json', 'tsdown.config.*', 'build.config.*'] as const
  31. /** Release types the dsh family accepts besides an explicit version. */
  32. const RELEASE_TYPES = ['major', 'minor', 'patch'] as const
  33. /** The workspace root manifest, which carries the dsh family's version. */
  34. const ROOT_MANIFEST = 'package.json'
  35. /** One manifest the bump rewrites, and the tag its new version will carry. */
  36. interface PlannedVersion {
  37. readonly manifestPath: string
  38. readonly label: string
  39. readonly from: string
  40. readonly to: string
  41. /** The tag this version publishes from, or undefined for a non-published manifest. */
  42. readonly tag: string | undefined
  43. }
  44. /** One private dsh package whose version follows the publishable family. */
  45. interface PrivateDshVersion {
  46. /** Repository-relative manifest path. */
  47. readonly manifestPath: string
  48. /** Package directory used in bump output. */
  49. readonly label: string
  50. /** Current manifest version. */
  51. readonly version: string
  52. }
  53. /**
  54. * Split a version into its release numbers, discarding any prerelease segment.
  55. * @param version - the current version.
  56. * @returns Major, minor, and patch.
  57. */
  58. function releaseNumbers(version: string): [number, number, number] {
  59. const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version)
  60. if (match === null) throw new Error(`cannot read release numbers from version ${version}`)
  61. return [Number(match[1]), Number(match[2]), Number(match[3])]
  62. }
  63. /**
  64. * Order two versions by their release numbers alone.
  65. * @param left - one version.
  66. * @param right - the other version.
  67. * @returns Negative when `left` is lower, positive when higher, zero when equal.
  68. */
  69. function compareReleaseNumbers(left: string, right: string): number {
  70. const [leftMajor, leftMinor, leftPatch] = releaseNumbers(left)
  71. const [rightMajor, rightMinor, rightPatch] = releaseNumbers(right)
  72. return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch
  73. }
  74. /**
  75. * The prerelease segment of a version, or undefined when it has none.
  76. * @param version - the version to read.
  77. * @returns The segment after the first `-`.
  78. */
  79. function prereleaseOf(version: string): string | undefined {
  80. const index = version.indexOf('-')
  81. return index === -1 ? undefined : version.slice(index + 1)
  82. }
  83. /**
  84. * Order two versions by semver precedence.
  85. *
  86. * Git's version sort cannot stand in for this: `--sort=v:refname` places
  87. * `4.0.1-rc.1` above `4.0.1`, while semver gives a prerelease lower precedence
  88. * than the release it precedes. Prerelease identifiers compare field by field,
  89. * numeric fields numerically, so `rc.10` outranks `rc.1`.
  90. * @param left - one version.
  91. * @param right - the other version.
  92. * @returns Negative when `left` is lower, positive when higher, zero when equal.
  93. */
  94. export function compareVersions(left: string, right: string): number {
  95. const numbers = compareReleaseNumbers(left, right)
  96. if (numbers !== 0) return numbers
  97. const leftPre = prereleaseOf(left)
  98. const rightPre = prereleaseOf(right)
  99. if (leftPre === undefined || rightPre === undefined) {
  100. if (leftPre === rightPre) return 0
  101. return leftPre === undefined ? 1 : -1
  102. }
  103. const leftFields = leftPre.split('.')
  104. const rightFields = rightPre.split('.')
  105. for (let index = 0; index < Math.max(leftFields.length, rightFields.length); index += 1) {
  106. const leftField = leftFields[index]
  107. const rightField = rightFields[index]
  108. // A shorter identifier list has lower precedence when all its fields match.
  109. if (leftField === undefined) return -1
  110. if (rightField === undefined) return 1
  111. if (leftField === rightField) continue
  112. const leftNumeric = /^\d+$/.test(leftField)
  113. const rightNumeric = /^\d+$/.test(rightField)
  114. if (leftNumeric && rightNumeric) return Number(leftField) - Number(rightField)
  115. // Numeric fields have lower precedence than alphanumeric ones.
  116. if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
  117. return leftField < rightField ? -1 : 1
  118. }
  119. return 0
  120. }
  121. /**
  122. * The next dsh version.
  123. * @param current - the family's current shared version.
  124. * @param request - `major`, `minor`, `patch`, or an explicit version.
  125. * @returns The target version.
  126. */
  127. function nextSharedVersion(current: string, request: string): string {
  128. if (!RELEASE_TYPES.includes(request as typeof RELEASE_TYPES[number])) {
  129. if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request)) {
  130. throw new Error(`usage: release:dsh <major|minor|patch|x.y.z>, got ${request}`)
  131. }
  132. return request
  133. }
  134. const [major, minor, patch] = releaseNumbers(current)
  135. if (request === 'major') return `${String(major + 1)}.0.0`
  136. if (request === 'minor') return `${String(major)}.${String(minor + 1)}.0`
  137. return `${String(major)}.${String(minor)}.${String(patch + 1)}`
  138. }
  139. /**
  140. * The version a vendored package publishes next.
  141. *
  142. * The baseline is the higher of the manifest version and the last tagged
  143. * version: a vendor re-sync restores upstream's version, which is lower than
  144. * the release version this repository already reserved, and incrementing that
  145. * would reuse an existing version.
  146. *
  147. * A prerelease does not consume its own release numbers. Publishing
  148. * `4.0.1-rc.1` leaves `4.0.1` free, so the next stable version is `4.0.1`
  149. * rather than `4.0.2`, and a second prerelease keeps those numbers too.
  150. * @param current - the package's manifest version.
  151. * @param tagged - the version its newest tag names, when it has one.
  152. * @param prerelease - prerelease identifier to append, for a rehearsal publication.
  153. * @returns The target version.
  154. */
  155. export function nextVendorVersion(
  156. current: string,
  157. tagged: string | undefined,
  158. prerelease?: string,
  159. ): string {
  160. const taggedOrder = tagged === undefined ? undefined : compareReleaseNumbers(tagged, current)
  161. const ahead = taggedOrder !== undefined && taggedOrder > 0
  162. const baseline = ahead && tagged !== undefined ? tagged : current
  163. const [major, minor, patch] = releaseNumbers(baseline)
  164. // Reuse the numbers when the tagged version that set them is a prerelease
  165. // of them; increment when a stable release already holds them.
  166. const taggedPrerelease = tagged !== undefined && prereleaseOf(tagged) !== undefined
  167. const sameReleasePrereleases = taggedOrder === 0 && prereleaseOf(current) !== undefined
  168. const reuse = taggedPrerelease && (ahead || sameReleasePrereleases)
  169. const numbers = reuse
  170. ? `${String(major)}.${String(minor)}.${String(patch)}`
  171. : `${String(major)}.${String(minor)}.${String(patch + 1)}`
  172. return prerelease === undefined ? numbers : `${numbers}-${prerelease}`
  173. }
  174. /**
  175. * Whether a repository-relative path reaches the member's published payload.
  176. * @param member - the member the path belongs to.
  177. * @param path - repository-relative path.
  178. * @returns True when `files`, npm's always-published set, or a build input selects it.
  179. */
  180. export function reachesPayload(member: ReleaseMember, path: string): boolean {
  181. const relative = path.slice(member.directory.length + 1)
  182. const files = member.manifest.files
  183. const selected = Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : []
  184. const built = selected.some(pattern => pattern.startsWith('lib'))
  185. const patterns = [...ALWAYS_PUBLISHED, ...selected, ...built ? BUILD_INPUTS : []]
  186. return patterns.some(pattern =>
  187. matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern)
  188. }
  189. /**
  190. * The newest version a member tagged.
  191. * @param family - the member's family.
  192. * @param member - the member.
  193. * @returns The version, or undefined when the member has no release tag.
  194. */
  195. function lastTaggedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined {
  196. const prefix = family.tagPrefixFor(member)
  197. const versions = capture('git', ['tag', '--list', `${prefix}*`])
  198. .split('\n').filter(line => line !== '').map(tag => tag.slice(prefix.length))
  199. if (versions.length === 0) return undefined
  200. return versions.reduce((newest, candidate) => compareVersions(candidate, newest) > 0 ? candidate : newest)
  201. }
  202. /**
  203. * Write a version into a manifest, preserving formatting and key order.
  204. * @param root - repository root.
  205. * @param manifestPath - repository-relative manifest path.
  206. * @param from - the version the manifest currently carries.
  207. * @param to - the target version.
  208. */
  209. function writeVersion(root: string, manifestPath: string, from: string, to: string): void {
  210. const path = join(root, manifestPath)
  211. const text = readFileSync(path, 'utf8')
  212. const line = `"version": "${from}"`
  213. if (!text.includes(line)) throw new Error(`${manifestPath}: cannot locate ${line}`)
  214. writeFileSync(path, text.replace(line, `"version": "${to}"`))
  215. }
  216. /**
  217. * Read the workspace root version.
  218. * @param root - repository root.
  219. * @returns The root manifest version.
  220. */
  221. function rootVersion(root: string): string {
  222. const manifest: unknown = JSON.parse(readFileSync(join(root, ROOT_MANIFEST), 'utf8'))
  223. const version = (manifest as Record<string, unknown>).version
  224. if (typeof version !== 'string') throw new Error('package.json must declare a string version')
  225. return version
  226. }
  227. /**
  228. * Discover private package manifests that share the dsh version without joining
  229. * its publish set.
  230. * @param root - repository root.
  231. * @returns Private package manifests sorted by path.
  232. */
  233. function privateDshVersions(root: string): PrivateDshVersion[] {
  234. return globSync('packages/*/*/package.json', { cwd: root })
  235. .map(path => path.replaceAll('\\', '/'))
  236. .sort()
  237. .flatMap((manifestPath) => {
  238. const parsed: unknown = JSON.parse(readFileSync(join(root, manifestPath), 'utf8'))
  239. if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
  240. throw new Error(`${manifestPath} is not a JSON object`)
  241. }
  242. const manifest = parsed as Record<string, unknown>
  243. if (manifest.private !== true) return []
  244. if (typeof manifest.version !== 'string') {
  245. throw new Error(`${manifestPath} must declare a string version`)
  246. }
  247. return [{
  248. manifestPath,
  249. label: manifestPath.slice(0, -'/package.json'.length),
  250. version: manifest.version,
  251. }]
  252. })
  253. }
  254. /**
  255. * Plan the dsh family's rewrite: one version for every publishable member,
  256. * private package, and the root.
  257. * @param family - the dsh family.
  258. * @param root - repository root.
  259. * @param members - the family's members.
  260. * @param request - `major`, `minor`, `patch`, or an explicit version.
  261. * @returns The manifests to rewrite and the shared target version.
  262. */
  263. export function planShared(
  264. family: ReleaseFamily,
  265. root: string,
  266. members: readonly ReleaseMember[],
  267. request: string,
  268. ): { planned: PlannedVersion[]; version: string } {
  269. const [first] = members
  270. if (first === undefined) throw new Error(`release family ${family.id} has no members`)
  271. const version = nextSharedVersion(first.version, request)
  272. // The workspace root carries the family version too: the workspace constraint
  273. // requires every member's version to equal the root's.
  274. const planned: PlannedVersion[] = [
  275. { manifestPath: ROOT_MANIFEST, label: ROOT_MANIFEST, from: rootVersion(root), to: version, tag: undefined },
  276. ]
  277. for (const member of members) {
  278. planned.push({
  279. manifestPath: `${member.directory}/package.json`,
  280. label: member.directory,
  281. from: member.version,
  282. to: version,
  283. tag: family.tagFor({ ...member, version }),
  284. })
  285. }
  286. const publishableManifests = new Set(members.map(member => `${member.directory}/package.json`))
  287. for (const entry of privateDshVersions(root)) {
  288. if (publishableManifests.has(entry.manifestPath)) continue
  289. planned.push({
  290. manifestPath: entry.manifestPath,
  291. label: entry.label,
  292. from: entry.version,
  293. to: version,
  294. tag: undefined,
  295. })
  296. }
  297. return { planned, version }
  298. }
  299. /**
  300. * Plan the vendored family's rewrite: every package advances together while
  301. * retaining its own version line and tag.
  302. * @param family - the vendored family.
  303. * @param members - the family's members.
  304. * @param prerelease - prerelease identifier to append, for a rehearsal publication.
  305. * @returns The manifests to rewrite.
  306. */
  307. function planPerPackage(
  308. family: ReleaseFamily,
  309. members: readonly ReleaseMember[],
  310. prerelease: string | undefined,
  311. ): PlannedVersion[] {
  312. const planned: PlannedVersion[] = []
  313. for (const member of members) {
  314. const tagged = lastTaggedVersion(family, member)
  315. const to = nextVendorVersion(member.version, tagged, prerelease)
  316. planned.push({
  317. manifestPath: `${member.directory}/package.json`,
  318. label: member.directory,
  319. from: member.version,
  320. to,
  321. tag: family.tagFor({ ...member, version: to }),
  322. })
  323. }
  324. return planned
  325. }
  326. /**
  327. * Bump the family named by `--family` and commit; `--dry-run` only reports the
  328. * plan. `--prerelease rc.1` makes the vendored family publish a rehearsal
  329. * version, which never takes the stable dist-tag.
  330. */
  331. function main(): void {
  332. const { values, positionals } = parseArgs({
  333. options: {
  334. family: { type: 'string' },
  335. prerelease: { type: 'string' },
  336. 'dry-run': { type: 'boolean', default: false },
  337. },
  338. allowPositionals: true,
  339. })
  340. if (values.family === undefined) throw new Error('usage: bump.ts --family <dsh|vendor> [version]')
  341. const family = releaseFamily(values.family)
  342. const root = process.cwd()
  343. const members = family.members(root)
  344. family.verifyVersions(members)
  345. let planned: PlannedVersion[]
  346. let sharedVersion: string | undefined
  347. if (family.id === 'dsh') {
  348. const request = positionals[0]
  349. if (request === undefined) throw new Error('usage: release:dsh <major|minor|patch|x.y.z>')
  350. if (values.prerelease !== undefined) {
  351. throw new Error('release:dsh takes the prerelease in its version argument, as in 0.0.1-rc.1')
  352. }
  353. const shared = planShared(family, root, members, request)
  354. planned = shared.planned
  355. sharedVersion = shared.version
  356. } else {
  357. if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch')
  358. if (values.prerelease !== undefined && !/^[0-9A-Za-z.-]+$/.test(values.prerelease)) {
  359. throw new Error(`--prerelease must be a semver prerelease identifier, got ${values.prerelease}`)
  360. }
  361. planned = planPerPackage(family, members, values.prerelease)
  362. }
  363. if (planned.length === 0) {
  364. console.log(`release bump: family ${family.id}, nothing changed since publication`)
  365. return
  366. }
  367. const dryRun = values['dry-run']
  368. if (!dryRun) {
  369. for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to)
  370. capture('pnpm', ['install', '--lockfile-only'])
  371. }
  372. const summary = sharedVersion
  373. ?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ')
  374. console.log(`release bump: family ${family.id} -> ${summary}`)
  375. for (const entry of planned) console.log(` ${entry.label}: ${entry.from} -> ${entry.to}`)
  376. if (dryRun) {
  377. console.log('release bump: dry run, nothing written')
  378. return
  379. }
  380. capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)])
  381. capture('git', ['commit', '-m', `release(${family.id}): ${summary}`])
  382. console.log('release bump: committed. After this merges to master, tag it:')
  383. for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) {
  384. console.log(` git tag ${tag} <merge commit> && git push origin ${tag}`)
  385. }
  386. }
  387. if (isEntry(import.meta.url)) main()