Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions scripts/create-single-release.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
140 changes: 77 additions & 63 deletions scripts/create-single-release.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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+)\)/)
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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}...`)

Expand All @@ -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 {
Expand Down Expand Up @@ -414,4 +426,6 @@ async function main() {
}
}

main()
if (import.meta.main) {
main()
}
Loading