diff --git a/README.md b/README.md index 49dbf3f..a4dae37 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Whenever you push to the default branch, this action parses conventional commits | `checkout` | `true` | Set to `false` if the caller has already checked out with `fetch-depth: 0`. | | `packages` | _(unset)_ | Newline-separated list of publishable workspace directories (paths or globs, e.g. `packages/*`). When set, uppt operates in monorepo lockstep mode. See [Monorepo support](#monorepo-support). | | `allow-forks` | `false` | Whether to run when the repository is a fork. By default the action skips (with a notice) so forks don't open release PRs of their own. | +| `prerelease` | _(unset)_ | One-shot prerelease identifier (`beta`, `rc`, or a bare number like `0`). From a stable version the normal bump is applied and the identifier attached at counter 0 (`4.5.2` → `5.0.0-beta.0`); from a prerelease with the same identifier the counter increments (`5.0.0-beta.0` → `5.0.0-beta.1`). When unset, a prerelease version graduates to its stable version (`5.0.0-beta.1` → `5.0.0`). | ### Creates a release (`danielroe/uppt/release`) diff --git a/pr/action.yml b/pr/action.yml index ca23c57..bdc826e 100644 --- a/pr/action.yml +++ b/pr/action.yml @@ -27,6 +27,10 @@ inputs: description: 'Newline-separated list of publishable workspace directories, relative to the repo root. Each line is a path or glob (e.g. `packages/*`); `!`-prefixed entries are excluded; workspaces with `"private": true` are skipped. Omit for single-package repos.' required: false default: '' + prerelease: + description: 'One-shot prerelease identifier (e.g. `beta`, `rc`, or a bare number like `0`). When set, this run cuts or continues a prerelease: from a stable version the normal bump is applied and the identifier attached at counter 0 (`4.5.2` → `5.0.0-beta.0`); from a prerelease with the same identifier the counter increments (`5.0.0-beta.0` → `5.0.0-beta.1`). Omit for normal releases; a prerelease version with this unset graduates to its stable version.' + required: false + default: '' allow-forks: description: 'Whether to run when the repository is a fork. Defaults to `false` so a fork of a repo using uppt does not open release PRs of its own.' required: false @@ -76,4 +80,5 @@ runs: GITHUB_TOKEN: ${{ inputs.token }} RELEASE_BASE: ${{ inputs.base-branch }} PACKAGES: ${{ inputs.packages }} + PRERELEASE: ${{ inputs.prerelease }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/update-changelog.ts diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index 92d4909..c3034d7 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -16,6 +16,9 @@ // PACKAGES newline-separated list of publishable workspace // paths/globs; when set, the release bumps every // resolved workspace's package.json in lockstep +// PRERELEASE one-shot prerelease identifier (e.g. "beta", "rc", +// "0"); when set, the release cuts or continues a +// prerelease instead of a stable version import process from 'node:process' import { execFileSync } from 'node:child_process' @@ -167,29 +170,65 @@ function determineBump (commits: Commit[]): 'major' | 'minor' | 'patch' { return 'patch' } -export function incVersion (version: string, bump: 'major' | 'minor' | 'patch'): string { - // uppt does not (yet) model prerelease or build-metadata releases. - // Silently rolling `1.2.3-rc.1` forward to `1.2.4` would lose the - // prerelease line, which is almost never what a maintainer wants. - // Refuse and let them reconcile. - const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/) +export function incVersion (version: string, bump: 'major' | 'minor' | 'patch', prerelease?: string): string { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9a-zA-Z.-]+))?$/) if (!match) { throw new Error( - `Cannot bump version "${version}": expected strict "X.Y.Z" semver. uppt does not currently support prerelease or build-metadata versions.`, + `Cannot bump version "${version}": expected strict "X.Y.Z" semver, optionally with a prerelease suffix. uppt does not support build-metadata versions.`, ) } - let [, x, y, z] = match.map(Number) as [number, number, number, number, number] + let [x, y, z] = match.slice(1, 4).map(Number) as [number, number, number] + const currentPre = match[4] - if (x === 0) { - if (bump === 'major') { bump = 'minor' } - else if (bump === 'minor') { bump = 'patch' } + const bumpBase = () => { + if (x === 0) { + if (bump === 'major') { bump = 'minor' } + else if (bump === 'minor') { bump = 'patch' } + } + if (bump === 'major') { x += 1; y = 0; z = 0 } + else if (bump === 'minor') { y += 1; z = 0 } + else { z += 1 } + } + + if (prerelease === undefined) { + // A prerelease already reserved its target version, so graduating it + // drops the suffix without a further bump: `feat:` on `5.0.0-0` must + // produce `5.0.0`, not `5.1.0`. + if (currentPre === undefined) bumpBase() + return `${x}.${y}.${z}` + } + + // The identifier flows into branch/ref names and, downstream, gh argv; + // pin it to a safe alphabet to rule out flag injection and ref confusion. + if (!/^[a-z0-9][a-z0-9.-]*$/.test(prerelease)) { + throw new Error( + `Invalid prerelease identifier "${prerelease}": expected lowercase alphanumerics, "." or "-" after the first character (e.g. "beta", "rc", "0").`, + ) } - if (bump === 'major') { x += 1; y = 0; z = 0 } - else if (bump === 'minor') { y += 1; z = 0 } - else { z += 1 } - return `${x}.${y}.${z}` + const isBareNumber = /^\d+$/.test(prerelease) + + if (currentPre === undefined) { + bumpBase() + return isBareNumber ? `${x}.${y}.${z}-0` : `${x}.${y}.${z}-${prerelease}.0` + } + + // A bare-number identifier is a style selector for the `-N` form, not a + // counter seed: new lines always start at 0. + if (isBareNumber) { + return /^\d+$/.test(currentPre) + ? `${x}.${y}.${z}-${Number(currentPre) + 1}` + : `${x}.${y}.${z}-0` + } + + const dot = currentPre.lastIndexOf('.') + const head = dot === -1 ? currentPre : currentPre.slice(0, dot) + const tail = dot === -1 ? '' : currentPre.slice(dot + 1) + if (head === prerelease && /^\d+$/.test(tail)) { + return `${x}.${y}.${z}-${prerelease}.${Number(tail) + 1}` + } + return `${x}.${y}.${z}-${prerelease}.0` } function formatChangelog ( @@ -521,7 +560,8 @@ async function main () { const currentVersion = resolveCurrentVersion(process.cwd(), packagesInput) const bump = determineBump(commits) - const newVersion = incVersion(currentVersion, bump) + const prerelease = process.env.PRERELEASE?.trim() || undefined + const newVersion = incVersion(currentVersion, bump, prerelease) const releaseBranch = `release/v${newVersion}` const changelog = formatChangelog(commits, { diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts index 8d014af..8aafab6 100644 --- a/test/update-changelog.test.ts +++ b/test/update-changelog.test.ts @@ -56,8 +56,48 @@ describe('incVersion', () => { expect(incVersion('0.0.3', 'patch')).toBe('0.0.4') }) - it('throws on a prerelease version', () => { - expect(() => incVersion('1.2.3-rc.1', 'patch')).toThrowError(/strict "X\.Y\.Z" semver/) + it('graduates a bare-number prerelease regardless of bump level', () => { + expect(incVersion('5.0.0-0', 'patch')).toBe('5.0.0') + expect(incVersion('5.0.0-0', 'minor')).toBe('5.0.0') + expect(incVersion('5.0.0-0', 'major')).toBe('5.0.0') + }) + + it('graduates a dotted-identifier prerelease', () => { + expect(incVersion('5.0.0-beta.3', 'patch')).toBe('5.0.0') + expect(incVersion('1.2.3-rc.1', 'major')).toBe('1.2.3') + }) + + it('cuts a new prerelease line from a stable version', () => { + expect(incVersion('4.5.2', 'major', 'beta')).toBe('5.0.0-beta.0') + expect(incVersion('4.5.2', 'minor', 'rc')).toBe('4.6.0-rc.0') + }) + + it('increments the counter when the identifier matches', () => { + expect(incVersion('5.0.0-beta.0', 'major', 'beta')).toBe('5.0.0-beta.1') + expect(incVersion('5.0.0-beta.3', 'patch', 'beta')).toBe('5.0.0-beta.4') + }) + + it('resets the counter when the identifier changes', () => { + expect(incVersion('5.0.0-beta.3', 'major', 'rc')).toBe('5.0.0-rc.0') + expect(incVersion('5.0.0-0', 'patch', 'beta')).toBe('5.0.0-beta.0') + }) + + it('supports bare-number prereleases', () => { + expect(incVersion('4.5.2', 'major', '0')).toBe('5.0.0-0') + expect(incVersion('5.0.0-0', 'major', '0')).toBe('5.0.0-1') + expect(incVersion('5.0.0-beta.3', 'patch', '0')).toBe('5.0.0-0') + }) + + it('treats a bare-number identifier as a style selector, starting new lines at 0', () => { + expect(incVersion('4.5.2', 'major', '3')).toBe('5.0.0-0') + expect(incVersion('5.0.0-beta.3', 'patch', '7')).toBe('5.0.0-0') + }) + + it('rejects unsafe prerelease identifiers', () => { + expect(() => incVersion('4.5.2', 'patch', '--upload-pack=x')).toThrowError(/Invalid prerelease identifier/) + expect(() => incVersion('4.5.2', 'patch', 'beta bang')).toThrowError(/Invalid prerelease identifier/) + expect(() => incVersion('4.5.2', 'patch', 'BETA')).toThrowError(/Invalid prerelease identifier/) + expect(() => incVersion('4.5.2', 'patch', '')).toThrowError(/Invalid prerelease identifier/) }) it('throws on a version with build metadata', () => {