277 lines
7.9 KiB
JavaScript
277 lines
7.9 KiB
JavaScript
// @ts-nocheck
|
|
import fs from 'fs'
|
|
import path from 'node:path'
|
|
import { globSync } from 'node:fs'
|
|
import { execSync, execFileSync } from 'node:child_process'
|
|
import { tmpdir } from 'node:os'
|
|
|
|
const rootDir = path.join(import.meta.dirname, '..')
|
|
const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
|
|
|
// Resolve GitHub usernames from commit author emails
|
|
const usernameCache = {}
|
|
async function resolveUsername(email) {
|
|
if (!ghToken || !email) return null
|
|
if (usernameCache[email] !== undefined) return usernameCache[email]
|
|
|
|
try {
|
|
const res = await fetch(`https://api.github.com/search/users?q=${email}`, {
|
|
headers: { Authorization: `token ${ghToken}` },
|
|
})
|
|
const data = await res.json()
|
|
const login = data?.items?.[0]?.login || null
|
|
usernameCache[email] = login
|
|
return login
|
|
} catch {
|
|
usernameCache[email] = null
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Resolve author from a PR number via GitHub API
|
|
const prAuthorCache = {}
|
|
async function resolveAuthorForPR(prNumber) {
|
|
if (prAuthorCache[prNumber] !== undefined) return prAuthorCache[prNumber]
|
|
|
|
if (!ghToken) {
|
|
prAuthorCache[prNumber] = null
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`https://api.github.com/repos/TanStack/query/pulls/${prNumber}`,
|
|
{ headers: { Authorization: `token ${ghToken}` } },
|
|
)
|
|
const data = await res.json()
|
|
const login = data?.user?.login || null
|
|
prAuthorCache[prNumber] = login
|
|
return login
|
|
} catch {
|
|
prAuthorCache[prNumber] = null
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Get the previous release commit to diff against.
|
|
// This script runs right after the "ci: Version Packages" PR is merged,
|
|
// so HEAD is the release commit.
|
|
const releaseLogs = execSync(
|
|
'git log --oneline --grep="^ci: Version Packages" --grep="^ci: changeset release" --format=%H',
|
|
)
|
|
.toString()
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
|
|
const currentRelease = releaseLogs[0] || 'HEAD'
|
|
const previousRelease = releaseLogs[1]
|
|
|
|
// Find packages that were actually bumped by comparing versions
|
|
const packagesDir = path.join(rootDir, 'packages')
|
|
const allPkgJsonPaths = globSync('*/package.json', { cwd: packagesDir })
|
|
|
|
const bumpedPackages = []
|
|
for (const relPath of allPkgJsonPaths) {
|
|
const fullPath = path.join(packagesDir, relPath)
|
|
const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8'))
|
|
if (currentPkg.private) continue
|
|
|
|
// Get the version from the previous release commit
|
|
if (previousRelease) {
|
|
try {
|
|
const prevContent = execFileSync(
|
|
'git',
|
|
['show', `${previousRelease}:packages/${relPath}`],
|
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
|
|
)
|
|
const prevPkg = JSON.parse(prevContent)
|
|
if (prevPkg.version !== currentPkg.version) {
|
|
bumpedPackages.push({
|
|
name: currentPkg.name,
|
|
version: currentPkg.version,
|
|
prevVersion: prevPkg.version,
|
|
dir: path.dirname(relPath),
|
|
})
|
|
}
|
|
} catch {
|
|
// Package didn't exist in previous release — it's new
|
|
bumpedPackages.push({
|
|
name: currentPkg.name,
|
|
version: currentPkg.version,
|
|
prevVersion: null,
|
|
dir: path.dirname(relPath),
|
|
})
|
|
}
|
|
} else {
|
|
// No previous release — include all non-private packages
|
|
bumpedPackages.push({
|
|
name: currentPkg.name,
|
|
version: currentPkg.version,
|
|
prevVersion: null,
|
|
dir: path.dirname(relPath),
|
|
})
|
|
}
|
|
}
|
|
|
|
bumpedPackages.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
// Build changelog from git log between releases (conventional commits)
|
|
const rangeFrom = previousRelease || `${currentRelease}~1`
|
|
const rawLog = execSync(
|
|
`git log ${rangeFrom}..${currentRelease} --pretty=format:"%h %ae %s" --no-merges`,
|
|
{ encoding: 'utf-8' },
|
|
).trim()
|
|
|
|
const typeOrder = [
|
|
'breaking',
|
|
'feat',
|
|
'fix',
|
|
'perf',
|
|
'refactor',
|
|
'docs',
|
|
'chore',
|
|
'test',
|
|
'ci',
|
|
]
|
|
const typeLabels = {
|
|
breaking: '⚠️ Breaking Changes',
|
|
feat: 'Features',
|
|
fix: 'Fix',
|
|
perf: 'Performance',
|
|
refactor: 'Refactor',
|
|
docs: 'Documentation',
|
|
chore: 'Chore',
|
|
test: 'Tests',
|
|
ci: 'CI',
|
|
}
|
|
const typeIndex = (t) => {
|
|
const i = typeOrder.indexOf(t)
|
|
return i === -1 ? 99 : i
|
|
}
|
|
|
|
const groups = {}
|
|
const commits = rawLog ? rawLog.split('\n') : []
|
|
|
|
for (const line of commits) {
|
|
const match = line.match(/^(\w+)\s+(\S+)\s+(.*)$/)
|
|
if (!match) continue
|
|
const [, hash, email, subject] = match
|
|
|
|
// Skip release commits
|
|
if (
|
|
subject.startsWith('ci: Version Packages') ||
|
|
subject.startsWith('ci: changeset release')
|
|
)
|
|
continue
|
|
|
|
// Parse conventional commit: type(scope)!: message
|
|
const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/)
|
|
const type = conventionalMatch ? conventionalMatch[1] : 'other'
|
|
const isBreaking = conventionalMatch ? !!conventionalMatch[3] : false
|
|
const scope = conventionalMatch ? conventionalMatch[2] || '' : ''
|
|
const message = conventionalMatch ? conventionalMatch[4] : subject
|
|
|
|
// Only include user-facing change types
|
|
if (!['feat', 'fix', 'perf', 'refactor', 'build', 'chore'].includes(type))
|
|
continue
|
|
|
|
// Extract PR number if present
|
|
const prMatch = message.match(/\(#(\d+)\)/)
|
|
const prNumber = prMatch ? prMatch[1] : null
|
|
|
|
const bucket = isBreaking ? 'breaking' : type
|
|
if (!groups[bucket]) groups[bucket] = []
|
|
groups[bucket].push({ hash, email, scope, message, prNumber })
|
|
}
|
|
|
|
// Build markdown grouped by conventional commit type
|
|
const sortedTypes = Object.keys(groups).sort(
|
|
(a, b) => typeIndex(a) - typeIndex(b),
|
|
)
|
|
|
|
let changelogMd = ''
|
|
for (const type of sortedTypes) {
|
|
const label = typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1)
|
|
changelogMd += `### ${label}\n\n`
|
|
|
|
for (const commit of groups[type]) {
|
|
const scopePrefix = commit.scope ? `${commit.scope}: ` : ''
|
|
const cleanMessage = commit.message.replace(/\s*\(#\d+\)/, '')
|
|
const prRef = commit.prNumber ? ` (#${commit.prNumber})` : ''
|
|
const username = commit.prNumber
|
|
? await resolveAuthorForPR(commit.prNumber)
|
|
: await resolveUsername(commit.email)
|
|
const authorSuffix = username ? ` by @${username}` : ''
|
|
|
|
changelogMd += `- ${scopePrefix}${cleanMessage}${prRef} (${commit.hash})${authorSuffix}\n`
|
|
}
|
|
changelogMd += '\n'
|
|
}
|
|
|
|
if (!changelogMd.trim()) {
|
|
changelogMd = '- No changelog entries\n\n'
|
|
}
|
|
|
|
const now = new Date()
|
|
const date = now.toISOString().slice(0, 10)
|
|
const time = now.toISOString().slice(11, 16).replace(':', '')
|
|
const tagName = `release-${date}-${time}`
|
|
const titleDate = `${date} ${now.toISOString().slice(11, 16)}`
|
|
|
|
const isPrerelease = process.argv.includes('--prerelease')
|
|
const isLatest = process.argv.includes('--latest')
|
|
|
|
const body = `Release ${titleDate}
|
|
|
|
## Changes
|
|
|
|
${changelogMd}
|
|
## Packages
|
|
|
|
${bumpedPackages.map((p) => `- ${p.name}@${p.version}`).join('\n')}
|
|
`
|
|
|
|
// Create the release
|
|
// Check if tag already exists — if so, try to create the release for it
|
|
// (handles retries where the tag was pushed but release creation failed)
|
|
let tagExists = false
|
|
try {
|
|
execSync(`git rev-parse ${tagName}`, { stdio: 'ignore' })
|
|
tagExists = true
|
|
} catch {
|
|
// Tag doesn't exist yet
|
|
}
|
|
|
|
if (!tagExists) {
|
|
execSync(`git tag -a -m "${tagName}" ${tagName}`)
|
|
execSync('git push --tags')
|
|
}
|
|
|
|
const prereleaseFlag = isPrerelease ? '--prerelease' : ''
|
|
const latestFlag = isLatest ? ' --latest' : ''
|
|
const tmpFile = path.join(tmpdir(), `release-notes-${tagName}.md`)
|
|
fs.writeFileSync(tmpFile, body)
|
|
|
|
try {
|
|
execSync(
|
|
`gh release create ${tagName} ${prereleaseFlag} --title "Release ${titleDate}" --notes-file ${tmpFile}${latestFlag}`,
|
|
{ stdio: 'inherit' },
|
|
)
|
|
console.info(`GitHub release ${tagName} created.`)
|
|
} catch (err) {
|
|
// Clean up the tag if we created it but release failed
|
|
if (!tagExists) {
|
|
console.info(`Release creation failed, cleaning up tag ${tagName}...`)
|
|
try {
|
|
execSync(`git push --delete origin ${tagName}`, { stdio: 'ignore' })
|
|
execSync(`git tag -d ${tagName}`, { stdio: 'ignore' })
|
|
} catch {
|
|
// Best effort cleanup
|
|
}
|
|
}
|
|
throw err
|
|
} finally {
|
|
fs.unlinkSync(tmpFile)
|
|
}
|