Skip to content

Commit 0d581ce

Browse files
authored
fix(release): bound release commit lookups (#7724)
1 parent bc6b1b2 commit 0d581ce

2 files changed

Lines changed: 229 additions & 63 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { execFileSync } from 'node:child_process'
2+
import { mkdtempSync, rmSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import path from 'node:path'
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+
7+
const SCRIPT = path.resolve(import.meta.dirname, 'create-single-release.ts')
8+
9+
interface VersionCommit {
10+
hash: string
11+
version: string
12+
title: string
13+
date: string
14+
author: string
15+
}
16+
17+
interface ReleaseLookup {
18+
current: VersionCommit | null
19+
previous: VersionCommit | null
20+
}
21+
22+
describe('release commit lookup', () => {
23+
let directory: string
24+
let tree: string
25+
let head: string
26+
27+
function git(args: string[], input?: string): string {
28+
return execFileSync('git', args, {
29+
cwd: directory,
30+
encoding: 'utf8',
31+
input,
32+
env: {
33+
...process.env,
34+
GIT_AUTHOR_NAME: 'Release Author',
35+
GIT_AUTHOR_EMAIL: 'release@example.com',
36+
GIT_COMMITTER_NAME: 'Release Author',
37+
GIT_COMMITTER_EMAIL: 'release@example.com',
38+
},
39+
}).trim()
40+
}
41+
42+
function commit(message: string, parents = head ? [head] : []): string {
43+
head = git(['commit-tree', tree, ...parents.flatMap((parent) => ['-p', parent])], message)
44+
git(['update-ref', 'refs/heads/main', head])
45+
return head
46+
}
47+
48+
function lookup(version: string, commitSha = ''): ReleaseLookup {
49+
const output = execFileSync(
50+
'bun',
51+
[
52+
'--no-env-file',
53+
'--eval',
54+
`import { findVersionCommit, findPreviousVersionCommit } from ${JSON.stringify(SCRIPT)};
55+
const current = findVersionCommit(${JSON.stringify(version)});
56+
const previous = current ? findPreviousVersionCommit(current) : null;
57+
process.stdout.write(JSON.stringify({ current, previous }));`,
58+
],
59+
{
60+
cwd: directory,
61+
encoding: 'utf8',
62+
env: { ...process.env, GH_PAT: '', GITHUB_SHA: commitSha, LOG_LEVEL: 'ERROR' },
63+
}
64+
)
65+
return JSON.parse(output)
66+
}
67+
68+
beforeEach(() => {
69+
directory = mkdtempSync(path.join(tmpdir(), 'sim-release-test-'))
70+
head = ''
71+
git(['init', '--initial-branch=main', '--quiet'])
72+
tree = git(['mktree'], '')
73+
})
74+
75+
afterEach(() => {
76+
rmSync(directory, { recursive: true, force: true })
77+
})
78+
79+
it('finds the release boundaries when older history exceeds the subprocess buffer', () => {
80+
for (let index = 0; index < 40; index++) {
81+
commit(`chore: historical change ${index} ${'x'.repeat(32_000)}`)
82+
}
83+
const previous = commit('v0.8.30: previous release')
84+
commit('fix(search): improve indexing (#7720)')
85+
const current = commit('v0.8.31: current release')
86+
87+
expect(() => git(['log', '--format=%H|%s|%ai|%an', 'main'])).toThrow(/ENOBUFS/)
88+
expect(lookup('v0.8.31')).toMatchObject({
89+
current: { hash: current, version: 'v0.8.31' },
90+
previous: { hash: previous, version: 'v0.8.30' },
91+
})
92+
})
93+
94+
it('uses the CI commit when main has advanced and HEAD is detached', () => {
95+
const previous = commit('v0.8.30: previous release')
96+
const current = commit('v0.8.31: current release')
97+
commit('v0.8.32: later release')
98+
git(['checkout', '--detach', '--quiet', current])
99+
git(['branch', '-D', 'main'])
100+
101+
expect(lookup('v0.8.31', current)).toMatchObject({
102+
current: { hash: current },
103+
previous: { hash: previous },
104+
})
105+
})
106+
107+
it('rejects a CI commit whose version differs from the requested release', () => {
108+
commit('v0.8.30: previous release')
109+
const current = commit('v0.8.31: current release')
110+
111+
expect(lookup('v0.8.30', current)).toEqual({ current: null, previous: null })
112+
})
113+
114+
it('supports looking up an older release on main', () => {
115+
const previous = commit('v0.8.30: previous release')
116+
const current = commit('v0.8.31: current release')
117+
commit('v0.8.32: later release')
118+
119+
expect(lookup('v0.8.31')).toMatchObject({
120+
current: { hash: current },
121+
previous: { hash: previous },
122+
})
123+
})
124+
125+
it('ignores release-like commit bodies and releases merged from another branch', () => {
126+
const previous = commit('v0.8.30: previous release')
127+
const sideRelease = commit('v9.0.0: release on staging', [previous])
128+
const mainCommit = commit('chore: mention a version\n\nv8.0.0: not a release', [previous])
129+
const current = commit('v0.8.31: current release', [mainCommit, sideRelease])
130+
commit('v0.8.32: later release\n\nv0.8.31: mentioned in the body')
131+
132+
expect(lookup('v0.8.31')).toMatchObject({
133+
current: { hash: current },
134+
previous: { hash: previous },
135+
})
136+
})
137+
138+
it('preserves pipe characters in release titles', () => {
139+
const current = commit('v0.8.31: parsers | search improvements')
140+
141+
expect(lookup('v0.8.31')).toMatchObject({
142+
current: { hash: current, title: 'parsers | search improvements', author: 'Release Author' },
143+
previous: null,
144+
})
145+
})
146+
147+
it('returns no match for a missing version or a similar version number', () => {
148+
commit('v0.8.310: a different version')
149+
150+
expect(lookup('v0.8.31')).toEqual({ current: null, previous: null })
151+
})
152+
})

‎scripts/create-single-release.ts‎

Lines changed: 77 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,16 @@
11
#!/usr/bin/env bun
22

3-
import { execSync } from 'node:child_process'
3+
import { execFileSync } from 'node:child_process'
44
import { Octokit } from '@octokit/rest'
5+
import { createLogger } from '@sim/logger'
56
import { sleep } from '@sim/utils/helpers'
67

8+
const logger = createLogger('CreateRelease')
79
const GITHUB_TOKEN = process.env.GH_PAT
810
const REPO_OWNER = 'simstudioai'
911
const REPO_NAME = 'sim'
1012

11-
if (!GITHUB_TOKEN) {
12-
console.error('❌ GH_PAT environment variable is required')
13-
process.exit(1)
14-
}
15-
1613
const targetVersion = process.argv[2]
17-
if (!targetVersion) {
18-
console.error('❌ Version argument is required')
19-
console.error('Usage: bun run scripts/create-single-release.ts v0.3.XX')
20-
process.exit(1)
21-
}
2214

2315
const octokit = new Octokit({
2416
auth: GITHUB_TOKEN,
@@ -40,70 +32,79 @@ interface CommitDetail {
4032
prNumber?: string
4133
}
4234

43-
function execCommand(command: string): string {
35+
function execGit(args: string[]): string {
4436
try {
45-
return execSync(command, { encoding: 'utf8' }).trim()
37+
return execFileSync('git', args, { encoding: 'utf8' }).trim()
4638
} catch (error) {
47-
console.error(`❌ Command failed: ${command}`)
39+
logger.error('Git command failed', { args })
4840
throw error
4941
}
5042
}
5143

52-
function findVersionCommit(version: string): VersionCommit | null {
53-
console.log(`🔍 Finding commit for version ${version}...`)
44+
const VERSION_COMMIT_FORMAT = '--format=%H%x00%s%x00%aI%x00%an'
5445

55-
const gitLog = execCommand('git log --oneline --format="%H|%s|%ai|%an" main')
56-
const lines = gitLog.split('\n').filter((line) => line.trim())
46+
function parseVersionCommit(line: string): VersionCommit | null {
47+
if (!line) return null
5748

58-
for (const line of lines) {
59-
const [hash, message, date, author] = line.split('|')
49+
const [hash, message, date, author] = line.split('\0')
50+
const versionMatch = message.match(/^\s*(v\d+\.\d+\.?\d*):\s*(.+)$/)
51+
if (!versionMatch) return null
6052

61-
const versionMatch = message.match(/^\s*(v\d+\.\d+\.?\d*):\s*(.+)$/)
62-
if (versionMatch && versionMatch[1] === version) {
63-
return {
64-
hash,
65-
version,
66-
title: versionMatch[2],
67-
date: new Date(date).toISOString(),
68-
author,
69-
}
70-
}
53+
return {
54+
hash,
55+
version: versionMatch[1],
56+
title: versionMatch[2],
57+
date: new Date(date).toISOString(),
58+
author,
7159
}
72-
73-
return null
7460
}
7561

76-
function findPreviousVersionCommit(currentVersion: string): VersionCommit | null {
77-
console.log(`🔍 Finding previous version before ${currentVersion}...`)
78-
79-
const gitLog = execCommand('git log --oneline --format="%H|%s|%ai|%an" main')
80-
const lines = gitLog.split('\n').filter((line) => line.trim())
81-
82-
let foundCurrent = false
83-
84-
for (const line of lines) {
85-
const [hash, message, date, author] = line.split('|')
62+
/** Reads one release candidate at a time, stopping at the first matching subject. */
63+
function findReleaseCommit(ref: string, version?: string, skip = 0): VersionCommit | null {
64+
const versionPattern = version ? version.replaceAll('.', '[.]') : 'v[0-9]+[.][0-9]+[.]?[0-9]*'
65+
66+
while (true) {
67+
const line = execGit([
68+
'log',
69+
'--first-parent',
70+
'--max-count=1',
71+
`--skip=${skip}`,
72+
'--extended-regexp',
73+
`--grep=^[[:space:]]*${versionPattern}:[[:space:]]*.+`,
74+
VERSION_COMMIT_FORMAT,
75+
ref,
76+
'--',
77+
])
78+
if (!line) return null
79+
80+
const commit = parseVersionCommit(line)
81+
if (commit && (!version || commit.version === version)) return commit
82+
83+
/** Git's grep also matches commit bodies; only release subjects are boundaries. */
84+
skip++
85+
}
86+
}
8687

87-
const versionMatch = message.match(/^\s*(v\d+\.\d+\.?\d*):\s*(.+)$/)
88-
if (versionMatch) {
89-
if (versionMatch[1] === currentVersion) {
90-
foundCurrent = true
91-
continue
92-
}
88+
export function findVersionCommit(
89+
version: string,
90+
commitSha = process.env.GITHUB_SHA
91+
): VersionCommit | null {
92+
logger.info(`Finding commit for version ${version}`)
93+
if (!/^v\d+\.\d+\.?\d*$/.test(version)) return null
9394

94-
if (foundCurrent) {
95-
return {
96-
hash,
97-
version: versionMatch[1],
98-
title: versionMatch[2],
99-
date: new Date(date).toISOString(),
100-
author,
101-
}
102-
}
103-
}
95+
if (commitSha) {
96+
const commit = parseVersionCommit(
97+
execGit(['log', '-1', VERSION_COMMIT_FORMAT, commitSha, '--'])
98+
)
99+
return commit?.version === version ? commit : null
104100
}
105101

106-
return null
102+
return findReleaseCommit('main', version)
103+
}
104+
105+
export function findPreviousVersionCommit(currentCommit: VersionCommit): VersionCommit | null {
106+
logger.info(`Finding previous version before ${currentCommit.version}`)
107+
return findReleaseCommit(currentCommit.hash, undefined, 1)
107108
}
108109

109110
async function fetchGitHubCommitDetails(
@@ -145,7 +146,7 @@ async function fetchGitHubCommitDetails(
145146
console.warn(`⚠️ Could not fetch commit ${hash.substring(0, 7)}: ${error?.message || error}`)
146147

147148
try {
148-
const gitData = execCommand(`git log --format="%s|%an" -1 ${hash}`).split('|')
149+
const gitData = execGit(['log', '--format=%s|%an', '-1', hash, '--']).split('|')
149150
let message = gitData[0] || 'Unknown commit'
150151

151152
const prMatch = message.match(/\(#(\d+)\)/)
@@ -188,7 +189,7 @@ async function getCommitsBetweenVersions(
188189
console.log(`🔍 Getting commits before first version ${currentCommit.version}`)
189190
}
190191

191-
const gitLog = execCommand(`git log --oneline --format="%H|%s" ${range}`)
192+
const gitLog = execGit(['log', '--format=%H|%s', range, '--'])
192193

193194
if (!gitLog.trim()) {
194195
console.log(`⚠️ No commits found in range ${range}`)
@@ -350,6 +351,17 @@ async function generateReleaseBody(
350351
}
351352

352353
async function main() {
354+
if (!GITHUB_TOKEN) {
355+
logger.error('GH_PAT environment variable is required')
356+
process.exit(1)
357+
}
358+
if (!targetVersion) {
359+
logger.error(
360+
'Version argument is required. Usage: bun run scripts/create-single-release.ts vX.Y.Z'
361+
)
362+
process.exit(1)
363+
}
364+
353365
try {
354366
console.log(`🚀 Creating single release for ${targetVersion}...`)
355367

@@ -363,7 +375,7 @@ async function main() {
363375
`✅ Found version commit: ${versionCommit.hash.substring(0, 7)} - ${versionCommit.title}`
364376
)
365377

366-
const previousCommit = findPreviousVersionCommit(targetVersion)
378+
const previousCommit = findPreviousVersionCommit(versionCommit)
367379
if (previousCommit) {
368380
console.log(`✅ Found previous version: ${previousCommit.version}`)
369381
} else {
@@ -414,4 +426,6 @@ async function main() {
414426
}
415427
}
416428

417-
main()
429+
if (import.meta.main) {
430+
main()
431+
}

0 commit comments

Comments
 (0)