From 00ffbe0e5721d435c38863567530aaef30b2bf1b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 2 Oct 2026 11:48:46 +0000 Subject: [PATCH] feat: respect hand-edited release pr versions resolves https://github.com/danielroe/uppt/issues/74 --- README.md | 2 + scripts/update-changelog.ts | 110 ++++++++++++++++++++++++++--- test/update-changelog-main.test.ts | 59 +++++++++++++++- test/update-changelog.test.ts | 36 +++++++++- 4 files changed, 194 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2b44615..3fd86e3 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ jobs: > [!TIP] > You can edit the release PR to add your own release notes. Anything above `## 👉 Changelog` is preserved when the changelog is updated. > +> To release a different version than the one uppt picked, just change `version` in a `package.json` on the release branch. The next run will update the PR title and changelog to match (and, in a lockstep monorepo, the other manifests), and keep that version on later pushes. The branch keeps its original name. +> > If base changes a manifest the release PR also bumps, the next run rebases the PR onto it, keeping its version. A release branch carrying other changes is left for you to update. ### Inputs diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index 757463f..bca6231 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -34,7 +34,7 @@ import { resolve } from 'node:path' import { runMain } from './_cli.ts' import { makePkgFormatter } from './pkg-format.ts' -import { buildScopeMap, parseScopesInput, resolveCurrentVersion, resolveWorkspaces, type Workspace } from './_workspaces.ts' +import { buildScopeMap, isPrerelease, parseScopesInput, resolveCurrentVersion, resolveWorkspaces, type Workspace } from './_workspaces.ts' import { buildDependencyGraph, DEPENDENCY_FIELDS, propagateReleases, type BumpLevel } from './_dependency-graph.ts' export interface Commit { @@ -1036,6 +1036,80 @@ export function stripPlaceholderTimetable (body: string): string { .replaceAll(`\n${TIMETABLE_PLACEHOLDER}`, '') } +function bumpBetween (from: string, to: string): BumpLevel { + const core = (v: string) => v.split(/[-+]/)[0]!.split('.').map(Number) + const [a, b] = [core(from), core(to)] + if (a[0] !== b[0]) return 'major' + if (a[1] !== b[1]) return 'minor' + return 'patch' +} + +/** Semver precedence of the prerelease suffixes of two versions sharing a core. */ +function comparePrerelease (a: string, b: string): number { + const ids = (v: string) => v.split('-').slice(1).join('-').split('.') + const [pa, pb] = [ids(a), ids(b)] + for (let i = 0; i < Math.min(pa.length, pb.length); i++) { + const [x, y] = [pa[i]!, pb[i]!] + if (x === y) continue + const [nx, ny] = [/^\d+$/.test(x), /^\d+$/.test(y)] + if (nx && ny) return Number(x) - Number(y) + if (nx !== ny) return nx ? -1 : 1 + return x < y ? -1 : 1 + } + return pa.length - pb.length +} + +/** + * Hand-set version of a lockstep release PR: a branch manifest version that + * differs from the version in the branch name. `null` if none, or if unusable. + */ +export function resolvePinnedVersion (opts: { + headRef: string + branchVersions: Array + currentVersion: string + prerelease: boolean +}): string | null { + const nameVersion = opts.headRef.slice('release/v'.length) + const edited = [...new Set(opts.branchVersions.filter((v): v is string => typeof v === 'string' && v !== nameVersion))] + if (edited.length > 1) { + console.warn(`Ignoring hand-edited versions on ${opts.headRef}: manifests disagree (${edited.join(', ')}).`) + return null + } + const pinned = edited[0] + if (!pinned) return null + if (!/^\d+\.\d+\.\d+(?:-[0-9a-zA-Z.-]+)?$/.test(pinned)) { + console.warn(`Ignoring hand-edited version "${pinned}" on ${opts.headRef}: expected strict "X.Y.Z" semver.`) + return null + } + if ((compareVersions(pinned, opts.currentVersion) || comparePrerelease(pinned, opts.currentVersion)) <= 0) { + console.warn(`Ignoring hand-edited version "${pinned}" on ${opts.headRef}: not above the current version ${opts.currentVersion}.`) + return null + } + if (isPrerelease(pinned) !== opts.prerelease) { + console.warn(`Ignoring hand-edited version "${pinned}" on ${opts.headRef}: this run cuts a ${opts.prerelease ? 'prerelease' : 'stable release'}.`) + return null + } + return pinned +} + +async function findTrackReleasePR ( + repo: { owner: string, repo: string }, + opts: { baseBranch: string, prerelease: boolean }, +): Promise<{ number: number, head: string } | undefined> { + const prs = await gh>( + `/repos/${repo.owner}/${repo.repo}/pulls?state=open&per_page=100&base=${encodeURIComponent(opts.baseBranch)}&head=${repo.owner}:`, + ) + const pr = prs + .filter(pr => + pr.head.repo?.full_name === `${repo.owner}/${repo.repo}` + && pr.base.ref === opts.baseBranch + && pr.head.ref.startsWith('release/v') + && pr.head.ref.slice('release/v'.length).includes('-') === opts.prerelease, + ) + .sort((a, b) => b.updated_at.localeCompare(a.updated_at))[0] + return pr && { number: pr.number, head: pr.head.ref } +} + function readVersion (source: string | null): string | undefined { try { const version = (JSON.parse(source!) as { version?: unknown }).version @@ -1173,10 +1247,29 @@ export async function main () { const currentVersion = resolveCurrentVersion(process.cwd(), packagesInput) - const bump = determineBump(commits) const prerelease = process.env.PRERELEASE?.trim() || undefined - const newVersion = incVersion(currentVersion, bump, prerelease) - const releaseBranch = `release/v${newVersion}` + const computedVersion = incVersion(currentVersion, determineBump(commits), prerelease) + + const bumpPaths = buildBumpFileSet({ monorepo, workspaces, rootPkg, rootPkgSource, currentVersion, newVersion: computedVersion }) + .map(file => file.path) + + let pinned: { version: string, pr: { number: number, head: string } } | null = null + const trackPR = await findTrackReleasePR(repo, { baseBranch, prerelease: Boolean(prerelease) }) + if (trackPR) { + const branchVersions: Array = [] + for (const path of bumpPaths) branchVersions.push(readVersion(await getFileContent(repo, path, trackPR.head))) + const version = resolvePinnedVersion({ + headRef: trackPR.head, + branchVersions, + currentVersion, + prerelease: Boolean(prerelease), + }) + if (version) pinned = { version, pr: trackPR } + } + + const newVersion = pinned?.version ?? computedVersion + const bump = pinned ? bumpBetween(currentVersion, newVersion) : determineBump(commits) + const releaseBranch = pinned?.pr.head ?? `release/v${newVersion}` const changelog = formatChangelog(commits, { owner: repo.owner, @@ -1185,7 +1278,7 @@ export async function main () { toRef: `v${newVersion}`, }) - console.log(`Current: ${currentVersion} -> ${newVersion} (${bump})`) + console.log(`Current: ${currentVersion} -> ${newVersion} (${bump}${pinned ? `, pinned on #${pinned.pr.number}` : ''})`) if (monorepo) { console.log(`Workspaces (${workspaces.length}): ${workspaces.map(ws => ws.name).join(', ')}`) } @@ -1237,9 +1330,10 @@ export async function main () { const newContributors = contributors.filter(c => c.isFirstTime) const currentPR = await findOpenPR(repo, releaseBranch) - const preamble = extractPreamble(currentPR?.body) - || seedPreamble - || `> v${newVersion} is the next ${bump} release.\n>\n${TIMETABLE_PLACEHOLDER}` + const intro = `> v${newVersion} is the next ${bump} release.` + const preamble = (extractPreamble(currentPR?.body) || seedPreamble) + ?.replace(/^> v\S+ is the next (?:major|minor|patch) release\.$/m, intro) + || `${intro}\n>\n${TIMETABLE_PLACEHOLDER}` const body = [ preamble, diff --git a/test/update-changelog-main.test.ts b/test/update-changelog-main.test.ts index d78e2e2..51fd736 100644 --- a/test/update-changelog-main.test.ts +++ b/test/update-changelog-main.test.ts @@ -619,17 +619,70 @@ describe('lockstep main', () => { }) }) -describe('lockstep main with a conflicting release branch', () => { +describe('lockstep main with a hand-edited version', () => { + function openReleasePR (ref: string, body = '> v1.3.0 is the next minor release.\n\n## 👉 Changelog\n\nstuff') { + api.openPRs = [{ number: 5, body, head: { ref, repo: { full_name: 'owner/repo' } }, base: { ref: 'main' }, updated_at: '2024-01-02T00:00:00Z' }] + } + const prUpdate = () => calls.findLast(c => c.method === 'PATCH' && c.path === '/repos/owner/repo/pulls/5')!.body as { title: string } + + it('retitles the PR to the edited version, keeping its branch', async () => { + openReleasePR('release/v1.3.0') + releaseBranch('release/v1.3.0', { 'package.json': pkgJson('2.0.0') }) + await main() + expect(calls.filter(c => c.method !== 'GET' && !c.path.endsWith('/pulls/5'))).toEqual([]) + expect(calls.find(c => (c.body as { state?: string } | undefined)?.state === 'closed')).toBeUndefined() + expect(prUpdate().title).toBe('v2.0.0') + expect(prBody()).toContain('> v2.0.0 is the next major release.') + expect(prBody()).toContain('compare/v1.2.3...v2.0.0') + }) + + it('follows a version edited down', async () => { + git.commits = [{ ...FEAT, subject: 'feat!: break a thing (#7)' }] + openReleasePR('release/v2.0.0', '> v2.0.0 is the next major release.\n\n## 👉 Changelog\n\nstuff') + releaseBranch('release/v2.0.0', { 'package.json': pkgJson('1.2.4') }) + await main() + expect(prUpdate().title).toBe('v1.2.4') + expect(prBody()).toContain('> v1.2.4 is the next patch release.') + }) + + it('brings the other lockstep manifests up to the edited version', async () => { + process.env.PACKAGES = 'packages/*' + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + writePackage('packages/b', { name: 'b', version: '1.2.3' }) + openReleasePR('release/v1.3.0') + releaseBranch('release/v1.3.0', { + 'packages/a/package.json': pkgJson('1.5.0', 'a'), + 'packages/b/package.json': pkgJson('1.3.0', 'b'), + }) + await main() + expect(blobContents().map(c => JSON.parse(c))).toEqual([{ name: 'a', version: '1.5.0' }, { name: 'b', version: '1.5.0' }, { name: 'pkg', version: '1.5.0' }]) + expect(prUpdate().title).toBe('v1.5.0') + }) + it.each(['{ not json', '{ "name": "pkg" }'])('rebuilds a branch whose manifest has no readable version: %s', async (manifest) => { + openReleasePR('release/v1.3.0') releaseBranch('release/v1.3.0', { 'package.json': manifest }) await main() expect(JSON.parse(blobContents()[0]!)).toMatchObject({ version: '1.3.0' }) }) - it('rebuilds on base, keeping the version', async () => { - releaseBranch('release/v1.3.0', { 'package.json': pkgJson('1.3.0') }, { behindBy: 2, baseChanged: ['package.json'] }) + it('resets an edited version that is not above the current one', async () => { + openReleasePR('release/v1.3.0') + releaseBranch('release/v1.3.0', { 'package.json': pkgJson('1.0.0') }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await main() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('not above the current version')) + expect(calls.find(c => c.path.endsWith('/rename'))).toBeUndefined() expect(JSON.parse(blobContents()[0]!)).toMatchObject({ version: '1.3.0' }) + }) +}) + +describe('lockstep main with a conflicting release branch', () => { + it('rebuilds on base, keeping the pinned version', async () => { + api.openPRs = [{ number: 5, body: null, head: { ref: 'release/v1.3.0', repo: { full_name: 'owner/repo' } }, base: { ref: 'main' }, updated_at: '' }] + releaseBranch('release/v1.3.0', { 'package.json': pkgJson('2.0.0') }, { behindBy: 2, baseChanged: ['package.json'] }) + await main() + expect(JSON.parse(blobContents()[0]!)).toMatchObject({ version: '2.0.0' }) expect(calls).toContainEqual(expect.objectContaining({ path: '/repos/owner/repo/git/commits', body: expect.objectContaining({ parents: ['base-sha'] }) })) expect(calls).toContainEqual(expect.objectContaining({ method: 'PATCH', path: '/repos/owner/repo/git/refs/heads/release/v1.3.0', body: { sha: 'commit-sha', force: true } })) }) diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts index 5874a31..a2eceb0 100644 --- a/test/update-changelog.test.ts +++ b/test/update-changelog.test.ts @@ -1,9 +1,9 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { resolve } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { stripPlaceholderTimetable, TIMETABLE_PLACEHOLDER, buildBumpFileSet, dropRevertedCommits, buildIndependentBody, determineBump, formatChangelog, buildIndependentBumpFileSet, computeIndependentPlan, extractPreamble, truncateBody, dropAlreadyReleased, incVersion, latestLockstepTag, latestTagForPackage, releaseBranchDrift, isSupersededReleaseBranch, type Commit } from '../scripts/update-changelog.ts' +import { resolvePinnedVersion, stripPlaceholderTimetable, TIMETABLE_PLACEHOLDER, buildBumpFileSet, dropRevertedCommits, buildIndependentBody, determineBump, formatChangelog, buildIndependentBumpFileSet, computeIndependentPlan, extractPreamble, truncateBody, dropAlreadyReleased, incVersion, latestLockstepTag, latestTagForPackage, releaseBranchDrift, isSupersededReleaseBranch, type Commit } from '../scripts/update-changelog.ts' import { resolveWorkspaces } from '../scripts/_workspaces.ts' let tmp: string @@ -1005,3 +1005,35 @@ describe('isSupersededReleaseBranch', () => { expect(isSupersededReleaseBranch('release/v5.0.0-beta.0', { ...opts, releaseBranch: 'release/v5.0.0-beta.1', prerelease: true })).toBe(true) }) }) + +describe('resolvePinnedVersion', () => { + const base = { headRef: 'release/v1.3.0', currentVersion: '1.2.3', prerelease: false } + + it('returns a version that differs from the branch name', () => { + expect(resolvePinnedVersion({ ...base, branchVersions: ['2.0.0'] })).toBe('2.0.0') + }) + + it.each(['1.3.0-beta.2', '1.3.0-beta.1.0', '1.3.0-beta.x', '1.3.0-rc.0'])('accepts %s after 1.3.0-beta.1', (version) => { + expect(resolvePinnedVersion({ headRef: 'release/v1.3.0-beta.9', currentVersion: '1.3.0-beta.1', prerelease: true, branchVersions: [version] })).toBe(version) + }) + + it.each(['1.3.0-beta.1', '1.3.0-beta', '1.3.0-alpha.5', '1.3.0-1'])('rejects %s after 1.3.0-beta.1', (version) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(resolvePinnedVersion({ headRef: 'release/v1.3.0-beta.9', currentVersion: '1.3.0-beta.1', prerelease: true, branchVersions: [version] })).toBeNull() + }) + + it('returns null when nothing was edited', () => { + expect(resolvePinnedVersion({ ...base, branchVersions: ['1.3.0', undefined] })).toBeNull() + }) + + it.each([ + [['2.0.0', '3.0.0'], false], + [['latest'], false], + [['1.2.3'], false], + [['2.0.0-beta.0'], false], + [['2.0.0'], true], + ])('rejects unusable versions %j (prerelease run: %s)', (branchVersions, prerelease) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(resolvePinnedVersion({ ...base, branchVersions, prerelease })).toBeNull() + }) +}) -- 2.51.2