From 59dc2ce0f8c96a36e5eab68f5fca32c1b1a0df9c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 02:01:33 -0700 Subject: [PATCH] fix(release): bound release commit lookups --- scripts/create-single-release.test.ts | 152 ++++++++++++++++++++++++++ scripts/create-single-release.ts | 140 +++++++++++++----------- 2 files changed, 229 insertions(+), 63 deletions(-) create mode 100644 scripts/create-single-release.test.ts diff --git a/scripts/create-single-release.test.ts b/scripts/create-single-release.test.ts new file mode 100644 index 00000000000..651f3677886 --- /dev/null +++ b/scripts/create-single-release.test.ts @@ -0,0 +1,152 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const SCRIPT = path.resolve(import.meta.dirname, 'create-single-release.ts') + +interface VersionCommit { + hash: string + version: string + title: string + date: string + author: string +} + +interface ReleaseLookup { + current: VersionCommit | null + previous: VersionCommit | null +} + +describe('release commit lookup', () => { + let directory: string + let tree: string + let head: string + + function git(args: string[], input?: string): string { + return execFileSync('git', args, { + cwd: directory, + encoding: 'utf8', + input, + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Release Author', + GIT_AUTHOR_EMAIL: 'release@example.com', + GIT_COMMITTER_NAME: 'Release Author', + GIT_COMMITTER_EMAIL: 'release@example.com', + }, + }).trim() + } + + function commit(message: string, parents = head ? [head] : []): string { + head = git(['commit-tree', tree, ...parents.flatMap((parent) => ['-p', parent])], message) + git(['update-ref', 'refs/heads/main', head]) + return head + } + + function lookup(version: string, commitSha = ''): ReleaseLookup { + const output = execFileSync( + 'bun', + [ + '--no-env-file', + '--eval', + `import { findVersionCommit, findPreviousVersionCommit } from ${JSON.stringify(SCRIPT)}; + const current = findVersionCommit(${JSON.stringify(version)}); + const previous = current ? findPreviousVersionCommit(current) : null; + process.stdout.write(JSON.stringify({ current, previous }));`, + ], + { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, GH_PAT: '', GITHUB_SHA: commitSha, LOG_LEVEL: 'ERROR' }, + } + ) + return JSON.parse(output) + } + + beforeEach(() => { + directory = mkdtempSync(path.join(tmpdir(), 'sim-release-test-')) + head = '' + git(['init', '--initial-branch=main', '--quiet']) + tree = git(['mktree'], '') + }) + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }) + }) + + it('finds the release boundaries when older history exceeds the subprocess buffer', () => { + for (let index = 0; index < 40; index++) { + commit(`chore: historical change ${index} ${'x'.repeat(32_000)}`) + } + const previous = commit('v0.8.30: previous release') + commit('fix(search): improve indexing (#7720)') + const current = commit('v0.8.31: current release') + + expect(() => git(['log', '--format=%H|%s|%ai|%an', 'main'])).toThrow(/ENOBUFS/) + expect(lookup('v0.8.31')).toMatchObject({ + current: { hash: current, version: 'v0.8.31' }, + previous: { hash: previous, version: 'v0.8.30' }, + }) + }) + + it('uses the CI commit when main has advanced and HEAD is detached', () => { + const previous = commit('v0.8.30: previous release') + const current = commit('v0.8.31: current release') + commit('v0.8.32: later release') + git(['checkout', '--detach', '--quiet', current]) + git(['branch', '-D', 'main']) + + expect(lookup('v0.8.31', current)).toMatchObject({ + current: { hash: current }, + previous: { hash: previous }, + }) + }) + + it('rejects a CI commit whose version differs from the requested release', () => { + commit('v0.8.30: previous release') + const current = commit('v0.8.31: current release') + + expect(lookup('v0.8.30', current)).toEqual({ current: null, previous: null }) + }) + + it('supports looking up an older release on main', () => { + const previous = commit('v0.8.30: previous release') + const current = commit('v0.8.31: current release') + commit('v0.8.32: later release') + + expect(lookup('v0.8.31')).toMatchObject({ + current: { hash: current }, + previous: { hash: previous }, + }) + }) + + it('ignores release-like commit bodies and releases merged from another branch', () => { + const previous = commit('v0.8.30: previous release') + const sideRelease = commit('v9.0.0: release on staging', [previous]) + const mainCommit = commit('chore: mention a version\n\nv8.0.0: not a release', [previous]) + const current = commit('v0.8.31: current release', [mainCommit, sideRelease]) + commit('v0.8.32: later release\n\nv0.8.31: mentioned in the body') + + expect(lookup('v0.8.31')).toMatchObject({ + current: { hash: current }, + previous: { hash: previous }, + }) + }) + + it('preserves pipe characters in release titles', () => { + const current = commit('v0.8.31: parsers | search improvements') + + expect(lookup('v0.8.31')).toMatchObject({ + current: { hash: current, title: 'parsers | search improvements', author: 'Release Author' }, + previous: null, + }) + }) + + it('returns no match for a missing version or a similar version number', () => { + commit('v0.8.310: a different version') + + expect(lookup('v0.8.31')).toEqual({ current: null, previous: null }) + }) +}) diff --git a/scripts/create-single-release.ts b/scripts/create-single-release.ts index 9d98b930593..162a6966196 100755 --- a/scripts/create-single-release.ts +++ b/scripts/create-single-release.ts @@ -1,24 +1,16 @@ #!/usr/bin/env bun -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { Octokit } from '@octokit/rest' +import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' +const logger = createLogger('CreateRelease') const GITHUB_TOKEN = process.env.GH_PAT const REPO_OWNER = 'simstudioai' const REPO_NAME = 'sim' -if (!GITHUB_TOKEN) { - console.error('❌ GH_PAT environment variable is required') - process.exit(1) -} - const targetVersion = process.argv[2] -if (!targetVersion) { - console.error('❌ Version argument is required') - console.error('Usage: bun run scripts/create-single-release.ts v0.3.XX') - process.exit(1) -} const octokit = new Octokit({ auth: GITHUB_TOKEN, @@ -40,70 +32,79 @@ interface CommitDetail { prNumber?: string } -function execCommand(command: string): string { +function execGit(args: string[]): string { try { - return execSync(command, { encoding: 'utf8' }).trim() + return execFileSync('git', args, { encoding: 'utf8' }).trim() } catch (error) { - console.error(`❌ Command failed: ${command}`) + logger.error('Git command failed', { args }) throw error } } -function findVersionCommit(version: string): VersionCommit | null { - console.log(`🔍 Finding commit for version ${version}...`) +const VERSION_COMMIT_FORMAT = '--format=%H%x00%s%x00%aI%x00%an' - const gitLog = execCommand('git log --oneline --format="%H|%s|%ai|%an" main') - const lines = gitLog.split('\n').filter((line) => line.trim()) +function parseVersionCommit(line: string): VersionCommit | null { + if (!line) return null - for (const line of lines) { - const [hash, message, date, author] = line.split('|') + const [hash, message, date, author] = line.split('\0') + const versionMatch = message.match(/^\s*(v\d+\.\d+\.?\d*):\s*(.+)$/) + if (!versionMatch) return null - const versionMatch = message.match(/^\s*(v\d+\.\d+\.?\d*):\s*(.+)$/) - if (versionMatch && versionMatch[1] === version) { - return { - hash, - version, - title: versionMatch[2], - date: new Date(date).toISOString(), - author, - } - } + return { + hash, + version: versionMatch[1], + title: versionMatch[2], + date: new Date(date).toISOString(), + author, } - - return null } -function findPreviousVersionCommit(currentVersion: string): VersionCommit | null { - console.log(`🔍 Finding previous version before ${currentVersion}...`) - - const gitLog = execCommand('git log --oneline --format="%H|%s|%ai|%an" main') - const lines = gitLog.split('\n').filter((line) => line.trim()) - - let foundCurrent = false - - for (const line of lines) { - const [hash, message, date, author] = line.split('|') +/** Reads one release candidate at a time, stopping at the first matching subject. */ +function findReleaseCommit(ref: string, version?: string, skip = 0): VersionCommit | null { + const versionPattern = version ? version.replaceAll('.', '[.]') : 'v[0-9]+[.][0-9]+[.]?[0-9]*' + + while (true) { + const line = execGit([ + 'log', + '--first-parent', + '--max-count=1', + `--skip=${skip}`, + '--extended-regexp', + `--grep=^[[:space:]]*${versionPattern}:[[:space:]]*.+`, + VERSION_COMMIT_FORMAT, + ref, + '--', + ]) + if (!line) return null + + const commit = parseVersionCommit(line) + if (commit && (!version || commit.version === version)) return commit + + /** Git's grep also matches commit bodies; only release subjects are boundaries. */ + skip++ + } +} - const versionMatch = message.match(/^\s*(v\d+\.\d+\.?\d*):\s*(.+)$/) - if (versionMatch) { - if (versionMatch[1] === currentVersion) { - foundCurrent = true - continue - } +export function findVersionCommit( + version: string, + commitSha = process.env.GITHUB_SHA +): VersionCommit | null { + logger.info(`Finding commit for version ${version}`) + if (!/^v\d+\.\d+\.?\d*$/.test(version)) return null - if (foundCurrent) { - return { - hash, - version: versionMatch[1], - title: versionMatch[2], - date: new Date(date).toISOString(), - author, - } - } - } + if (commitSha) { + const commit = parseVersionCommit( + execGit(['log', '-1', VERSION_COMMIT_FORMAT, commitSha, '--']) + ) + return commit?.version === version ? commit : null } - return null + return findReleaseCommit('main', version) +} + +export function findPreviousVersionCommit(currentCommit: VersionCommit): VersionCommit | null { + logger.info(`Finding previous version before ${currentCommit.version}`) + return findReleaseCommit(currentCommit.hash, undefined, 1) } async function fetchGitHubCommitDetails( @@ -145,7 +146,7 @@ async function fetchGitHubCommitDetails( console.warn(`⚠️ Could not fetch commit ${hash.substring(0, 7)}: ${error?.message || error}`) try { - const gitData = execCommand(`git log --format="%s|%an" -1 ${hash}`).split('|') + const gitData = execGit(['log', '--format=%s|%an', '-1', hash, '--']).split('|') let message = gitData[0] || 'Unknown commit' const prMatch = message.match(/\(#(\d+)\)/) @@ -188,7 +189,7 @@ async function getCommitsBetweenVersions( console.log(`🔍 Getting commits before first version ${currentCommit.version}`) } - const gitLog = execCommand(`git log --oneline --format="%H|%s" ${range}`) + const gitLog = execGit(['log', '--format=%H|%s', range, '--']) if (!gitLog.trim()) { console.log(`⚠️ No commits found in range ${range}`) @@ -350,6 +351,17 @@ async function generateReleaseBody( } async function main() { + if (!GITHUB_TOKEN) { + logger.error('GH_PAT environment variable is required') + process.exit(1) + } + if (!targetVersion) { + logger.error( + 'Version argument is required. Usage: bun run scripts/create-single-release.ts vX.Y.Z' + ) + process.exit(1) + } + try { console.log(`🚀 Creating single release for ${targetVersion}...`) @@ -363,7 +375,7 @@ async function main() { `✅ Found version commit: ${versionCommit.hash.substring(0, 7)} - ${versionCommit.title}` ) - const previousCommit = findPreviousVersionCommit(targetVersion) + const previousCommit = findPreviousVersionCommit(versionCommit) if (previousCommit) { console.log(`✅ Found previous version: ${previousCommit.version}`) } else { @@ -414,4 +426,6 @@ async function main() { } } -main() +if (import.meta.main) { + main() +}