bump.ts 16 KB

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