scripts/release-version.mjs

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";

function parts(version) {
  const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
  assert.ok(match, `Invalid stable version: ${version}`);
  return match.slice(1).map(Number);
}

function highestReleaseVersion(current, tags) {
  let highest = parts(current);
  assert.ok(highest.every(Number.isSafeInteger), "Current version is out of range");
  for (const tag of tags) {
    const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag);
    if (!match) continue;
    const candidate = match.slice(1).map(Number);
    if (candidate.some((value) => !Number.isSafeInteger(value))) continue;
    if (candidate[0] > highest[0] || (candidate[0] === highest[0] && candidate[1] > highest[1]) ||
      (candidate[0] === highest[0] && candidate[1] === highest[1] && candidate[2] > highest[2]))
      highest = candidate;
  }
  return highest;
}

export function nextReleaseVersion(current, tags) {
  const highest = highestReleaseVersion(current, tags);
  assert.ok(Number.isSafeInteger(highest[2] + 1), "Next patch version is out of range");
  return `${highest[0]}.${highest[1]}.${highest[2] + 1}`;
}

export function releaseVersion(current, tags, sourceTags = []) {
  const existing = highestReleaseVersion(current, sourceTags).join(".");
  return existing !== current ? existing : nextReleaseVersion(current, tags);
}

export function releaseTags() {
  const list = (...args) => execFileSync("git", ["tag", "--list", "v[0-9]*", ...args], { encoding: "utf8" }).trim().split(/\s+/).filter(Boolean);
  return { tags: list(), sourceTags: list("--points-at", "HEAD") };
}

if (import.meta.main) {
  const { version } = JSON.parse(readFileSync("package.json", "utf8"));
  const { tags, sourceTags } = releaseTags();
  console.log(releaseVersion(version, tags, sourceTags));
}