// release.ts — the PURE half of `bun run release` (KEY: AUTOPILOT-133): boundary // selection, commit grouping, changelog insertion, version formatting, and the // precondition / publish-gate decisions that make publishing unreachable without // an operator paragraph and an explicit confirmation. // // WHY this module exists at all: 0.9.1 was cut by hand (fe66d23) and the 0.9.2 // prep had to be done by hand too. The parts of a release that are easy to get // silently wrong — which commit is the boundary, which commits are release // noise, where the new section goes — are pure functions here, so // test/release.test.ts can pin them with fixtures without touching git, npm or // the filesystem. scripts/release.ts owns every side effect (git, npm, $EDITOR, // the draft file outside the tree) and CALLS these; it re-implements none of them. // // ONE boundary rule, never a guess: the last release is the NEWEST `v*` tag when // one exists, else the explicit `--since `. If neither is available the // caller must refuse — `selectBoundary` returns null rather than inventing a // range. // // ONE noise rule: the commits a release is MADE of describe the release // machinery, not the release. `ship (post-approval merge-finisher...)` // and `docs(release): ...` are the release commits this repo produces (0.9.1's // publish commit is `fe66d23 docs(release): amend [0.9.1] ...`), `chore(release)` // is the version/changelog bump, and a pure revert removes a change that never // shipped. All are dropped from the draft; everything else is kept, including // commits with no item key (dropping those would hide changes). // --------------------------------------------------------------------------- // Versions // --------------------------------------------------------------------------- export type VersionParts = { major: number; minor: number; patch: number }; const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; /** Parse an exact `x.y.z` version, or null. No `v` prefix, no ranges — one * spelling, because the target version is compared against npm and a tag. */ export function parseVersion(version: string): VersionParts | null { const m = SEMVER_RE.exec(version.trim()); if (!m) return null; return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }; } export function formatVersion(parts: VersionParts): string { return `${parts.major}.${parts.minor}.${parts.patch}`; } /** Numeric x.y.z ordering (negative when a < b). Unparseable inputs compare as * equal so a stray tag can never silently win the "newest" sort. */ export function compareVersions(a: string, b: string): number { const pa = parseVersion(a); const pb = parseVersion(b); if (!pa || !pb) return 0; return pa.major - pb.major || pa.minor - pb.minor || pa.patch - pb.patch; } export const VERSION_BUMPS = ["major", "minor", "patch"] as const; export type VersionBump = (typeof VERSION_BUMPS)[number]; export function isVersionBump(spec: string): spec is VersionBump { return (VERSION_BUMPS as readonly string[]).includes(spec); } /** * The version `npm version --no-git-tag-version` will write, computed * BEFORE npm runs so the preconditions can test npm and the tag list against the * exact target. `spec` is `major|minor|patch` or an explicit `x.y.z`; anything * else throws (the caller refuses loud rather than bumping something unexpected). */ export function resolveTargetVersion(current: string, spec: string): string { const cur = parseVersion(current); if (!cur) throw new Error(`current version "${current}" is not x.y.z`); const wanted = spec.trim(); if (isVersionBump(wanted)) { if (wanted === "major") return formatVersion({ major: cur.major + 1, minor: 0, patch: 0 }); if (wanted === "minor") return formatVersion({ major: cur.major, minor: cur.minor + 1, patch: 0 }); return formatVersion({ major: cur.major, minor: cur.minor, patch: cur.patch + 1 }); } const explicit = parseVersion(wanted); if (!explicit) throw new Error(`version spec "${spec}" is not major|minor|patch or x.y.z`); return formatVersion(explicit); } // --------------------------------------------------------------------------- // Boundary // --------------------------------------------------------------------------- export type Boundary = { kind: "tag" | "since"; ref: string }; /** The newest `v` tag, or null when none exists. Non-semver `v*` tags * are ignored (a stray `v-nightly` must not become a boundary). */ export function newestReleaseTag(tags: string[]): string | null { const versions = tags .map((tag) => tag.trim()) .filter((tag) => tag.startsWith("v") && parseVersion(tag.slice(1)) !== null); if (versions.length === 0) return null; versions.sort((a, b) => compareVersions(b.slice(1), a.slice(1))); return versions[0] ?? null; } /** * The boundary of the unreleased range: the newest `v*` tag when one exists, * else `--since `. null when neither is available — the caller must REFUSE * and say so; a release boundary is never guessed. */ export function selectBoundary(input: { tags: string[]; since?: string }): Boundary | null { const tag = newestReleaseTag(input.tags); if (tag) return { kind: "tag", ref: tag }; const since = input.since?.trim(); if (since) return { kind: "since", ref: since }; return null; } // --------------------------------------------------------------------------- // Commits: parse, classify, group // --------------------------------------------------------------------------- export type ReleaseCommit = { sha: string; subject: string; item: string | null }; /** The item key in a commit subject. The generic item-key form is * `PREFIX-` (`ITEM-133`); this repo's keys are `AUTOPILOT-`, so * the prefix is any uppercase alphanumeric run — one pattern for both. */ export const ITEM_KEY_RE = /\b([A-Z][A-Z0-9]*-\d+)\b/; export function itemKeyOf(subject: string): string | null { const m = ITEM_KEY_RE.exec(subject); return m ? m[1] : null; } export function shortSha(sha: string): string { return sha.slice(0, 7); } /** Parse `git log --format='%H%x09%s'` output. Duplicate shas are dropped (the * same commit listed twice is one commit); malformed lines are skipped, never * guessed at. */ export function parseCommitLog(raw: string): ReleaseCommit[] { const commits: ReleaseCommit[] = []; const seen = new Set(); for (const line of raw.split("\n")) { if (!line.trim()) continue; const tab = line.indexOf("\t"); if (tab === -1) continue; const sha = line.slice(0, tab).trim(); const subject = line.slice(tab + 1).trim(); if (!sha || seen.has(sha)) continue; seen.add(sha); commits.push({ sha, subject, item: itemKeyOf(subject) }); } return commits; } /** Release machinery, not release content (see the header). */ export const RELEASE_NOISE_PATTERNS: readonly RegExp[] = [ /^ship\b.*\(post-approval merge-finisher/i, /^docs\(release\)/i, /^chore\(release\)/i, /^revert\b/i, ]; export function isReleaseNoise(subject: string): boolean { return RELEASE_NOISE_PATTERNS.some((re) => re.test(subject)); } export function dropReleaseNoise(commits: ReleaseCommit[]): ReleaseCommit[] { return commits.filter((c) => !isReleaseNoise(c.subject)); } export type CommitGroup = { key: string; commits: ReleaseCommit[] }; /** Group by item key, preserving first-appearance order (git log is newest * first, so the draft reads newest batch first). Commits with no item key land * in the `""` group and are rendered as `(no ITEM key)` — never dropped. */ export function groupCommits(commits: ReleaseCommit[]): CommitGroup[] { const groups = new Map(); for (const commit of commits) { const key = commit.item ?? ""; const bucket = groups.get(key); if (bucket) bucket.push(commit); else groups.set(key, [commit]); } return [...groups.entries()].map(([key, bucket]) => ({ key, commits: bucket })); } // --------------------------------------------------------------------------- // Draft + changelog // --------------------------------------------------------------------------- /** The marker that must be gone before a release can proceed. The literal * string is the contract: `operatorParagraphPresent` and the draft template * share this constant. */ export const DRAFT_TODO_MARKER = ""; export function formatSectionHeading(version: string, since: string): string { return `## [${version}] - since ${since}`; } export function operatorParagraphPresent(draft: string): boolean { return !draft.includes(DRAFT_TODO_MARKER); } export function renderCommitLine(commit: ReleaseCommit): string { return `- \`${shortSha(commit.sha)}\` ${commit.subject}`; } /** * The draft changelog section: heading, the TODO marker (plus a short instruction * block so the marker is not removed without writing the paragraph), then the * commits grouped by item key. `since` is the PREVIOUS released version — the * version package.json carried before the bump — because the file's convention * is `## [x.y.z] - since ` (see `## [0.9.1] - since 0.9.0`). */ export function renderDraft(input: { version: string; since: string; groups: CommitGroup[] }): string { const lines: string[] = [ formatSectionHeading(input.version, input.since), "", DRAFT_TODO_MARKER, "", ]; for (const group of input.groups) { lines.push("", `### ${group.key || "(no ITEM key)"}`, ""); for (const commit of group.commits) lines.push(renderCommitLine(commit)); } return `${lines.join("\n")}\n`; } function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * The operator paragraph of the newest (first) existing section — the text under * its heading, up to the first `###`/`## ` sub-heading. This is what a new * operator paragraph is modelled on (for the 0.9.2 prep it is the `## [0.9.1]` * paragraph). Empty string when the file has no section. */ export function newestSectionParagraph(changelog: string): string { const heading = /^## \[[^\n]*\n/m.exec(changelog); if (!heading) return ""; const rest = changelog.slice(heading.index + heading[0].length); const stop = /^(?:###|## )/m.exec(rest); return (stop ? rest.slice(0, stop.index) : rest).trim(); } /** Whether CHANGELOG.md already carries a `## []` section heading. */ export function changelogHasSection(changelog: string, version: string): boolean { return new RegExp(`^## \\[${escapeRegExp(version)}\\]`, "m").test(changelog); } /** Index of the first `## [` heading, or -1. */ function firstSectionIndex(changelog: string): number { const m = /^## \[/m.exec(changelog); return m ? m.index : -1; } /** * Insert `section` ABOVE the first existing changelog section and preserve the * rest of the file byte-for-byte: the text from the first `## [` heading onward is * returned untouched, and the preamble is returned untouched too. Only the blank * line joining the preamble to the new section is normalised when the file did not * already end the preamble with one. Appends after the preamble when the file has * no section yet. */ export function insertChangelogSection(changelog: string, section: string): string { const trimmed = section.trim(); const idx = firstSectionIndex(changelog); if (idx === -1) return `${changelog.replace(/\s*$/, "")}\n\n${trimmed}\n`; const before = changelog.slice(0, idx); const after = changelog.slice(idx); const sep = before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n"; return `${before}${sep}${trimmed}\n\n${after}`; } // --------------------------------------------------------------------------- // Preconditions + publish gate // --------------------------------------------------------------------------- export type PreconditionCode = | "branch" | "dirty-tree" | "npm-auth" | "version-exists" | "tag-exists" | "changelog-exists"; export type PreconditionFailure = { code: PreconditionCode; message: string }; export type PreconditionInput = { /** `git rev-parse --abbrev-ref HEAD`. */ branch: string; /** The branch a release is cut from (default `main`). */ releaseBranch: string; /** `git status --porcelain` output. */ porcelain: string; /** `npm whoami` output, or null when it failed. */ npmUser: string | null; /** The target version (see resolveTargetVersion). */ version: string; /** `npm view @ version` output when it returns one, else null. */ publishedVersion: string | null; /** Whether `refs/tags/v` already exists. */ tagExists: boolean; /** Whether CHANGELOG.md already has a `## []` section. */ changelogHasVersion: boolean; }; /** * Every reason the release must NOT start, evaluated from probes the caller * gathered WITHOUT changing anything. An empty array means "cleared to proceed"; * a non-empty one is printed before anything is written or bumped. */ export function checkPreconditions(input: PreconditionInput): PreconditionFailure[] { const failures: PreconditionFailure[] = []; if (input.branch !== input.releaseBranch) { failures.push({ code: "branch", message: `HEAD is on "${input.branch}", not the release branch "${input.releaseBranch}" — cut the release from ${input.releaseBranch}`, }); } if (input.porcelain.trim() !== "") { failures.push({ code: "dirty-tree", message: "the working tree is not clean (`git status --porcelain` is non-empty) — a release starts from a clean tree", }); } if (input.npmUser === null) { failures.push({ code: "npm-auth", message: "`npm whoami` failed — no usable npm session (run `npm login`)", }); } if (input.publishedVersion !== null) { failures.push({ code: "version-exists", message: `${input.version} is already published on npm (npm view returned "${input.publishedVersion}")`, }); } if (input.tagExists) { failures.push({ code: "tag-exists", message: `tag v${input.version} already exists` }); } if (input.changelogHasVersion) { failures.push({ code: "changelog-exists", message: `CHANGELOG.md already has a "## [${input.version}]" section`, }); } return failures; } export type PublishGateFailure = { code: "paragraph" | "confirmation"; message: string }; /** * The LAST line of defence before `npm publish`: both the operator paragraph * (the TODO marker is gone) and an explicit confirmation must be present. The * script calls this immediately before publishing, so there is no path to the * publish step that skips either — `--yes` skips the PROMPT, never this gate. */ export function publishGate(input: { draft: string; confirmed: boolean }): PublishGateFailure[] { const failures: PublishGateFailure[] = []; if (!operatorParagraphPresent(input.draft)) { failures.push({ code: "paragraph", message: `the draft still contains "${DRAFT_TODO_MARKER}" — write the operator paragraph before publishing`, }); } if (!input.confirmed) { failures.push({ code: "confirmation", message: "no explicit confirmation — refusing to publish without one", }); } return failures; }