// Release-time Conventional Commits versioner (npm run bump): scan the // commits since the last version change, pick the highest bump they call for // (breaking -> major, feat -> minor, fix/perf -> patch), and stage it into // package.json, package-lock.json, and public/manifest.json. This used to // run as a commit-msg hook, but bumping on every feat/fix rewrote the same // three files in every branch and parallel worktrees kept conflicting on // them. deploy-ext.sh still makes no version decisions; it only checks // agreement. import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { GIT_SHAPES, HEADER, bumpFor } from './conventional.mjs' const FILES = ['package.json', 'package-lock.json', 'public/manifest.json'] const RANK = { major: 3, minor: 2, patch: 1 } const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) const current = JSON.parse(readFileSync('package.json', 'utf8')).version const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec(current) if (!parts) { console.error(`version-bump: cannot bump non-x.y.z version "${current}"`) process.exit(1) } // Pending edits to the release files would get swept into the bump. const pending = git('status', '--porcelain', '--', ...FILES).trim() if (pending) { console.error(`version-bump: uncommitted changes to release files:\n${pending}`) console.error('version-bump: commit or stash them first') process.exit(1) } // Anchor on the commit that set the current version: walk the commits that // touched package.json, newest first, while they still hold the current // version. Works with or without release tags. let anchor = null for (const sha of git('log', '--format=%H', '--', 'package.json').split('\n').filter(Boolean)) { if (JSON.parse(git('show', `${sha}:package.json`)).version !== current) break anchor = sha } if (!anchor) { console.error('version-bump: no commit sets the current version — is package.json committed?') process.exit(1) } // One message per commit since the anchor, NUL-separated. const messages = git('log', '--format=%B%x00', `${anchor}..HEAD`) .split('\0') .map((message) => message.replace(/^\n/, '')) .filter((message) => message.trim() !== '') let bump = null let releasing = 0 for (const message of messages) { const lines = message.split('\n') const header = lines.find((line) => line.trim() !== '') ?? '' if (GIT_SHAPES.test(header)) continue if (!HEADER.test(header)) { console.error(`version-bump: skipping non-conventional header: "${header}"`) continue } const one = bumpFor(header, lines) if (!one) continue releasing += 1 if (!bump || RANK[one] > RANK[bump]) bump = one } const anchorShort = anchor.slice(0, 7) if (!bump) { console.log(`version-bump: no releasing commits since ${anchorShort} (${messages.length} scanned); nothing to do`) process.exit(0) } const [major, minor, patch] = parts.slice(1).map(Number) const next = bump === 'major' ? `${major + 1}.0.0` : bump === 'minor' ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}` // String-replace the hand-maintained files so their formatting survives; the // lockfile is npm-written 2-space JSON, which stringify round-trips. for (const file of ['package.json', 'public/manifest.json']) { const text = readFileSync(file, 'utf8') const needle = `"version": "${current}"` if (!text.includes(needle)) { console.error(`version-bump: ${file} has no ${needle}`) process.exit(1) } writeFileSync(file, text.replace(needle, `"version": "${next}"`)) } const lock = JSON.parse(readFileSync('package-lock.json', 'utf8')) lock.version = next if (lock.packages?.['']) lock.packages[''].version = next writeFileSync('package-lock.json', JSON.stringify(lock, null, 2) + '\n') execFileSync('git', ['add', ...FILES]) console.log(`version-bump: staged ${current} -> ${next} (${bump}) from ${releasing} releasing commits since ${anchorShort}`) console.log(`version-bump: next: git commit -m "chore(release): v${next}" && git tag "v${next}" -a -m "release v${next}" && git push --follow-tags`)