From 7b066e142448f8b08cfb81eb7470551afa15b68f Mon Sep 17 00:00:00 2001 From: Jermiah Joseph Date: Wed, 11 Nov 2026 10:21:55 -0500 Subject: [PATCH] fix(release): take the previous version from tags that exist release.ts read the previous version from the CHANGELOG [Unreleased] compare link, and release-notes.ts from the next heading down. A release that failed after its commit reached main (0.65.0 CI, 0.65.1 Publish) left a heading and link for a tag that never existed. The next release then failed on git log v0.65.0..HEAD, and Publish failed with "tag v0.65.0 is not in this clone". Recovery needed a hand-edited link (#653) and an extra patch release (0.65.2). release.ts now uses the linked version only when its tag exists, else the newest v* tag below the new version. release-notes.ts walks the headings below the section to the first with a tag. Rejected: a CHANGELOG-only fix, which leaves the next failed release to break the same way. Validation: release-notes.ts 0.65.1 now gives "Full diff v0.64.0...v0.65.1" (it exited 1 before). release.ts --dry-run with the link set to nonexistent v0.65.9 resolves the previous version to 0.65.2. bun run typecheck passes. Closes #657 --- .github/scripts/release-notes.ts | 8 ++++++-- .github/scripts/release.ts | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release-notes.ts b/.github/scripts/release-notes.ts index d7c337e8..9c585848 100644 --- a/.github/scripts/release-notes.ts +++ b/.github/scripts/release-notes.ts @@ -55,8 +55,12 @@ if (!repoUrl) { console.error('CHANGELOG.md needs a "[Unreleased]: /compare/..." link ref at the bottom') process.exit(1) } -// The previous release is the next version heading down (the changelog is newest first). -const previous = /^## \[([^\]]+)\]/.exec(lines[end] ?? "")?.[1] +// The previous release is the next version heading down that has a tag (the changelog is newest first). +// A heading for a version that never shipped (its CI or Publish failed) has no tag, so it is skipped. +const previous = lines + .slice(end) + .map((l) => /^## \[([^\]]+)\]/.exec(l)?.[1]) + .find((v): v is string => v !== undefined && v !== "Unreleased" && refExists(`v${v}`)) // Before the tag exists (a dry run from main) the commits are the ones on HEAD. const tag = `v${version}` diff --git a/.github/scripts/release.ts b/.github/scripts/release.ts index c2301c3c..e0673267 100644 --- a/.github/scripts/release.ts +++ b/.github/scripts/release.ts @@ -37,7 +37,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os" import { join } from "node:path" import semver from "semver" -import { changelogBlock, collectCommits } from "./commit-detail.ts" +import { changelogBlock, collectCommits, refExists } from "./commit-detail.ts" import { approvalState, judgeChecks, mergeRules, type CheckRow, type GhReview } from "./merge-gate.ts" import { bumpInstallPins, replaceSinceNext } from "./since-next.ts" @@ -516,7 +516,17 @@ const docRewrites = ["README.md", ...[...new Bun.Glob("docs/**/*.md").scanSync(" const linkAt = lines.findIndex((l) => l.startsWith("[Unreleased]: ")) const link = /^\[Unreleased\]: (.+)\/compare\/v(.+)\.\.\.HEAD$/.exec(lines[linkAt] ?? "") if (!link) fail('CHANGELOG.md needs a "[Unreleased]: /compare/v...HEAD" link ref at the bottom') -const [, repoUrl, previous] = link +const [, repoUrl, linkedPrevious] = link +// The link names the last version the changelog saw, but an unpublished version (a release whose CI or +// Publish failed) leaves a heading and link for a tag that does not exist, and the next release then fails +// on `git log v..HEAD`. So the previous version is the newest tag that exists below this release. +const previous = refExists(`v${linkedPrevious}`) + ? linkedPrevious + : git("tag", "--list", "v*", "--sort=-v:refname") + .split("\n") + .map((t) => t.slice(1)) + .find((v) => semver.valid(v) && semver.lt(v, version)) +if (!previous) fail(`no release tag below ${version} exists, and CHANGELOG.md links v${linkedPrevious}, which is not a tag`) if (!semver.gt(version, previous)) fail(`${version} does not increase on the last released version ${previous}`) // The commit-level detail: every commit since the previous tag, grouped by type, in a