const NUM = '(0|[1-9]\\d*)';
const STABLE_BASE_PATTERN = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}$`);
const RC_BASE_PATTERN = /^8\.0\.0-rc\.([1-9]\d*)$/;
export interface ParsedVersion {
major: number;
minor: number;
patch: number;
}
* Parses a semver-shaped version string into its numeric components.
* Tolerant of pre-release suffixes (`0.7.0-foo` parses the same as
* `0.7.0`); strict on the leading `major.minor.patch` shape — anything
* else returns NaN-bearing components.
*/
export function parseVersion(version: string): ParsedVersion {
const [major, minor, patch] = version.split('-')[0].split('.').map(Number);
return { major, minor, patch };
}
* Given the current version, computes the next minor's zero-patch
* form: `0.7.0` -> `0.8.0`, `1.2.5` -> `1.3.0`. Pure / deterministic.
* Pre-release suffixes on the input are ignored (`0.7.0-foo` -> `0.8.0`).
*/
export function computeNextMinor(current: string): string {
const { major, minor } = parseVersion(current);
return `${major}.${minor + 1}.0`;
}
* Computes the next release version from the current base
* (see docs/oss/versioning.md):
*
* - `8.0.0-rc.N` -> `8.0.0-rc.N+1` — the RC line advances its counter.
* - pre-8 stable (`0.17.0`) -> `8.0.0-rc.1` — the one-time transition
* onto the v8 RC line; there are no further `0.x` minors.
* - stable `>= 8` -> next minor.
*/
export function computeNextReleaseVersion(current: string): string {
assertCanonicalBase(current);
const rcMatch = current.match(RC_BASE_PATTERN);
if (rcMatch) {
return `8.0.0-rc.${Number(rcMatch[1]) + 1}`;
}
if (parseVersion(current).major < 8) {
return '8.0.0-rc.1';
}
return computeNextMinor(current);
}
export interface VersionResult {
version: string;
tag: string;
devVersion?: string;
}
export type PreviousVersionLookup =
| { available: true; version: string | undefined }
| { available: false };
* The publish plan for a push to `main`. A changed root version means
* the push is a release bump: publish `<base>` under `latest`, plus a
* `<base>-dev.N` follow-up so the `dev` dist-tag never falls behind
* `latest`. An unchanged (or unreadable — a transient git error must
* never silently promote to `latest`) previous version means the usual
* dev-only publish. Adopted from prisma/composer#241.
*/
export function planPushPublish(
base: string,
previous: PreviousVersionLookup,
latestDevVersion: string | undefined,
): VersionResult {
const isReleaseBump = previous.available && previous.version !== base;
if (isReleaseBump) {
return {
version: base,
tag: 'latest',
devVersion: composeDevVersion(base, latestDevVersion).version,
};
}
return composeDevVersion(base, latestDevVersion);
}
* The publish plan for a `workflow_dispatch`. A real (non-dry-run)
* `latest` dispatch is the recovery path for a failed release publish,
* so it carries the same `<base>-dev.N` follow-up as a release push —
* recovering the release must also recover the `dev` dist-tag.
* Dry runs and non-`latest` dispatches publish only the requested tag.
*/
export function planDispatchPublish(
base: string,
tag: string,
isDryRun: boolean,
latestDevVersion: string | undefined,
): VersionResult {
if (tag === 'latest' && !isDryRun) {
return { version: base, tag, devVersion: composeDevVersion(base, latestDevVersion).version };
}
return { version: base, tag };
}
* Composes the `<base>-dev.N` version for a routine (non-release) push,
* given the version currently published under the `dev` dist-tag. The
* counter continues while the base is unchanged and resets to 1 when
* the base moves (new minor, new rc counter, stable-to-rc transition).
*/
export function composeDevVersion(
baseVersion: string,
latestDevVersion: string | undefined,
): VersionResult {
let buildNumber = 1;
if (latestDevVersion) {
const devPattern = /^(\d+\.\d+\.\d+(?:-rc\.\d+)?)-dev\.(\d+)$/;
const match = latestDevVersion.match(devPattern);
if (match) {
const [, devBase, build] = match;
if (devBase === baseVersion) {
buildNumber = Number.parseInt(build, 10) + 1;
}
}
}
return {
version: `${baseVersion}-dev.${buildNumber}`,
tag: 'dev',
};
}
* Asserts that a base version is canonical: either a clean release
* (`major.minor.patch`) or a version on the supported RC line
* (`8.0.0-rc.N`, N ≥ 1). Used to fail-fast in the publish workflow if
* root `package.json` was edited to something other than a release
* shape — without this guard, a malformed root would compose nonsense
* publish versions like `0.7.0-foo-dev.1`.
*/
export function assertCanonicalBase(base: string): void {
if (!STABLE_BASE_PATTERN.test(base) && !RC_BASE_PATTERN.test(base)) {
throw new Error(
`Base version "${base}" is not canonical. ` +
'The root package.json `version` must be a clean release shape ("0.7.0") ' +
'or on the supported RC line ("8.0.0-rc.N", N >= 1); nothing else is permitted on `main`.',
);
}
}