diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ce8261e..3f5d93a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,8 +92,8 @@ jobs: *) TAG="v$TAG" ;; esac + git rev-parse --verify "$TAG^{commit}" >/dev/null echo "tag=$TAG" >> "$GITHUB_OUTPUT" - git checkout --detach "$TAG" - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/scripts/release.ts b/scripts/release.ts index df705ef..c3fc218 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -4,6 +4,13 @@ import { $ } from "bun"; type BumpType = "patch" | "minor" | "major"; type Channel = "stable" | "next" | "finalize"; +interface FinalizeTarget { + tag: string; + commit: string; + prereleaseVersion: string; + stableVersion: string; +} + const VALID_BUMP_TYPES = ["patch", "minor", "major"] as const; const PACKAGE_PATH = "package.json"; @@ -94,6 +101,34 @@ function normalizePrereleaseTag(tag: string): string { return tag.startsWith("v") ? tag : `v${tag}`; } +async function currentBranch(): Promise { + return (await $`git branch --show-current`.text()).trim(); +} + +async function commitForRef(ref: string): Promise { + return (await $`git rev-list -n 1 ${ref}`.text()).trim(); +} + +async function packageJsonAtRef(ref: string): Promise<{ version: string }> { + const packageRef = `${ref}:${PACKAGE_PATH}`; + return JSON.parse(await $`git show ${packageRef}`.text()) as { + version: string; + }; +} + +function shortCommit(commit: string): string { + return commit.slice(0, 12); +} + +async function isAncestor( + ancestor: string, + descendant: string, +): Promise { + const result = + await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.nothrow(); + return result.exitCode === 0; +} + async function resolveLatestPrereleaseTag(): Promise { const tag = (await $`git tag --list v*-next.* --sort=-version:refname`.text()) .split("\n") @@ -107,7 +142,9 @@ async function resolveLatestPrereleaseTag(): Promise { return tag; } -async function resolveFinalizeTag(explicitTag?: string): Promise { +async function resolveFinalizeTarget( + explicitTag?: string, +): Promise { const tag = explicitTag ? normalizePrereleaseTag(explicitTag) : await resolveLatestPrereleaseTag(); @@ -117,17 +154,107 @@ async function resolveFinalizeTag(explicitTag?: string): Promise { fail(`Error: Prerelease tag '${tag}' does not exist`); } - const packageRef = `${tag}:${PACKAGE_PATH}`; - const pkgAtTag = JSON.parse(await $`git show ${packageRef}`.text()) as { - version: string; - }; - if (!parseNextPrerelease(pkgAtTag.version)) { + const pkgAtTag = await packageJsonAtRef(tag); + const parsedVersion = parseNextPrerelease(pkgAtTag.version); + if (!parsedVersion) { fail( `Error: Tag '${tag}' does not point to a pre-release version (found ${pkgAtTag.version})`, ); } - return tag; + return { + tag, + commit: await commitForRef(tag), + prereleaseVersion: pkgAtTag.version, + stableVersion: parsedVersion.baseVersion, + }; +} + +async function prepareFinalizeMain(target: FinalizeTarget): Promise { + const branch = await currentBranch(); + if (branch !== "main") { + fail( + `Error: Finalize must run on main so the stable version commit can be pushed (currently on '${branch}')`, + ); + } + + console.log("Pulling latest changes..."); + await $`git pull --ff-only origin main`; + + const head = (await $`git rev-parse HEAD`.text()).trim(); + const releaseTag = `v${target.stableVersion}`; + const existingReleaseTag = (await $`git tag -l ${releaseTag}`.text()).trim(); + + if (existingReleaseTag && head === (await commitForRef(releaseTag))) { + return; + } + + if (head !== target.commit) { + fail( + `Error: Cannot finalize ${target.tag} because main is at ${shortCommit(head)}, not ${shortCommit(target.commit)}. Create a new prerelease from current main, or finalize before additional commits land on main.`, + ); + } +} + +async function ensureFinalizeMainStillMatches( + target: FinalizeTarget, + currentVersion: string, +): Promise { + const head = (await $`git rev-parse HEAD`.text()).trim(); + if (head !== target.commit) { + fail( + `Error: Cannot finalize ${target.tag} because main is at ${shortCommit(head)}, not ${shortCommit(target.commit)}.`, + ); + } + + if (currentVersion !== target.prereleaseVersion) { + fail( + `Error: Cannot finalize ${target.tag} because ${PACKAGE_PATH} on main is ${currentVersion}, but the prerelease tag contains ${target.prereleaseVersion}.`, + ); + } +} + +async function finishExistingFinalizeTag( + target: FinalizeTarget, + newVersion: string, +): Promise { + const releaseTag = `v${newVersion}`; + const pkgAtReleaseTag = await packageJsonAtRef(releaseTag); + if (pkgAtReleaseTag.version !== newVersion) { + fail( + `Error: Tag '${releaseTag}' already exists, but ${PACKAGE_PATH} contains version ${pkgAtReleaseTag.version}`, + ); + } + + const head = (await $`git rev-parse HEAD`.text()).trim(); + const releaseCommit = await commitForRef(releaseTag); + + if (head === releaseCommit) { + console.log(`Tag '${releaseTag}' already exists with version ${newVersion}`); + console.log(`Release tag ${releaseTag} already exists on main; continuing`); + return; + } + + if (head !== target.commit) { + fail( + `Error: Tag '${releaseTag}' already exists, but main is at ${shortCommit(head)} instead of ${shortCommit(target.commit)} or ${shortCommit(releaseCommit)}.`, + ); + } + + if (!(await isAncestor(head, releaseCommit))) { + fail( + `Error: Tag '${releaseTag}' already exists, but it cannot be fast-forwarded from ${target.tag} on main.`, + ); + } + + console.log(`Tag '${releaseTag}' already exists with version ${newVersion}`); + console.log(`Fast-forwarding main to existing release tag ${releaseTag}...`); + await $`git merge --ff-only ${releaseCommit}`; + + console.log("Pushing main to origin..."); + await $`git push --no-verify origin main`; + + console.log(`Release tag ${releaseTag} already exists; main now contains ${newVersion}`); } async function main() { @@ -144,20 +271,18 @@ async function main() { process.exit(1); } - let resolvedFinalizeTag: string | undefined; + let finalizeTarget: FinalizeTarget | undefined; if (channel === "finalize") { console.log("Fetching latest tags..."); await $`git fetch origin --tags`; - resolvedFinalizeTag = await resolveFinalizeTag(prereleaseTag); - - console.log(`Checking out prerelease tag ${resolvedFinalizeTag}...`); - await $`git checkout --detach ${resolvedFinalizeTag}`; + finalizeTarget = await resolveFinalizeTarget(prereleaseTag); + await prepareFinalizeMain(finalizeTarget); } else { // Ensure we're on main branch - const currentBranch = (await $`git branch --show-current`.text()).trim(); - if (currentBranch !== "main") { + const branch = await currentBranch(); + if (branch !== "main") { console.error( - `Error: Must be on main branch (currently on '${currentBranch}')`, + `Error: Must be on main branch (currently on '${branch}')`, ); process.exit(1); } @@ -177,7 +302,7 @@ async function main() { channel === "next" ? bumpNextVersion(currentVersion, bumpType) : channel === "finalize" - ? finalizeVersion(currentVersion) + ? (finalizeTarget?.stableVersion ?? finalizeVersion(currentVersion)) : bumpVersion(currentVersion, bumpType!); const tagName = `v${newVersion}`; @@ -187,30 +312,24 @@ async function main() { const existingTag = (await $`git tag -l ${tagName}`.text()).trim(); if (existingTag) { if (channel === "finalize") { - const packageRef = `${tagName}:${PACKAGE_PATH}`; - const pkgAtTag = JSON.parse(await $`git show ${packageRef}`.text()) as { - version: string; - }; - - if (pkgAtTag.version === newVersion) { - console.log(`Tag '${tagName}' already exists with version ${newVersion}`); - console.log(`Checking out existing release tag ${tagName}...`); - await $`git checkout --detach ${tagName}`; - console.log(""); - console.log(`Release tag ${tagName} already exists; continuing`); - return; + if (!finalizeTarget) { + fail("Error: Finalize target was not resolved"); } - - console.error( - `Error: Tag '${tagName}' already exists, but package.json contains version ${pkgAtTag.version}`, - ); - process.exit(1); + await finishExistingFinalizeTag(finalizeTarget, newVersion); + return; } console.error(`Error: Tag '${tagName}' already exists`); process.exit(1); } + if (channel === "finalize") { + if (!finalizeTarget) { + fail("Error: Finalize target was not resolved"); + } + await ensureFinalizeMainStillMatches(finalizeTarget, currentVersion); + } + // Update package.json pkg.version = newVersion; await Bun.write(PACKAGE_PATH, `${JSON.stringify(pkg, null, 2)}\n`); @@ -225,16 +344,12 @@ async function main() { // Push commit and tag (skip hooks — already validated before release) console.log("Pushing to origin..."); - if (channel !== "finalize") { - await $`git push --no-verify origin main`; - } + await $`git push --no-verify origin main`; await $`git push --no-verify origin ${tagName}`; console.log(""); console.log(`Release ${newVersion} completed successfully!`); - if (channel !== "finalize") { - console.log(" - Commit pushed to main"); - } + console.log(" - Commit pushed to main"); console.log(` - Tag ${tagName} pushed to origin`); console.log(""); console.log("Next steps:"); diff --git a/tests/unit/scripts/release.test.ts b/tests/unit/scripts/release.test.ts new file mode 100644 index 0000000..24a567b --- /dev/null +++ b/tests/unit/scripts/release.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const RELEASE_SCRIPT = resolve(import.meta.dir, '../../../scripts/release.ts'); +const PUBLISH_WORKFLOW = resolve(import.meta.dir, '../../../.github/workflows/publish.yml'); +const PRERELEASE_VERSION = '2.0.0-next.1'; +const PRERELEASE_TAG = `v${PRERELEASE_VERSION}`; +const STABLE_VERSION = '2.0.0'; +const STABLE_TAG = `v${STABLE_VERSION}`; + +const tempDirs: string[] = []; + +interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface ReleaseRepository { + root: string; + repo: string; + remote: string; + baseCommit: string; + prereleaseCommit: string; +} + +function run(cwd: string, command: string[]): CommandResult { + const env = { ...process.env }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + env.GIT_CONFIG_GLOBAL = '/dev/null'; + env.GIT_CONFIG_NOSYSTEM = '1'; + + const result = Bun.spawnSync(command, { + cwd, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +function git(cwd: string, ...args: string[]): string { + const result = run(cwd, ['git', ...args]); + if (result.exitCode !== 0) { + throw new Error( + `git ${args.join(' ')} failed:\n${result.stdout}${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +function remoteGit(remote: string, ...args: string[]): string { + return git(remote, `--git-dir=${remote}`, ...args); +} + +async function writePackage(repo: string, version: string): Promise { + await writeFile( + join(repo, 'package.json'), + `${JSON.stringify({ name: 'release-fixture', version }, null, 2)}\n`, + ); +} + +async function createReleaseRepository(): Promise { + const root = await mkdtemp(join(tmpdir(), 'allagents-release-test-')); + tempDirs.push(root); + const repo = join(root, 'repo'); + const remote = join(root, 'remote.git'); + + git(root, 'init', '--bare', '--initial-branch=main', remote); + git(root, 'init', '--initial-branch=main', repo); + git(repo, 'config', '--local', 'user.name', 'Release Test'); + git(repo, 'config', '--local', 'user.email', 'release-test@example.com'); + + await writePackage(repo, '1.9.0'); + git(repo, 'add', 'package.json'); + git(repo, 'commit', '-m', 'base release'); + const baseCommit = git(repo, 'rev-parse', 'HEAD'); + + await writePackage(repo, PRERELEASE_VERSION); + git(repo, 'add', 'package.json'); + git(repo, 'commit', '-m', `chore(release): bump version to ${PRERELEASE_VERSION}`); + git(repo, 'tag', '-a', PRERELEASE_TAG, '-m', `Release ${PRERELEASE_VERSION}`); + const prereleaseCommit = git(repo, 'rev-parse', 'HEAD'); + + git(repo, 'remote', 'add', 'origin', remote); + git(repo, 'push', '-u', 'origin', 'main'); + git(repo, 'push', 'origin', PRERELEASE_TAG); + + return { root, repo, remote, baseCommit, prereleaseCommit }; +} + +async function createDetachedStableTag( + fixture: ReleaseRepository, + startRef = PRERELEASE_TAG, +): Promise { + git(fixture.repo, 'checkout', '--detach', startRef); + await writePackage(fixture.repo, STABLE_VERSION); + git(fixture.repo, 'add', 'package.json'); + git(fixture.repo, 'commit', '-m', `chore(release): bump version to ${STABLE_VERSION}`); + git(fixture.repo, 'tag', '-a', STABLE_TAG, '-m', `Release ${STABLE_VERSION}`); + const stableCommit = git(fixture.repo, 'rev-parse', 'HEAD'); + git(fixture.repo, 'push', 'origin', STABLE_TAG); + git(fixture.repo, 'checkout', 'main'); + return stableCommit; +} + +function finalize(repo: string): CommandResult { + return run(repo, ['bun', RELEASE_SCRIPT, 'finalize', PRERELEASE_TAG]); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('release finalize', () => { + test('commits the stable version on main and pushes main with the annotated tag', async () => { + const fixture = await createReleaseRepository(); + + const result = finalize(fixture.repo); + + expect(result.exitCode).toBe(0); + const remoteMain = remoteGit(fixture.remote, 'rev-parse', 'refs/heads/main'); + const stableCommit = remoteGit(fixture.remote, 'rev-parse', `${STABLE_TAG}^{}`); + expect(remoteMain).toBe(stableCommit); + expect(remoteMain).not.toBe(fixture.prereleaseCommit); + expect(git(fixture.repo, 'branch', '--show-current')).toBe('main'); + expect(remoteGit(fixture.remote, 'cat-file', '-t', `refs/tags/${STABLE_TAG}`)).toBe( + 'tag', + ); + + const pkg = JSON.parse( + remoteGit(fixture.remote, 'show', 'refs/heads/main:package.json'), + ) as { version: string }; + expect(pkg.version).toBe(STABLE_VERSION); + }); + + test('rejects finalize when main advanced after the selected prerelease', async () => { + const fixture = await createReleaseRepository(); + await writeFile(join(fixture.repo, 'README.md'), 'advanced main\n'); + git(fixture.repo, 'add', 'README.md'); + git(fixture.repo, 'commit', '-m', 'advance main'); + git(fixture.repo, 'push', 'origin', 'main'); + const advancedMain = git(fixture.repo, 'rev-parse', 'HEAD'); + + const result = finalize(fixture.repo); + + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'Create a new prerelease from current main', + ); + expect(remoteGit(fixture.remote, 'rev-parse', 'refs/heads/main')).toBe( + advancedMain, + ); + expect( + run(fixture.root, [ + 'git', + `--git-dir=${fixture.remote}`, + 'rev-parse', + '--verify', + `refs/tags/${STABLE_TAG}`, + ]).exitCode, + ).not.toBe(0); + }); + + test('fast-forwards main to an existing detached stable tag', async () => { + const fixture = await createReleaseRepository(); + const stableCommit = await createDetachedStableTag(fixture); + expect(remoteGit(fixture.remote, 'rev-parse', 'refs/heads/main')).toBe( + fixture.prereleaseCommit, + ); + + const result = finalize(fixture.repo); + + expect(result.exitCode).toBe(0); + expect(remoteGit(fixture.remote, 'rev-parse', 'refs/heads/main')).toBe( + stableCommit, + ); + expect(remoteGit(fixture.remote, 'rev-parse', `${STABLE_TAG}^{}`)).toBe( + stableCommit, + ); + expect(git(fixture.repo, 'branch', '--show-current')).toBe('main'); + + const retry = finalize(fixture.repo); + expect(retry.exitCode).toBe(0); + expect(remoteGit(fixture.remote, 'rev-parse', 'refs/heads/main')).toBe( + stableCommit, + ); + }); + + test('rejects recovery when the detached stable tag diverged from the prerelease', async () => { + const fixture = await createReleaseRepository(); + const stableCommit = await createDetachedStableTag(fixture, fixture.baseCommit); + + const result = finalize(fixture.repo); + + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + `cannot be fast-forwarded from ${PRERELEASE_TAG}`, + ); + expect(remoteGit(fixture.remote, 'rev-parse', 'refs/heads/main')).toBe( + fixture.prereleaseCommit, + ); + expect(remoteGit(fixture.remote, 'rev-parse', `${STABLE_TAG}^{}`)).toBe( + stableCommit, + ); + }); +}); + +test('publish workflow validates the finalize tag without detaching from main', async () => { + const workflow = await readFile(PUBLISH_WORKFLOW, 'utf8'); + + expect(workflow).toContain('git rev-parse --verify "$TAG^{commit}" >/dev/null'); + expect(workflow).not.toContain('git checkout --detach "$TAG"'); +});