[READ-ONLY] Mirror of https://github.com/danielroe/uppt. A composite GitHub Action that turns conventional commits into a draft release PR, tags the PR on merge, and stages publishing to npm via OIDC
github-action npm npmjs oidc staged-publish trusted-publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

uppt / scripts / update-changelog.ts
23 kB 697 lines
1// Zero-dependency release-PR updater. 2// 3// Reads the conventional commits since the latest tag, decides the next 4// semver bump, and creates or updates a draft "release PR" against the 5// default branch. The PR body is a generated changelog plus a contributor 6// list pulled from the GitHub API. 7// 8// Env: 9// GITHUB_TOKEN required for PR create/update; optional for read-only 10// contributor + PR lookups (public endpoints work 11// unauthenticated against public repos, just with a 12// 60 req/hr ceiling). 13// GITHUB_REPOSITORY "owner/repo" (set automatically inside Actions) 14// DRY_RUN if set, skip git push and GitHub writes 15// RELEASE_BASE override base branch (default: current branch) 16// PACKAGES newline-separated list of publishable workspace 17// paths/globs; when set, the release bumps every 18// resolved workspace's package.json in lockstep 19 20import process from 'node:process' 21import { execFileSync } from 'node:child_process' 22import { Buffer } from 'node:buffer' 23import { readFileSync } from 'node:fs' 24import { resolve } from 'node:path' 25import { makePkgFormatter } from './pkg-format.ts' 26 27import { resolveCurrentVersion, resolveWorkspaces, type Workspace } from './_workspaces.ts' 28 29interface Commit { 30 shortHash: string 31 message: string 32 type: string 33 scope: string 34 description: string 35 isBreaking: boolean 36 author: { name: string, email: string } 37 references: string[] 38} 39 40interface Contributor { 41 name: string 42 username: string 43 isFirstTime: boolean 44} 45 46const TYPE_TITLES: Record<string, string> = { 47 feat: '🚀 Enhancements', 48 perf: '🔥 Performance', 49 fix: '🩹 Fixes', 50 refactor: '💅 Refactors', 51 docs: '📖 Documentation', 52 build: '📦 Build', 53 types: '🌊 Types', 54 chore: '🏡 Chore', 55 examples: '🏀 Examples', 56 test: '✅ Tests', 57 style: '🎨 Styles', 58 ci: '🤖 CI', 59} 60 61const KNOWN_TYPES = new Set(Object.keys(TYPE_TITLES)) 62 63const git = (...args: string[]) => 64 execFileSync('git', args, { encoding: 'utf8' }).trim() 65 66function getRepo (): { owner: string, repo: string } { 67 const env = process.env.GITHUB_REPOSITORY 68 if (env && env.includes('/')) { 69 const [owner, repo] = env.split('/') 70 return { owner: owner!, repo: repo! } 71 } 72 const url = git('remote', 'get-url', 'origin') 73 const match = url.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?$/) 74 if (!match) throw new Error(`Cannot parse repo from remote url: ${url}`) 75 return { owner: match[1]!, repo: match[2]! } 76} 77 78function getCurrentBranch (): string { 79 return process.env.RELEASE_BASE || git('rev-parse', '--abbrev-ref', 'HEAD') 80} 81 82interface Tag { name: string, ref: string } 83 84function getLatestTag (): Tag | null { 85 // Pick the most recent semver-shaped tag by creation date. We deliberately 86 // don't use `git describe`, which only finds tags reachable from HEAD; the 87 // previous release tag isn't always an ancestor of HEAD (e.g. release 88 // branches that were never merged back). 89 // 90 // We return both the short name (for display / URLs) and the fully 91 // qualified ref (`refs/tags/...`) so subsequent git calls aren't confused 92 // by branches sharing the tag name. 93 try { 94 const stdout = execFileSync( 95 'git', 96 ['for-each-ref', '--sort=-creatordate', '--format=%(refname:strip=2)', 'refs/tags'], 97 { encoding: 'utf8' }, 98 ) 99 const name = stdout.split('\n').map(s => s.trim()).find(t => /^v?\d+\.\d+\.\d+/.test(t)) 100 return name ? { name, ref: `refs/tags/${name}` } : null 101 } catch { 102 return null 103 } 104} 105 106function parseCommit (raw: string): Commit | null { 107 const [shortHash, authorName, authorEmail, subject, body] = raw.split('\x1f') 108 if (!shortHash || !subject) return null 109 110 const header = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/) 111 if (!header) { 112 return { 113 shortHash, 114 message: subject, 115 type: '', 116 scope: '', 117 description: subject, 118 isBreaking: false, 119 author: { name: authorName || '', email: authorEmail || '' }, 120 references: [], 121 } 122 } 123 const [, type, scope = '', bang, rawDescription] = header 124 const isBreaking = Boolean(bang) || /BREAKING[ -]CHANGE/.test(body || '') 125 126 const references: string[] = [] 127 for (const m of (body || '').matchAll(/(?:closes?|fixes?|resolves?)\s+#(\d+)/gi)) { 128 references.push(`#${m[1]}`) 129 } 130 for (const m of subject.matchAll(/\(#(\d+)\)/g)) { 131 references.push(`#${m[1]}`) 132 } 133 // Drop trailing `(#nnn)` PR refs from the description: we'll re-attach them 134 // in the rendered changelog from the deduped `references` list. 135 const description = rawDescription!.replace(/\s*\(#\d+\)\s*$/, '').trim() 136 137 return { 138 shortHash, 139 message: subject, 140 type: type!.toLowerCase(), 141 scope, 142 description, 143 isBreaking, 144 author: { name: authorName || '', email: authorEmail || '' }, 145 references: [...new Set(references)], 146 } 147} 148 149function getCommitsSince (tag: Tag | null): Commit[] { 150 const range = tag ? `${tag.ref}..HEAD` : 'HEAD' 151 const stdout = execFileSync( 152 'git', 153 ['log', range, `--pretty=format:%h%x1f%an%x1f%ae%x1f%s%x1f%b%x1e`], 154 { encoding: 'utf8' }, 155 ) 156 return stdout 157 .split('\x1e') 158 .map(s => s.replace(/^\n/, '')) 159 .filter(Boolean) 160 .map(parseCommit) 161 .filter((c): c is Commit => c !== null) 162} 163 164function determineBump (commits: Commit[]): 'major' | 'minor' | 'patch' { 165 if (commits.some(c => c.isBreaking)) return 'major' 166 if (commits.some(c => c.type === 'feat')) return 'minor' 167 return 'patch' 168} 169 170export function incVersion (version: string, bump: 'major' | 'minor' | 'patch'): string { 171 // uppt does not (yet) model prerelease or build-metadata releases. 172 // Silently rolling `1.2.3-rc.1` forward to `1.2.4` would lose the 173 // prerelease line, which is almost never what a maintainer wants. 174 // Refuse and let them reconcile. 175 const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/) 176 if (!match) { 177 throw new Error( 178 `Cannot bump version "${version}": expected strict "X.Y.Z" semver. uppt does not currently support prerelease or build-metadata versions.`, 179 ) 180 } 181 182 let [, x, y, z] = match.map(Number) as [number, number, number, number, number] 183 184 if (x === 0) { 185 if (bump === 'major') { bump = 'minor' } 186 else if (bump === 'minor') { bump = 'patch' } 187 } 188 189 if (bump === 'major') { x += 1; y = 0; z = 0 } 190 else if (bump === 'minor') { y += 1; z = 0 } 191 else { z += 1 } 192 return `${x}.${y}.${z}` 193} 194 195function formatChangelog ( 196 commits: Commit[], 197 opts: { owner: string, repo: string, fromRef: Tag | null, toRef: string }, 198): string { 199 const grouped = new Map<string, Commit[]>() 200 for (const c of commits) { 201 if (!KNOWN_TYPES.has(c.type)) continue 202 if (c.type === 'chore' && c.scope === 'deps') continue 203 const list = grouped.get(c.type) || [] 204 list.push(c) 205 grouped.set(c.type, list) 206 } 207 208 const lines: string[] = [] 209 if (opts.fromRef) { 210 const compareUrl = `https://github.com/${opts.owner}/${opts.repo}/compare/${opts.fromRef.name}...${opts.toRef}` 211 lines.push(`[compare changes](${compareUrl})`, '') 212 } 213 214 const commitUrl = (sha: string) => 215 `https://github.com/${opts.owner}/${opts.repo}/commit/${sha}` 216 217 for (const type of Object.keys(TYPE_TITLES)) { 218 const items = grouped.get(type) 219 if (!items?.length) continue 220 lines.push(`### ${TYPE_TITLES[type]}`, '') 221 for (const c of items) { 222 const scope = c.scope ? `**${c.scope}:** ` : '' 223 const breaking = c.isBreaking ? '⚠️ ' : '' 224 // Prefer PR references; fall back to a link to the commit itself so 225 // every line is traceable to something on GitHub. 226 const trailer = c.references.length 227 ? ` (${c.references.join(', ')})` 228 : ` ([\`${c.shortHash}\`](${commitUrl(c.shortHash)}))` 229 lines.push(`- ${breaking}${scope}${c.description}${trailer}`) 230 } 231 lines.push('') 232 } 233 return lines.join('\n').trim() 234} 235 236async function gh<T> (path: string, init: RequestInit & { requireAuth?: boolean } = {}): Promise<T> { 237 const { requireAuth, ...rest } = init 238 const token = process.env.GITHUB_TOKEN 239 if (requireAuth && !token) throw new Error('GITHUB_TOKEN is required for this call') 240 const res = await fetch(`https://api.github.com${path}`, { 241 ...rest, 242 headers: { 243 'Accept': 'application/vnd.github+json', 244 'X-GitHub-Api-Version': '2022-11-28', 245 ...(token ? { Authorization: `Bearer ${token}` } : {}), 246 'User-Agent': 'release-pr-updater', 247 ...(rest.body ? { 'Content-Type': 'application/json' } : {}), 248 ...(rest.headers as Record<string, string> | undefined), 249 }, 250 }) 251 if (!res.ok) { 252 throw new Error(`GitHub ${init.method || 'GET'} ${path} -> ${res.status} ${res.statusText}: ${await res.text()}`) 253 } 254 return res.json() as Promise<T> 255} 256 257async function getContributors ( 258 commits: Commit[], 259 repo: { owner: string, repo: string }, 260 cutoff: string | null, 261): Promise<Contributor[]> { 262 const out: Contributor[] = [] 263 const seenEmails = new Set<string>() 264 const seenUsers = new Set<string>() 265 266 for (const commit of commits) { 267 if (commit.author.name === 'renovate[bot]') continue 268 if (seenEmails.has(commit.author.email)) continue 269 seenEmails.add(commit.author.email) 270 271 let login: string | undefined 272 try { 273 const data = await gh<{ author: { login: string } | null }>( 274 `/repos/${repo.owner}/${repo.repo}/commits/${commit.shortHash}`, 275 ) 276 login = data.author?.login 277 } catch { 278 continue 279 } 280 if (!login || seenUsers.has(login)) continue 281 seenUsers.add(login) 282 283 // First-time contributor = no commits authored by them before the cutoff 284 // (the previous release tag's commit date). If we have no previous tag 285 // every contributor is, by definition, first-time. 286 let isFirstTime = true 287 if (cutoff) { 288 try { 289 const prior = await gh<unknown[]>( 290 `/repos/${repo.owner}/${repo.repo}/commits?author=${encodeURIComponent(login)}&until=${encodeURIComponent(cutoff)}&per_page=1`, 291 ) 292 isFirstTime = prior.length === 0 293 } catch { 294 isFirstTime = false 295 } 296 } 297 298 out.push({ name: commit.author.name, username: login, isFirstTime }) 299 } 300 return out 301} 302 303type ReleaseBranchState = 'missing' | 'at-base' | 'has-bump' 304 305async function getReleaseBranchState ( 306 repo: { owner: string, repo: string }, 307 opts: { base: string, branch: string }, 308): Promise<ReleaseBranchState> { 309 let branchHead: string 310 try { 311 const data = await gh<{ commit: { sha: string } }>( 312 `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.branch)}`, 313 ) 314 branchHead = data.commit.sha 315 } catch (err) { 316 if (err instanceof Error && /-> 404\b/.test(err.message)) return 'missing' 317 throw err 318 } 319 320 const baseInfo = await gh<{ commit: { sha: string } }>( 321 `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.base)}`, 322 ) 323 return branchHead === baseInfo.commit.sha ? 'at-base' : 'has-bump' 324} 325 326interface FileToCommit { 327 /** Path relative to the repo root, using forward slashes. */ 328 path: string 329 /** Raw UTF-8 contents to write at that path. */ 330 content: string 331} 332 333/** 334 * Land one atomic commit on `opts.branch` containing every file in 335 * `opts.files`, using the Git Data API. Creates the branch at `opts.base` 336 * if it doesn't exist yet. The resulting commit has the branch's current 337 * tip as its sole parent (or `opts.base`'s tip, if the branch was just 338 * created), so the ref fast-forwards. 339 */ 340async function commitFilesToBranch ( 341 repo: { owner: string, repo: string }, 342 opts: { base: string, branch: string, message: string, files: FileToCommit[] }, 343): Promise<void> { 344 if (!opts.files.length) { 345 throw new Error('commitFilesToBranch: refusing to commit with no files') 346 } 347 348 let parentSha: string 349 try { 350 const branchInfo = await gh<{ commit: { sha: string } }>( 351 `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.branch)}`, 352 { requireAuth: true }, 353 ) 354 parentSha = branchInfo.commit.sha 355 } catch (err) { 356 if (!(err instanceof Error) || !/-> 404\b/.test(err.message)) throw err 357 const baseInfo = await gh<{ commit: { sha: string } }>( 358 `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.base)}`, 359 { requireAuth: true }, 360 ) 361 await gh(`/repos/${repo.owner}/${repo.repo}/git/refs`, { 362 method: 'POST', 363 requireAuth: true, 364 body: JSON.stringify({ 365 ref: `refs/heads/${opts.branch}`, 366 sha: baseInfo.commit.sha, 367 }), 368 }) 369 parentSha = baseInfo.commit.sha 370 } 371 372 const parentCommit = await gh<{ tree: { sha: string } }>( 373 `/repos/${repo.owner}/${repo.repo}/git/commits/${parentSha}`, 374 { requireAuth: true }, 375 ) 376 377 const blobs = await Promise.all(opts.files.map(async (file) => { 378 const blob = await gh<{ sha: string }>( 379 `/repos/${repo.owner}/${repo.repo}/git/blobs`, 380 { 381 method: 'POST', 382 requireAuth: true, 383 body: JSON.stringify({ 384 content: Buffer.from(file.content, 'utf8').toString('base64'), 385 encoding: 'base64', 386 }), 387 }, 388 ) 389 return { path: file.path, sha: blob.sha } 390 })) 391 392 const tree = await gh<{ sha: string }>( 393 `/repos/${repo.owner}/${repo.repo}/git/trees`, 394 { 395 method: 'POST', 396 requireAuth: true, 397 body: JSON.stringify({ 398 base_tree: parentCommit.tree.sha, 399 tree: blobs.map(b => ({ 400 path: b.path, 401 mode: '100644', 402 type: 'blob', 403 sha: b.sha, 404 })), 405 }), 406 }, 407 ) 408 409 const commit = await gh<{ sha: string }>( 410 `/repos/${repo.owner}/${repo.repo}/git/commits`, 411 { 412 method: 'POST', 413 requireAuth: true, 414 body: JSON.stringify({ 415 message: opts.message, 416 tree: tree.sha, 417 parents: [parentSha], 418 }), 419 }, 420 ) 421 422 await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${opts.branch}`, { 423 method: 'PATCH', 424 requireAuth: true, 425 body: JSON.stringify({ sha: commit.sha }), 426 }) 427} 428 429async function isReleaseMergeCommit ( 430 repo: { owner: string, repo: string }, 431 sha: string, 432): Promise<boolean> { 433 // We don't want to update the changelog when a `release/vX.Y.Z` PR is merged. 434 try { 435 const prs = await gh<Array<{ head: { ref: string }, merged_at: string | null }>>( 436 `/repos/${repo.owner}/${repo.repo}/commits/${sha}/pulls`, 437 ) 438 return prs.some(pr => pr.merged_at && pr.head.ref.startsWith('release/v')) 439 } catch { 440 return false 441 } 442} 443 444/** 445 * Build the set of `package.json` files to write in the release commit. 446 * 447 * Single-package mode: just the root, bumped to `newVersion`. 448 * 449 * Monorepo mode: every listed workspace is rewritten to `newVersion`. 450 * The root is included only if its current `version` exactly matches 451 * the lockstep version; otherwise it's left untouched (it might be 452 * `0.0.0`, absent, or deliberately frozen, and none of those are uppt's 453 * business). When independent versioning lands, this is the place that 454 * decides which workspaces get a bump on a given release. 455 */ 456export function buildBumpFileSet (opts: { 457 monorepo: boolean 458 workspaces: Workspace[] 459 rootPkg: Record<string, unknown> 460 rootPkgSource: string 461 currentVersion: string 462 newVersion: string 463}): Array<{ path: string, content: string }> { 464 const files: Array<{ path: string, content: string }> = [] 465 const formatRootPkg = makePkgFormatter(opts.rootPkgSource) 466 467 if (!opts.monorepo) { 468 const updated = { ...opts.rootPkg, version: opts.newVersion } 469 files.push({ path: 'package.json', content: formatRootPkg(updated) }) 470 return files 471 } 472 473 let rootCoveredByWorkspaces = false 474 for (const ws of opts.workspaces) { 475 const wsPkg = { ...ws.pkg, version: opts.newVersion } 476 const path = ws.relDir === '.' ? 'package.json' : `${ws.relDir}/package.json` 477 if (path === 'package.json') rootCoveredByWorkspaces = true 478 files.push({ path, content: makePkgFormatter(ws.source)(wsPkg) }) 479 } 480 481 if (!rootCoveredByWorkspaces && opts.rootPkg.version === opts.currentVersion) { 482 const updated = { ...opts.rootPkg, version: opts.newVersion } 483 files.push({ path: 'package.json', content: formatRootPkg(updated) }) 484 } 485 486 return files 487} 488 489async function main () { 490 const dryRun = Boolean(process.env.DRY_RUN) 491 const repo = getRepo() 492 const baseBranch = getCurrentBranch() 493 494 const headSha = git('rev-parse', 'HEAD') 495 if (await isReleaseMergeCommit(repo, headSha)) { 496 console.log(`HEAD (${headSha.slice(0, 7)}) is the merge of a release PR; skipping.`) 497 return 498 } 499 500 const latestTag = getLatestTag() 501 502 const commits = getCommitsSince(latestTag).filter( 503 c => KNOWN_TYPES.has(c.type) && !(c.type === 'chore' && c.scope === 'deps'), 504 ) 505 506 if (!commits.length) { 507 console.log('No release-worthy commits since', latestTag?.name ?? 'repo root') 508 return 509 } 510 511 const packagesInput = process.env.PACKAGES?.trim() ?? '' 512 const monorepo = packagesInput.length > 0 513 const workspaces: Workspace[] = monorepo 514 ? resolveWorkspaces(process.cwd(), packagesInput) 515 : [] 516 517 const rootPkgPath = resolve(process.cwd(), 'package.json') 518 const rootPkgSource = readFileSync(rootPkgPath, 'utf8') 519 const rootPkg = JSON.parse(rootPkgSource) 520 521 const currentVersion = resolveCurrentVersion(process.cwd(), packagesInput) 522 523 const bump = determineBump(commits) 524 const newVersion = incVersion(currentVersion, bump) 525 const releaseBranch = `release/v${newVersion}` 526 527 const changelog = formatChangelog(commits, { 528 owner: repo.owner, 529 repo: repo.repo, 530 fromRef: latestTag, 531 toRef: releaseBranch, 532 }) 533 534 console.log(`Current: ${currentVersion} -> ${newVersion} (${bump})`) 535 if (monorepo) { 536 console.log(`Workspaces (${workspaces.length}): ${workspaces.map(ws => ws.name).join(', ')}`) 537 } 538 console.log(`Base branch: ${baseBranch}`) 539 console.log(`Release branch: ${releaseBranch}`) 540 console.log(`Commits: ${commits.length}`) 541 542 // Close any open release PRs that don't match the new target version, 543 // scoped to PRs targeting the *same* base branch. Repos with maintenance 544 // branches (e.g. nuxt's `main`, `4.x`, `3.x`) have a release PR per base; 545 // a `feat:` landing on `main` must not close the `4.x`-base PR. The common 546 // single-branch case: a patch PR (`release/v1.0.1`) gets superseded by a 547 // `feat:` that bumps the target to `release/v1.1.0`. We close the stale PR, 548 // lift its preamble (so the maintainer's intro text isn't lost), and 549 // delete its branch. 550 let seedPreamble: string | null = null 551 if (!dryRun && process.env.GITHUB_TOKEN) { 552 const openReleasePRs = await gh<Array<{ number: number, body: string | null, head: { ref: string, repo: { full_name: string } | null }, base: { ref: string }, updated_at: string }>>( 553 `/repos/${repo.owner}/${repo.repo}/pulls?state=open&per_page=100&base=${encodeURIComponent(baseBranch)}&head=${repo.owner}:`, 554 { requireAuth: true }, 555 ) 556 const sameRepo = `${repo.owner}/${repo.repo}` 557 const stale = openReleasePRs 558 .filter(pr => 559 pr.head.repo?.full_name === sameRepo 560 && pr.head.ref.startsWith('release/v') 561 && pr.head.ref !== releaseBranch 562 && pr.base.ref === baseBranch, 563 ) 564 .sort((a, b) => b.updated_at.localeCompare(a.updated_at)) 565 for (const pr of stale) { 566 console.log(`Closing superseded release PR #${pr.number} (${pr.head.ref})`) 567 const preamble = pr.body?.replace(/## 👉 Changelog[\s\S]*$/, '').trim() 568 if (preamble && !seedPreamble) seedPreamble = preamble 569 await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${pr.number}`, { 570 method: 'PATCH', 571 body: JSON.stringify({ state: 'closed' }), 572 requireAuth: true, 573 }) 574 try { 575 await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${pr.head.ref}`, { 576 method: 'DELETE', 577 requireAuth: true, 578 }) 579 } 580 catch (err) { 581 console.warn(` could not delete branch ${pr.head.ref}:`, err) 582 } 583 } 584 } 585 586 587 if (!dryRun) { 588 const state = await getReleaseBranchState(repo, { 589 base: baseBranch, 590 branch: releaseBranch, 591 }) 592 if (state !== 'has-bump') { 593 if (!process.env.GITHUB_TOKEN) { 594 throw new Error('GITHUB_TOKEN is required to create the release branch') 595 } 596 if (state === 'at-base') { 597 console.log(`Branch ${releaseBranch} exists at base HEAD with no bump; recovering by committing.`) 598 } 599 const files = buildBumpFileSet({ 600 monorepo, 601 workspaces, 602 rootPkg, 603 rootPkgSource, 604 currentVersion, 605 newVersion, 606 }) 607 await commitFilesToBranch(repo, { 608 base: baseBranch, 609 branch: releaseBranch, 610 message: `v${newVersion}`, 611 files, 612 }) 613 } 614 } 615 616 const hasToken = Boolean(process.env.GITHUB_TOKEN) 617 if (!hasToken && !dryRun) throw new Error('GITHUB_TOKEN is required to create or update the PR') 618 619 const cutoff = latestTag 620 ? git('log', '-1', '--format=%aI', latestTag.ref) 621 : null 622 623 // Contributor + existing-PR lookups hit public endpoints, so they work 624 // unauthenticated against public repos. We still benefit from a token 625 // (5000 req/h vs 60), but don't require one for previews. 626 const contributors = await getContributors(commits, repo, cutoff) 627 const newContributors = contributors.filter(c => c.isFirstTime) 628 629 const existing = await gh<Array<{ number: number, body: string | null }>>( 630 `/repos/${repo.owner}/${repo.repo}/pulls?head=${repo.owner}:${releaseBranch}&state=open`, 631 ) 632 const currentPR = existing[0] 633 const preamble = currentPR?.body?.replace(/## 👉 Changelog[\s\S]*$/, '').trim() 634 || seedPreamble 635 || `> v${newVersion} is the next ${bump} release.\n>\n> **Timetable**: to be announced.` 636 637 const body = [ 638 preamble, 639 '', 640 '## 👉 Changelog', 641 '', 642 changelog, 643 ...(newContributors.length 644 ? [ 645 '', 646 '### 🎉 New Contributors', 647 '', 648 newContributors.map(c => `- ${c.name} (@${c.username})`).join('\n'), 649 ] 650 : []), 651 '', 652 '### ❤️ Contributors', 653 '', 654 contributors.length 655 ? contributors.map(c => `- ${c.name} (@${c.username})`).join('\n') 656 : '_no contributors yet_', 657 ].join('\n') 658 659 if (dryRun) { 660 console.log('\n--- DRY RUN: PR body ---\n') 661 console.log(body) 662 return 663 } 664 665 if (currentPR) { 666 await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${currentPR.number}`, { 667 method: 'PATCH', 668 body: JSON.stringify({ body }), 669 requireAuth: true, 670 }) 671 console.log(`Updated PR #${currentPR.number}`) 672 } else { 673 const created = await gh<{ number: number, html_url: string }>( 674 `/repos/${repo.owner}/${repo.repo}/pulls`, 675 { 676 method: 'POST', 677 requireAuth: true, 678 body: JSON.stringify({ 679 title: `v${newVersion}`, 680 head: releaseBranch, 681 base: baseBranch, 682 body, 683 draft: true, 684 }), 685 }, 686 ) 687 console.log(`Created PR #${created.number}: ${created.html_url}`) 688 } 689} 690 691// Run as a script, not when imported by tests. 692if (import.meta.url === `file://${process.argv[1]}`) { 693 main().catch((err) => { 694 console.error(err) 695 process.exit(1) 696 }) 697}