[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
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}