|
| 1 | +/** |
| 2 | + * Prepare a release by bumping the version in package.json and rolling CHANGELOG.md. |
| 3 | + */ |
| 4 | + |
| 5 | +import { readFileSync, writeFileSync } from "node:fs" |
| 6 | +import { dirname, join } from "node:path" |
| 7 | +import { fileURLToPath, pathToFileURL } from "node:url" |
| 8 | + |
| 9 | +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") |
| 10 | +const VERSION_FILE = |
| 11 | + process.env.PREPARE_RELEASE_VERSION_FILE ?? join(ROOT, "package.json") |
| 12 | +const CHANGELOG_FILE = |
| 13 | + process.env.PREPARE_RELEASE_RELEASE_NOTES_FILE ?? join(ROOT, "CHANGELOG.md") |
| 14 | + |
| 15 | +const RELEASE_NOTES_HEADER = "# Release Notes\n\n" |
| 16 | +const LATEST_CHANGES_HEADER = "## Latest Changes" |
| 17 | + |
| 18 | +// Matches the single top-level `"version": "X.Y.Z"` in package.json. |
| 19 | +const VERSION_PATTERN = |
| 20 | + /^(?<indent>\s*)"version":\s*"(?<version>\d+\.\d+\.\d+)"/m |
| 21 | +// Matches any version section heading, with or without a date suffix, |
| 22 | +// e.g. `## 0.2.2` or `## 0.2.2 (2026-06-16)`. |
| 23 | +const VERSION_HEADING_PATTERN = /^## \d+\.\d+\.\d+(?: \([^)]+\))?\s*$/m |
| 24 | + |
| 25 | +function parseVersion(version) { |
| 26 | + if (!/^\d+\.\d+\.\d+$/.test(version)) { |
| 27 | + throw new Error(`Invalid version: '${version}'. Expected format: X.Y.Z`) |
| 28 | + } |
| 29 | + return version.split(".").map(Number) |
| 30 | +} |
| 31 | + |
| 32 | +function bumpVersion(version, bump) { |
| 33 | + const [major, minor, patch] = parseVersion(version) |
| 34 | + if (bump === "major") return `${major + 1}.0.0` |
| 35 | + if (bump === "minor") return `${major}.${minor + 1}.0` |
| 36 | + if (bump === "patch") return `${major}.${minor}.${patch + 1}` |
| 37 | + throw new Error(`Invalid bump: '${bump}'. Expected major, minor, or patch.`) |
| 38 | +} |
| 39 | + |
| 40 | +function getCurrentVersion(content) { |
| 41 | + const matches = [...content.matchAll(new RegExp(VERSION_PATTERN, "gm"))] |
| 42 | + if (matches.length !== 1) { |
| 43 | + throw new Error( |
| 44 | + `Expected exactly one "version" assignment in package.json, found ${matches.length}`, |
| 45 | + ) |
| 46 | + } |
| 47 | + return matches[0].groups.version |
| 48 | +} |
| 49 | + |
| 50 | +function updateVersionFile(content, version) { |
| 51 | + const current = getCurrentVersion(content) |
| 52 | + if (compareVersions(parseVersion(version), parseVersion(current)) <= 0) { |
| 53 | + throw new Error( |
| 54 | + `New version ${version} must be greater than current version ${current}`, |
| 55 | + ) |
| 56 | + } |
| 57 | + return content.replace(VERSION_PATTERN, `$<indent>"version": "${version}"`) |
| 58 | +} |
| 59 | + |
| 60 | +function compareVersions(a, b) { |
| 61 | + for (let i = 0; i < 3; i++) { |
| 62 | + if (a[i] !== b[i]) return a[i] - b[i] |
| 63 | + } |
| 64 | + return 0 |
| 65 | +} |
| 66 | + |
| 67 | +function updateChangelog(content, version, date) { |
| 68 | + if (!content.startsWith(RELEASE_NOTES_HEADER)) { |
| 69 | + throw new Error( |
| 70 | + `CHANGELOG.md must start with '${RELEASE_NOTES_HEADER.trim()}'`, |
| 71 | + ) |
| 72 | + } |
| 73 | + if (versionHeadingRegex(version).test(content)) { |
| 74 | + throw new Error(`CHANGELOG.md already contains a section for ${version}`) |
| 75 | + } |
| 76 | + |
| 77 | + const latestHeader = `${RELEASE_NOTES_HEADER}${LATEST_CHANGES_HEADER}\n` |
| 78 | + if (!content.startsWith(latestHeader)) { |
| 79 | + throw new Error(`CHANGELOG.md must start with '${latestHeader.trim()}'`) |
| 80 | + } |
| 81 | + |
| 82 | + return content.replace( |
| 83 | + latestHeader, |
| 84 | + `${RELEASE_NOTES_HEADER}${LATEST_CHANGES_HEADER}\n\n## ${version} (${date})\n`, |
| 85 | + ) |
| 86 | +} |
| 87 | + |
| 88 | +function getReleaseNotesBody(content, version) { |
| 89 | + const match = versionHeadingRegex(version).exec(content) |
| 90 | + if (!match) { |
| 91 | + throw new Error(`Could not find CHANGELOG section for ${version}`) |
| 92 | + } |
| 93 | + |
| 94 | + const rest = content.slice(match.index + match[0].length) |
| 95 | + const next = VERSION_HEADING_PATTERN.exec(rest) |
| 96 | + const body = (next ? rest.slice(0, next.index) : rest).trim() |
| 97 | + if (!body) { |
| 98 | + throw new Error(`CHANGELOG section for ${version} is empty`) |
| 99 | + } |
| 100 | + return `${body}\n` |
| 101 | +} |
| 102 | + |
| 103 | +function versionHeadingRegex(version) { |
| 104 | + return new RegExp(`^## ${escapeRegExp(version)}(?: \\([^)]+\\))?\\s*$`, "m") |
| 105 | +} |
| 106 | + |
| 107 | +function escapeRegExp(value) { |
| 108 | + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") |
| 109 | +} |
| 110 | + |
| 111 | +/** Validates a YYYY-MM-DD date, or returns today (UTC) when empty. */ |
| 112 | +function resolveDate(input) { |
| 113 | + if (!input) return new Date().toISOString().slice(0, 10) |
| 114 | + const parsed = new Date(`${input}T00:00:00Z`) |
| 115 | + if ( |
| 116 | + Number.isNaN(parsed.getTime()) || |
| 117 | + parsed.toISOString().slice(0, 10) !== input |
| 118 | + ) { |
| 119 | + throw new Error(`Invalid date: '${input}'. Expected format: YYYY-MM-DD`) |
| 120 | + } |
| 121 | + return input |
| 122 | +} |
| 123 | + |
| 124 | +function commandPrepare(bump, dateArg) { |
| 125 | + if (!bump) throw new Error("Usage: prepare <patch|minor|major> [YYYY-MM-DD]") |
| 126 | + const date = resolveDate(dateArg) |
| 127 | + const pkg = readFileSync(VERSION_FILE, "utf8") |
| 128 | + const changelog = readFileSync(CHANGELOG_FILE, "utf8") |
| 129 | + const version = bumpVersion(getCurrentVersion(pkg), bump) |
| 130 | + |
| 131 | + writeFileSync(VERSION_FILE, updateVersionFile(pkg, version)) |
| 132 | + writeFileSync(CHANGELOG_FILE, updateChangelog(changelog, version, date)) |
| 133 | + process.stdout.write(`Prepared release ${version} (${date})\n`) |
| 134 | +} |
| 135 | + |
| 136 | +function commandCurrentVersion() { |
| 137 | + process.stdout.write( |
| 138 | + `${getCurrentVersion(readFileSync(VERSION_FILE, "utf8"))}\n`, |
| 139 | + ) |
| 140 | +} |
| 141 | + |
| 142 | +function commandReleaseNotes() { |
| 143 | + const version = getCurrentVersion(readFileSync(VERSION_FILE, "utf8")) |
| 144 | + process.stdout.write( |
| 145 | + getReleaseNotesBody(readFileSync(CHANGELOG_FILE, "utf8"), version), |
| 146 | + ) |
| 147 | +} |
| 148 | + |
| 149 | +function main(argv) { |
| 150 | + const [command, arg, arg2] = argv |
| 151 | + try { |
| 152 | + if (command === "prepare") commandPrepare(arg, arg2) |
| 153 | + else if (command === "current-version") commandCurrentVersion() |
| 154 | + else if (command === "release-notes") commandReleaseNotes() |
| 155 | + else { |
| 156 | + process.stderr.write( |
| 157 | + "Usage: prepare-release.mjs <prepare <bump>|current-version|release-notes>\n", |
| 158 | + ) |
| 159 | + process.exit(2) |
| 160 | + } |
| 161 | + } catch (error) { |
| 162 | + process.stderr.write(`${error.message}\n`) |
| 163 | + process.exit(1) |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +// Run as a CLI only when executed directly, so tests can import the pure |
| 168 | +// functions below without triggering file writes. |
| 169 | +if ( |
| 170 | + process.argv[1] && |
| 171 | + import.meta.url === pathToFileURL(process.argv[1]).href |
| 172 | +) { |
| 173 | + main(process.argv.slice(2)) |
| 174 | +} |
| 175 | + |
| 176 | +export { |
| 177 | + parseVersion, |
| 178 | + bumpVersion, |
| 179 | + getCurrentVersion, |
| 180 | + updateVersionFile, |
| 181 | + updateChangelog, |
| 182 | + getReleaseNotesBody, |
| 183 | + resolveDate, |
| 184 | +} |
0 commit comments