|
| 1 | +// @ts-nocheck |
| 2 | +import { execFileSync, execSync } from 'node:child_process' |
| 3 | +import fs, { globSync } from 'node:fs' |
| 4 | +import { tmpdir } from 'node:os' |
| 5 | +import path from 'node:path' |
| 6 | + |
| 7 | +const rootDir = path.join(import.meta.dirname, '..') |
| 8 | +const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN |
| 9 | +const workspaceDirs = ['packages', 'cli-aliases'] |
| 10 | + |
| 11 | +const usernameCache = {} |
| 12 | +async function resolveUsername(email) { |
| 13 | + if (!ghToken || !email) return null |
| 14 | + if (usernameCache[email] !== undefined) return usernameCache[email] |
| 15 | + |
| 16 | + try { |
| 17 | + const res = await fetch(`https://api.github.com/search/users?q=${email}`, { |
| 18 | + headers: { Authorization: `token ${ghToken}` }, |
| 19 | + }) |
| 20 | + const data = await res.json() |
| 21 | + const login = data?.items?.[0]?.login || null |
| 22 | + usernameCache[email] = login |
| 23 | + return login |
| 24 | + } catch { |
| 25 | + usernameCache[email] = null |
| 26 | + return null |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +const prAuthorCache = {} |
| 31 | +async function resolveAuthorForPR(prNumber) { |
| 32 | + if (prAuthorCache[prNumber] !== undefined) return prAuthorCache[prNumber] |
| 33 | + |
| 34 | + if (!ghToken) { |
| 35 | + prAuthorCache[prNumber] = null |
| 36 | + return null |
| 37 | + } |
| 38 | + |
| 39 | + try { |
| 40 | + const res = await fetch( |
| 41 | + `https://api.github.com/repos/TanStack/cli/pulls/${prNumber}`, |
| 42 | + { headers: { Authorization: `token ${ghToken}` } }, |
| 43 | + ) |
| 44 | + const data = await res.json() |
| 45 | + const login = data?.user?.login || null |
| 46 | + prAuthorCache[prNumber] = login |
| 47 | + return login |
| 48 | + } catch { |
| 49 | + prAuthorCache[prNumber] = null |
| 50 | + return null |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +// This runs after the "ci: Version Packages" PR is merged, so HEAD is the |
| 55 | +// release commit. |
| 56 | +const releaseLogs = execSync( |
| 57 | + 'git log --oneline --grep="^ci: Version Packages" --grep="^ci: changeset release" --format=%H', |
| 58 | +) |
| 59 | + .toString() |
| 60 | + .trim() |
| 61 | + .split('\n') |
| 62 | + .filter(Boolean) |
| 63 | + |
| 64 | +const currentRelease = releaseLogs[0] || 'HEAD' |
| 65 | +const previousRelease = releaseLogs[1] |
| 66 | + |
| 67 | +const bumpedPackages = [] |
| 68 | +for (const workspaceDir of workspaceDirs) { |
| 69 | + const absWorkspaceDir = path.join(rootDir, workspaceDir) |
| 70 | + const pkgJsonPaths = globSync('*/package.json', { cwd: absWorkspaceDir }) |
| 71 | + |
| 72 | + for (const relPath of pkgJsonPaths) { |
| 73 | + const fullPath = path.join(absWorkspaceDir, relPath) |
| 74 | + const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8')) |
| 75 | + if (currentPkg.private) continue |
| 76 | + |
| 77 | + const repoRelPath = `${workspaceDir}/${relPath}` |
| 78 | + if (previousRelease) { |
| 79 | + try { |
| 80 | + const prevContent = execFileSync( |
| 81 | + 'git', |
| 82 | + ['show', `${previousRelease}:${repoRelPath}`], |
| 83 | + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }, |
| 84 | + ) |
| 85 | + const prevPkg = JSON.parse(prevContent) |
| 86 | + if (prevPkg.version !== currentPkg.version) { |
| 87 | + bumpedPackages.push({ |
| 88 | + name: currentPkg.name, |
| 89 | + version: currentPkg.version, |
| 90 | + prevVersion: prevPkg.version, |
| 91 | + dir: path.dirname(repoRelPath), |
| 92 | + }) |
| 93 | + } |
| 94 | + } catch { |
| 95 | + bumpedPackages.push({ |
| 96 | + name: currentPkg.name, |
| 97 | + version: currentPkg.version, |
| 98 | + prevVersion: null, |
| 99 | + dir: path.dirname(repoRelPath), |
| 100 | + }) |
| 101 | + } |
| 102 | + } else { |
| 103 | + bumpedPackages.push({ |
| 104 | + name: currentPkg.name, |
| 105 | + version: currentPkg.version, |
| 106 | + prevVersion: null, |
| 107 | + dir: path.dirname(repoRelPath), |
| 108 | + }) |
| 109 | + } |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +bumpedPackages.sort((a, b) => a.name.localeCompare(b.name)) |
| 114 | + |
| 115 | +const rangeFrom = previousRelease || `${currentRelease}~1` |
| 116 | +const rawLog = execSync( |
| 117 | + `git log ${rangeFrom}..${currentRelease} --pretty=format:"%h %ae %s" --no-merges`, |
| 118 | + { encoding: 'utf-8' }, |
| 119 | +).trim() |
| 120 | + |
| 121 | +const typeOrder = [ |
| 122 | + 'breaking', |
| 123 | + 'feat', |
| 124 | + 'fix', |
| 125 | + 'perf', |
| 126 | + 'refactor', |
| 127 | + 'docs', |
| 128 | + 'chore', |
| 129 | + 'test', |
| 130 | + 'ci', |
| 131 | +] |
| 132 | +const typeLabels = { |
| 133 | + breaking: 'Breaking Changes', |
| 134 | + feat: 'Features', |
| 135 | + fix: 'Fix', |
| 136 | + perf: 'Performance', |
| 137 | + refactor: 'Refactor', |
| 138 | + docs: 'Documentation', |
| 139 | + chore: 'Chore', |
| 140 | + test: 'Tests', |
| 141 | + ci: 'CI', |
| 142 | +} |
| 143 | +const typeIndex = (type) => { |
| 144 | + const index = typeOrder.indexOf(type) |
| 145 | + return index === -1 ? 99 : index |
| 146 | +} |
| 147 | + |
| 148 | +const groups = {} |
| 149 | +const commits = rawLog ? rawLog.split('\n') : [] |
| 150 | + |
| 151 | +for (const line of commits) { |
| 152 | + const match = line.match(/^(\w+)\s+(\S+)\s+(.*)$/) |
| 153 | + if (!match) continue |
| 154 | + const [, hash, email, subject] = match |
| 155 | + |
| 156 | + if ( |
| 157 | + subject.startsWith('ci: Version Packages') || |
| 158 | + subject.startsWith('ci: changeset release') |
| 159 | + ) { |
| 160 | + continue |
| 161 | + } |
| 162 | + |
| 163 | + const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$/) |
| 164 | + const type = conventionalMatch ? conventionalMatch[1] : 'other' |
| 165 | + const isBreaking = conventionalMatch ? Boolean(conventionalMatch[3]) : false |
| 166 | + const scope = conventionalMatch ? conventionalMatch[2] || '' : '' |
| 167 | + const message = conventionalMatch ? conventionalMatch[4] : subject |
| 168 | + |
| 169 | + if (!['chore', 'feat', 'fix', 'perf', 'refactor', 'build'].includes(type)) { |
| 170 | + continue |
| 171 | + } |
| 172 | + |
| 173 | + const prMatch = message.match(/\(#(\d+)\)/) |
| 174 | + const prNumber = prMatch ? prMatch[1] : null |
| 175 | + |
| 176 | + const bucket = isBreaking ? 'breaking' : type |
| 177 | + if (!groups[bucket]) groups[bucket] = [] |
| 178 | + groups[bucket].push({ hash, email, scope, message, prNumber }) |
| 179 | +} |
| 180 | + |
| 181 | +const sortedTypes = Object.keys(groups).sort( |
| 182 | + (a, b) => typeIndex(a) - typeIndex(b), |
| 183 | +) |
| 184 | + |
| 185 | +let changelogMd = '' |
| 186 | +for (const type of sortedTypes) { |
| 187 | + const label = typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1) |
| 188 | + changelogMd += `### ${label}\n\n` |
| 189 | + |
| 190 | + for (const commit of groups[type]) { |
| 191 | + const scopePrefix = commit.scope ? `${commit.scope}: ` : '' |
| 192 | + const cleanMessage = commit.message.replace(/\s*\(#\d+\)/, '') |
| 193 | + const prRef = commit.prNumber ? ` (#${commit.prNumber})` : '' |
| 194 | + const username = commit.prNumber |
| 195 | + ? await resolveAuthorForPR(commit.prNumber) |
| 196 | + : await resolveUsername(commit.email) |
| 197 | + const authorSuffix = username ? ` by @${username}` : '' |
| 198 | + |
| 199 | + changelogMd += `- ${scopePrefix}${cleanMessage}${prRef} (${commit.hash})${authorSuffix}\n` |
| 200 | + } |
| 201 | + changelogMd += '\n' |
| 202 | +} |
| 203 | + |
| 204 | +if (!changelogMd.trim()) { |
| 205 | + changelogMd = '- No changelog entries\n\n' |
| 206 | +} |
| 207 | + |
| 208 | +const now = new Date() |
| 209 | +const date = now.toISOString().slice(0, 10) |
| 210 | +const time = now.toISOString().slice(11, 16).replace(':', '') |
| 211 | +const tagName = `release-${date}-${time}` |
| 212 | +const titleDate = `${date} ${now.toISOString().slice(11, 16)}` |
| 213 | + |
| 214 | +const isPrerelease = process.argv.includes('--prerelease') |
| 215 | +const isLatest = process.argv.includes('--latest') |
| 216 | + |
| 217 | +const body = `Release ${titleDate} |
| 218 | +
|
| 219 | +## Changes |
| 220 | +
|
| 221 | +${changelogMd} |
| 222 | +## Packages |
| 223 | +
|
| 224 | +${bumpedPackages.map((pkg) => `- ${pkg.name}@${pkg.version}`).join('\n')} |
| 225 | +` |
| 226 | + |
| 227 | +let tagExists = false |
| 228 | +try { |
| 229 | + execSync(`git rev-parse ${tagName}`, { stdio: 'ignore' }) |
| 230 | + tagExists = true |
| 231 | +} catch { |
| 232 | + // Tag does not exist yet. |
| 233 | +} |
| 234 | + |
| 235 | +if (!tagExists) { |
| 236 | + execSync(`git tag -a -m "${tagName}" ${tagName}`) |
| 237 | + execSync('git push --tags') |
| 238 | +} |
| 239 | + |
| 240 | +const prereleaseFlag = isPrerelease ? '--prerelease' : '' |
| 241 | +const latestFlag = isLatest ? ' --latest' : '' |
| 242 | +const tmpFile = path.join(tmpdir(), `release-notes-${tagName}.md`) |
| 243 | +fs.writeFileSync(tmpFile, body) |
| 244 | + |
| 245 | +try { |
| 246 | + execSync( |
| 247 | + `gh release create ${tagName} ${prereleaseFlag} --title "Release ${titleDate}" --notes-file ${tmpFile}${latestFlag}`, |
| 248 | + { stdio: 'inherit' }, |
| 249 | + ) |
| 250 | + console.info(`GitHub release ${tagName} created.`) |
| 251 | +} catch (error) { |
| 252 | + if (!tagExists) { |
| 253 | + console.info(`Release creation failed, cleaning up tag ${tagName}...`) |
| 254 | + try { |
| 255 | + execSync(`git push --delete origin ${tagName}`, { stdio: 'ignore' }) |
| 256 | + execSync(`git tag -d ${tagName}`, { stdio: 'ignore' }) |
| 257 | + } catch { |
| 258 | + // Best effort cleanup. |
| 259 | + } |
| 260 | + } |
| 261 | + throw error |
| 262 | +} finally { |
| 263 | + fs.unlinkSync(tmpFile) |
| 264 | +} |
0 commit comments