diff --git a/.github/workflows/changelog-comment.yml b/.github/workflows/changelog-comment.yml
index b68e72c86e..59c8ea9daf 100644
--- a/.github/workflows/changelog-comment.yml
+++ b/.github/workflows/changelog-comment.yml
@@ -26,11 +26,21 @@ jobs:
const sections = ['### Added', '', '### Changed', '', '### Deprecated', '', '### Removed', '', '### Fixed', '', '### Security'].join('\n');
const productBlock = (name) => `\n${name}
\n\n${sections}\n\n `;
+ const bakeStatus = [
+ '',
+ '> [!NOTE]',
+ '> Changelog bake status:',
+ '> - [ ] App ',
+ '> - [ ] Website ',
+ '> - [ ] Hosting ',
+ ].join('\n');
const template = [
marker,
'## Pull request changelog',
'',
+ bakeStatus,
+ '',
'',
diff --git a/apps/frontend/src/helpers/changelog.ts b/apps/frontend/src/helpers/changelog.ts
new file mode 100644
index 0000000000..d0cd5b7d77
--- /dev/null
+++ b/apps/frontend/src/helpers/changelog.ts
@@ -0,0 +1,55 @@
+import { getChangelog, type ChangelogEntry, type Product, type VersionEntry } from '@modrinth/blog'
+import dayjs from 'dayjs'
+
+export interface AppRelease {
+ version: string
+ publishedAt: string
+ url: string
+}
+
+function resolveChangelogEntry(
+ entry: ChangelogEntry,
+ appReleaseByVersion: Map,
+): VersionEntry | null {
+ if (entry.date) {
+ return entry as VersionEntry
+ }
+
+ if (entry.product !== 'app' || !entry.version) {
+ return null
+ }
+
+ const release = appReleaseByVersion.get(entry.version)
+ if (!release) {
+ return null
+ }
+
+ return {
+ ...entry,
+ date: dayjs(release.publishedAt),
+ }
+}
+
+export function resolveChangelogEntries(appReleases: AppRelease[] = []): VersionEntry[] {
+ const appReleaseByVersion = new Map(appReleases.map((release) => [release.version, release]))
+
+ return getChangelog().flatMap((entry) => {
+ const resolvedEntry = resolveChangelogEntry(entry, appReleaseByVersion)
+ return resolvedEntry ? [resolvedEntry] : []
+ })
+}
+
+export function findChangelogEntry(
+ entries: VersionEntry[],
+ productParam: string | string[],
+ dateParam: string | string[],
+): VersionEntry | undefined {
+ const product = (Array.isArray(productParam) ? productParam[0] : productParam) as Product
+ const date = Array.isArray(dateParam) ? dateParam[0] : dateParam
+
+ return entries.find((entry) => {
+ if (entry.product !== product) return false
+ if (entry.version && entry.version === date) return true
+ return entry.date.unix() === Number(date)
+ })
+}
diff --git a/apps/frontend/src/pages/news/changelog/[product]/[date].vue b/apps/frontend/src/pages/news/changelog/[product]/[date].vue
index 7005fe0ab6..041135637b 100644
--- a/apps/frontend/src/pages/news/changelog/[product]/[date].vue
+++ b/apps/frontend/src/pages/news/changelog/[product]/[date].vue
@@ -1,26 +1,31 @@
@@ -54,7 +66,7 @@ const changelogEntries = computed(() =>
=> {
+ const releases: GitHubRelease[] = []
+ let page = 1
+
+ while (true) {
+ const response = await fetch(`${GITHUB_RELEASES_URL}?per_page=${PAGE_SIZE}&page=${page}`, {
+ headers: {
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'modrinth-changelog',
+ },
+ })
+
+ if (!response.ok) {
+ throw createError({
+ statusCode: 502,
+ message: `GitHub releases request failed with ${response.status}`,
+ })
+ }
+
+ const pageReleases = (await response.json()) as GitHubRelease[]
+ if (!Array.isArray(pageReleases)) {
+ throw createError({ statusCode: 502, message: 'Invalid GitHub releases response' })
+ }
+
+ releases.push(...pageReleases)
+
+ if (pageReleases.length < PAGE_SIZE) {
+ break
+ }
+
+ page++
+ }
+
+ return releases
+ .filter((release) => release.tag_name.startsWith('v'))
+ .map((release) => ({
+ version: release.tag_name.replace(/^v/, ''),
+ publishedAt: release.published_at ?? release.created_at,
+ url: release.html_url,
+ }))
+ },
+ {
+ maxAge: CACHE_MAX_AGE,
+ name: 'changelog-app-releases',
+ getKey: () => 'changelog-app-releases',
+ },
+)
diff --git a/package.json b/package.json
index 79c1175f11..5b32c1b50f 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,8 @@
"icons:add": "pnpm --filter @modrinth/assets icons:add",
"changelog:collect": "node scripts/run.mjs collect-changelog",
"changelog:combine-for-app": "node scripts/run.mjs build-theseus-release-notes",
+ "release:prepare": "node scripts/run.mjs release-prepare",
+ "release:push": "node scripts/run.mjs release-push",
"scripts": "node scripts/run.mjs"
},
"devDependencies": {
diff --git a/packages/blog/changelog.ts b/packages/blog/changelog.ts
index e1e87d5f93..9c608494b5 100644
--- a/packages/blog/changelog.ts
+++ b/packages/blog/changelog.ts
@@ -2,14 +2,22 @@ import dayjs from 'dayjs'
export type Product = 'web' | 'hosting' | 'app'
-export type VersionEntry = {
- date: dayjs.Dayjs
+export type ChangelogEntry = {
+ date?: dayjs.Dayjs
product: Product
version?: string
body: string
}
-const VERSIONS: VersionEntry[] = [
+export type VersionEntry = ChangelogEntry & {
+ date: dayjs.Dayjs
+}
+
+type RawChangelogEntry = Omit & {
+ date?: string
+}
+
+const VERSIONS: ChangelogEntry[] = ([
{
date: `2026-04-29T17:19:44+00:00`,
product: 'app',
@@ -2038,7 +2046,9 @@ Contributed by [IMB11](https://github.com/modrinth/code/pull/1301).`,
### Known Issues
- Backups may occasionally take longer than expected or become stuck. If a backup is unresponsive, please submit a support inquiry, and we'll investigate further.`,
},
-].map((x) => ({ ...x, date: dayjs(x.date) }) as VersionEntry)
+] satisfies RawChangelogEntry[]).map(
+ (x) => ({ ...x, date: x.date ? dayjs(x.date) : undefined }) as ChangelogEntry,
+)
export function getChangelog() {
return VERSIONS
diff --git a/scripts/build-theseus-release-notes.ts b/scripts/build-theseus-release-notes.ts
index d6078e82e7..a13b965bdc 100644
--- a/scripts/build-theseus-release-notes.ts
+++ b/scripts/build-theseus-release-notes.ts
@@ -18,7 +18,7 @@ const REPO_ROOT = join(__dirname, '..')
type Product = 'web' | 'app' | 'hosting'
interface ChangelogEntry {
- date: string
+ date?: string
product: Product
version: string | undefined
body: string
@@ -78,7 +78,7 @@ function parseArgs(argv: string[]): { dryRun: boolean; version: string; outFile:
*/
function parseChangelogEntries(src: string): ChangelogEntry[] {
const entryRe =
- /\{\s*date:\s*`([^`]+)`,\s*product:\s*'(\w+)',(?:\s*version:\s*[`']([^`']+)[`'],)?\s*body:\s*`([\s\S]*?)`,\s*\}/g
+ /\{\s*(?:date:\s*`((?:\\`|[^`])*)`,\s*)?product:\s*'(\w+)',(?:\s*version:\s*[`']([^`']+)[`'],)?\s*body:\s*`((?:\\`|[^`])*)`,\s*\}/g
const entries: ChangelogEntry[] = []
let match: RegExpExecArray | null
while ((match = entryRe.exec(src)) !== null) {
diff --git a/scripts/collect-changelog.ts b/scripts/collect-changelog.ts
index 3a20324d2c..80c99dabfd 100644
--- a/scripts/collect-changelog.ts
+++ b/scripts/collect-changelog.ts
@@ -6,6 +6,8 @@ import * as path from 'path'
type Product = 'web' | 'app' | 'hosting'
const CHANGELOG_MARKER = ''
+const BAKE_STATUS_MARKER = ''
+const LEGACY_BAKED_NOTICE = '> This changelog has been baked.'
const SECTION_ORDER = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const
const SECTION_HEADERS: Record = {
added: '## Added',
@@ -22,6 +24,18 @@ const PRODUCT_SUMMARY_MAP: Record = {
Hosting: 'hosting',
}
+const PRODUCT_LABELS: Record = {
+ app: 'App',
+ web: 'Website',
+ hosting: 'Hosting',
+}
+
+const PRODUCT_BAKE_MARKERS: Record = {
+ app: '',
+ web: '',
+ hosting: '',
+}
+
const GITHUB_API = 'https://api.github.com'
const REPO = 'modrinth/code'
@@ -42,10 +56,12 @@ interface CommentInfo {
function parseArgs(argv: string[]): {
version?: string
dryRun: boolean
+ noBake: boolean
pr?: number
} {
let version: string | undefined
let dryRun = false
+ let noBake = false
let pr: number | undefined
let i = 0
@@ -65,6 +81,9 @@ function parseArgs(argv: string[]): {
} else if (argv[i] === '--dry-run') {
dryRun = true
i++
+ } else if (argv[i] === '--no-bake') {
+ noBake = true
+ i++
} else if (argv[i] === '--pr') {
i++
if (i >= argv.length || argv[i].startsWith('--')) {
@@ -83,7 +102,7 @@ function parseArgs(argv: string[]): {
}
}
- return { version, dryRun, pr }
+ return { version, dryRun, noBake, pr }
}
async function getParser() {
@@ -91,9 +110,97 @@ async function getParser() {
return mod.parser
}
+function getBakedProducts(body: string): Set {
+ if (body.includes(LEGACY_BAKED_NOTICE)) {
+ return new Set(['app', 'web', 'hosting'])
+ }
+
+ const baked = new Set()
+ for (const line of body.split('\n')) {
+ const normalized = line.replace(/^>\s?/, '').trim()
+
+ for (const product of Object.keys(PRODUCT_BAKE_MARKERS) as Product[]) {
+ if (!normalized.includes(PRODUCT_BAKE_MARKERS[product])) continue
+
+ if (/^-\s+\[[xX]\]/.test(normalized)) {
+ baked.add(product)
+ }
+ }
+ }
+
+ return baked
+}
+
+function buildBakeStatusBlock(bakedProducts: Set): string {
+ return [
+ BAKE_STATUS_MARKER,
+ '> [!NOTE]',
+ '> Changelog bake status:',
+ ...(['app', 'web', 'hosting'] as Product[]).map(
+ (product) =>
+ `> - [${bakedProducts.has(product) ? 'x' : ' '}] ${PRODUCT_LABELS[product]} ${PRODUCT_BAKE_MARKERS[product]}`,
+ ),
+ ].join('\n')
+}
+
+function stripLegacyBakeNotice(body: string): string {
+ return body.replace(
+ /^> \[!NOTE\]\n> This changelog has been baked\. Any further edits will not be reflected\.\n\n/,
+ '',
+ )
+}
+
+function setBakeStatusBlock(body: string, bakedProducts: Set): string {
+ const bodyWithoutLegacyNotice = stripLegacyBakeNotice(body)
+ const lines = bodyWithoutLegacyNotice.split('\n')
+ const block = buildBakeStatusBlock(bakedProducts)
+ const statusIndex = lines.findIndex((line) => line.includes(BAKE_STATUS_MARKER))
+
+ if (statusIndex !== -1) {
+ let endIndex = statusIndex + 1
+ const seenProducts = new Set()
+
+ while (endIndex < lines.length) {
+ for (const product of Object.keys(PRODUCT_BAKE_MARKERS) as Product[]) {
+ if (lines[endIndex].includes(PRODUCT_BAKE_MARKERS[product])) {
+ seenProducts.add(product)
+ }
+ }
+
+ endIndex++
+
+ if (seenProducts.size === Object.keys(PRODUCT_BAKE_MARKERS).length) {
+ if (lines[endIndex]?.trim() === '') {
+ endIndex++
+ }
+ break
+ }
+ }
+
+ lines.splice(statusIndex, endIndex - statusIndex, block, '')
+ return lines.join('\n')
+ }
+
+ const markerIndex = lines.findIndex((line) => line.includes(CHANGELOG_MARKER))
+ if (markerIndex === -1) {
+ return [block, '', bodyWithoutLegacyNotice].join('\n')
+ }
+
+ let insertIndex = markerIndex + 1
+ if (lines[insertIndex]?.startsWith('## Pull request changelog')) {
+ insertIndex++
+ if (lines[insertIndex]?.trim() === '') {
+ insertIndex++
+ }
+ }
+ lines.splice(insertIndex, 0, block, '')
+ return lines.join('\n')
+}
+
function parseChangelogComment(body: string, parse: Function): ParsedChangelog | null {
if (!body.includes(CHANGELOG_MARKER)) return null
+ const bakedProducts = getBakedProducts(body)
const entries = new Map>()
const detailsRegex = /\s*(.*?)<\/summary>([\s\S]*?)<\/details>/g
@@ -102,6 +209,7 @@ function parseChangelogComment(body: string, parse: Function): ParsedChangelog |
const summaryLabel = match[1].trim()
const product = PRODUCT_SUMMARY_MAP[summaryLabel]
if (!product) continue
+ if (bakedProducts.has(product)) continue
const content = match[2].replace(//g, '').trim()
const firstSection = content.search(/^### /m)
@@ -242,9 +350,18 @@ async function fetchBotComment(token: string, prNumber: number): Promise {
- const admonition = '> [!NOTE]\n> This changelog has been baked. Any further edits will not be reflected.\n\n'
- const newBody = admonition + currentBody
+async function markCommentProductsBaked(
+ token: string,
+ commentId: number,
+ currentBody: string,
+ products: Set,
+): Promise {
+ const bakedProducts = getBakedProducts(currentBody)
+ for (const product of products) {
+ bakedProducts.add(product)
+ }
+
+ const newBody = setBakeStatusBlock(currentBody, bakedProducts)
await githubFetch(`/repos/${REPO}/issues/comments/${commentId}`, token, {
method: 'PATCH',
@@ -258,11 +375,11 @@ function getCurrentUTCISO(): string {
function generateEntry(product: string, body: string, version?: string): string {
const dateStr = getCurrentUTCISO()
+ const dateLine = product === 'app' && version ? '' : `\t\tdate: \`${dateStr}\`,\n`
const versionLine = version ? `\n\t\tversion: '${version}',` : ''
return `\t{
-\t\tdate: \`${dateStr}\`,
-\t\tproduct: '${product}',${versionLine}
+${dateLine}\t\tproduct: '${product}',${versionLine}
\t\tbody: \`${body}\`,
\t},`
}
@@ -270,7 +387,7 @@ function generateEntry(product: string, body: string, version?: string): string
function insertIntoChangelog(changelogPath: string, entryString: string): void {
const content = fs.readFileSync(changelogPath, 'utf-8')
- const marker = 'const VERSIONS: VersionEntry[] = ['
+ const marker = 'const VERSIONS: ChangelogEntry[] = (['
const markerIndex = content.indexOf(marker)
if (markerIndex === -1) {
@@ -301,6 +418,7 @@ async function main() {
const parse = await getParser()
const rootDir = path.resolve(__dirname, '..')
const changelogPath = path.join(rootDir, 'packages', 'blog', 'changelog.ts')
+ const targetProducts = new Set(args.version ? ['app', 'web', 'hosting'] : ['web', 'hosting'])
let prs: PRInfo[]
@@ -321,7 +439,8 @@ async function main() {
}
const allEntries = new Map; prNumber: number }[]>()
- const processedComments: { commentId: number; body: string }[] = []
+ const processedComments = new Map }>()
+ const unreleasedAppPRs: number[] = []
for (const pr of prs) {
const comment = await fetchBotComment(token, pr.number)
@@ -331,7 +450,7 @@ async function main() {
}
if (comment.body.includes('> This changelog has been baked.')) {
- console.log(chalk.gray(`PR #${pr.number}: already baked, skipping`))
+ console.log(chalk.gray(`PR #${pr.number}: all changelog products already baked, skipping`))
continue
}
@@ -346,13 +465,33 @@ async function main() {
console.log(chalk.cyan(`PR #${pr.number}: ${products.join(', ')} — ${types.join(', ')}`))
for (const [product, sections] of parsed.entries) {
+ if (!targetProducts.has(product)) {
+ if (product === 'app') {
+ unreleasedAppPRs.push(pr.number)
+ }
+ continue
+ }
+
if (!allEntries.has(product)) {
allEntries.set(product, [])
}
allEntries.get(product)!.push({ sections, prNumber: pr.number })
+
+ const processedComment = processedComments.get(comment.id) ?? {
+ body: comment.body,
+ products: new Set(),
+ }
+ processedComment.products.add(product)
+ processedComments.set(comment.id, processedComment)
}
+ }
- processedComments.push({ commentId: comment.id, body: comment.body })
+ if (unreleasedAppPRs.length > 0) {
+ console.log(
+ chalk.yellow(
+ `Unbaked App changelog entries found in PR(s) ${unreleasedAppPRs.join(', ')}. Run with --version to include them in an App release.`,
+ ),
+ )
}
if (allEntries.size === 0) {
@@ -360,12 +499,6 @@ async function main() {
return
}
- const hasApp = allEntries.has('app')
- if (hasApp && !args.version) {
- console.error(chalk.red('--version is required when app changelog entries exist'))
- process.exit(1)
- }
-
const products = [...allEntries.keys()].reverse()
for (const product of products) {
const productEntries = allEntries.get(product)!
@@ -385,10 +518,14 @@ async function main() {
}
if (!args.dryRun) {
- for (const { commentId, body } of processedComments) {
- await markCommentBaked(token, commentId, body)
+ if (args.noBake) {
+ console.log(chalk.yellow('Skipping PR comment bake status updates because --no-bake was passed'))
+ } else {
+ for (const [commentId, { body, products }] of processedComments) {
+ await markCommentProductsBaked(token, commentId, body, products)
+ }
+ console.log(chalk.gray(`Marked ${processedComments.size} comment(s) as baked`))
}
- console.log(chalk.gray(`Marked ${processedComments.length} comment(s) as baked`))
}
console.log()
diff --git a/scripts/release-prepare.ts b/scripts/release-prepare.ts
new file mode 100644
index 0000000000..f039895670
--- /dev/null
+++ b/scripts/release-prepare.ts
@@ -0,0 +1,172 @@
+import { execFileSync, spawnSync } from 'child_process'
+import chalk from 'chalk'
+
+const CHANGELOG_PATH = 'packages/blog/changelog.ts'
+
+interface Args {
+ version?: string
+ dryRun: boolean
+}
+
+function parseArgs(argv: string[]): Args {
+ let version: string | undefined
+ let dryRun = false
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i]
+ if (arg === '--') {
+ continue
+ }
+ if (arg === '--version') {
+ const value = argv[++i]
+ if (!value || value.startsWith('--')) {
+ console.error(chalk.red('--version requires a value'))
+ process.exit(1)
+ }
+ version = value.replace(/^v/, '')
+ continue
+ }
+ if (arg === '--dry-run') {
+ dryRun = true
+ continue
+ }
+
+ console.error(chalk.red(`Unknown argument: ${arg}`))
+ process.exit(1)
+ }
+
+ return { version, dryRun }
+}
+
+function git(args: string[]): string {
+ return execFileSync('git', args, { encoding: 'utf-8' }).trim()
+}
+
+function gitInherit(args: string[]): void {
+ execFileSync('git', args, { stdio: 'inherit' })
+}
+
+function ensureMainBranch(dryRun: boolean): boolean {
+ const branch = git(['branch', '--show-current'])
+ if (branch !== 'main') {
+ if (dryRun) {
+ console.warn(
+ chalk.yellow(
+ `Dry run: release:prepare normally runs from main, currently on ${branch || 'detached HEAD'}`,
+ ),
+ )
+ return false
+ }
+
+ console.error(chalk.red(`release:prepare must be run from main, currently on ${branch || 'detached HEAD'}`))
+ process.exit(1)
+ }
+
+ return true
+}
+
+function ensureCleanWorktree(dryRun: boolean): void {
+ const status = git(['status', '--porcelain=v1'])
+ if (status) {
+ if (dryRun) {
+ console.warn(chalk.yellow('Dry run: release:prepare normally requires a clean worktree'))
+ console.warn(chalk.gray(status))
+ return
+ }
+
+ console.error(chalk.red('release:prepare requires a clean worktree'))
+ console.error(status)
+ process.exit(1)
+ }
+}
+
+function ensureMainMatchesOrigin(): void {
+ gitInherit(['fetch', 'origin', '--tags'])
+
+ const head = git(['rev-parse', 'HEAD'])
+ const originMain = git(['rev-parse', 'origin/main'])
+ if (head !== originMain) {
+ console.error(chalk.red('main must match origin/main before preparing a release'))
+ console.error(chalk.gray(`HEAD: ${head}`))
+ console.error(chalk.gray(`origin/main: ${originMain}`))
+ process.exit(1)
+ }
+}
+
+function ensureTagDoesNotExist(version: string): void {
+ const tag = `v${version}`
+
+ try {
+ git(['rev-parse', '--verify', `refs/tags/${tag}`])
+ console.error(chalk.red(`Tag ${tag} already exists locally`))
+ process.exit(1)
+ } catch {
+ // Missing local tag is expected.
+ }
+
+ const remote = spawnSync('git', ['ls-remote', '--exit-code', '--tags', 'origin', `refs/tags/${tag}`], {
+ stdio: 'ignore',
+ })
+ if (remote.status === 0) {
+ console.error(chalk.red(`Tag ${tag} already exists on origin`))
+ process.exit(1)
+ }
+ if (remote.status !== 2) {
+ console.error(chalk.red(`Could not check remote tag ${tag}`))
+ process.exit(remote.status ?? 1)
+ }
+}
+
+function runCollector(args: Args): void {
+ const collectorArgs = ['scripts/run.mjs', 'collect-changelog']
+ if (args.version) {
+ collectorArgs.push('--version', args.version)
+ }
+ if (args.dryRun) {
+ collectorArgs.push('--no-bake')
+ }
+
+ const result = spawnSync(process.execPath, collectorArgs, { stdio: 'inherit' })
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1)
+ }
+}
+
+function stageChangelog(): void {
+ gitInherit(['add', CHANGELOG_PATH])
+}
+
+function main(): void {
+ const args = parseArgs(process.argv.slice(2))
+
+ if (args.dryRun) {
+ console.log(chalk.yellow('Dry run: changelog changes will be written and staged, but PR comments will not be baked'))
+ }
+
+ const isMainBranch = ensureMainBranch(args.dryRun)
+ ensureCleanWorktree(args.dryRun)
+ if (args.dryRun && !isMainBranch) {
+ console.warn(chalk.yellow('Dry run: skipping origin/main freshness check on non-main branch'))
+ } else {
+ ensureMainMatchesOrigin()
+ }
+ if (args.version) {
+ ensureTagDoesNotExist(args.version)
+ }
+
+ runCollector(args)
+ stageChangelog()
+
+ const staged = git(['diff', '--cached', '--name-only'])
+ if (!staged.split('\n').filter(Boolean).includes(CHANGELOG_PATH)) {
+ console.log(chalk.yellow('No changelog changes were staged'))
+ return
+ }
+
+ console.log(chalk.green(`Staged ${CHANGELOG_PATH}`))
+ if (args.dryRun) {
+ console.log(chalk.yellow('Dry run complete: review or reset the staged changelog changes when done testing'))
+ }
+}
+
+main()
diff --git a/scripts/release-push.ts b/scripts/release-push.ts
new file mode 100644
index 0000000000..4580345717
--- /dev/null
+++ b/scripts/release-push.ts
@@ -0,0 +1,443 @@
+import { execFileSync, spawnSync } from 'child_process'
+import * as fs from 'fs'
+import { createInterface } from 'readline/promises'
+import { stdin as input, stdout as output } from 'process'
+import chalk from 'chalk'
+
+const CHANGELOG_PATH = 'packages/blog/changelog.ts'
+const BUILD_WORKFLOW = 'theseus-build.yml'
+const RELEASE_WORKFLOW = 'theseus-release.yml'
+const WORKFLOW_POLL_MS = 30_000
+const WORKFLOW_TIMEOUT_MS = 1000 * 60 * 60 * 3
+
+type Product = 'web' | 'app' | 'hosting'
+
+interface Args {
+ dryRun: boolean
+}
+
+interface ParsedChangelogEntry {
+ date?: string
+ product: Product
+ version?: string
+}
+
+interface GhRunListItem {
+ databaseId: number
+ status: string
+ conclusion: string | null
+ url: string
+ headBranch: string
+ event: string
+ createdAt: string
+}
+
+interface GhRunView {
+ status: string
+ conclusion: string | null
+ url: string
+ jobs: Array<{
+ name: string
+ status: string
+ conclusion: string | null
+ steps?: Array<{
+ name: string
+ status: string
+ conclusion: string | null
+ }>
+ }>
+}
+
+function parseArgs(argv: string[]): Args {
+ let dryRun = false
+
+ for (const arg of argv) {
+ if (arg === '--') {
+ continue
+ }
+ if (arg === '--dry-run') {
+ dryRun = true
+ continue
+ }
+
+ console.error(chalk.red(`Unknown argument: ${arg}`))
+ process.exit(1)
+ }
+
+ return { dryRun }
+}
+
+function git(args: string[]): string {
+ return execFileSync('git', args, { encoding: 'utf-8' }).trim()
+}
+
+function gitInherit(args: string[]): void {
+ execFileSync('git', args, { stdio: 'inherit' })
+}
+
+function ensureMainBranch(dryRun: boolean): void {
+ const branch = git(['branch', '--show-current'])
+ if (branch !== 'main') {
+ if (dryRun) {
+ console.warn(
+ chalk.yellow(
+ `Dry run: release:push normally runs from main, currently on ${branch || 'detached HEAD'}`,
+ ),
+ )
+ return
+ }
+
+ console.error(chalk.red(`release:push must be run from main, currently on ${branch || 'detached HEAD'}`))
+ process.exit(1)
+ }
+}
+
+function ensureMainMatchesOrigin(): void {
+ gitInherit(['fetch', 'origin', '--tags'])
+
+ const head = git(['rev-parse', 'HEAD'])
+ const originMain = git(['rev-parse', 'origin/main'])
+ if (head !== originMain) {
+ console.error(chalk.red('main must match origin/main before pushing a release changelog'))
+ console.error(chalk.gray(`HEAD: ${head}`))
+ console.error(chalk.gray(`origin/main: ${originMain}`))
+ process.exit(1)
+ }
+}
+
+function statusPaths(): string[] {
+ const status = git(['status', '--porcelain=v1'])
+ if (!status) return []
+
+ return status.split('\n').map((line) => {
+ const rawPath = line.slice(3).trim()
+ const renamedPath = rawPath.includes(' -> ') ? rawPath.split(' -> ').at(-1)! : rawPath
+ return renamedPath.replace(/^"|"$/g, '')
+ })
+}
+
+function ensureOnlyChangelogChanged(dryRun: boolean): void {
+ const paths = statusPaths()
+ if (paths.length === 0) {
+ console.error(chalk.red('No changelog changes found to push'))
+ process.exit(1)
+ }
+
+ const invalidPaths = paths.filter((changedPath) => changedPath !== CHANGELOG_PATH)
+ if (invalidPaths.length > 0) {
+ if (dryRun) {
+ console.warn(chalk.yellow(`Dry run: release:push normally only allows ${CHANGELOG_PATH} changes`))
+ for (const invalidPath of invalidPaths) {
+ console.warn(chalk.gray(`- ${invalidPath}`))
+ }
+ return
+ }
+
+ console.error(chalk.red(`release:push only commits ${CHANGELOG_PATH}`))
+ for (const invalidPath of invalidPaths) {
+ console.error(chalk.gray(`- ${invalidPath}`))
+ }
+ process.exit(1)
+ }
+}
+
+function parseChangelogEntries(source: string): ParsedChangelogEntry[] {
+ const stringPattern = String.raw`(?:\\` + '`' + String.raw`|[^` + '`' + String.raw`])*`
+ const entryRegex = new RegExp(
+ String.raw`\{\s*(?:date:\s*` +
+ '`' +
+ `(${stringPattern})` +
+ '`' +
+ String.raw`,\s*)?product:\s*'(\w+)',(?:\s*version:\s*['` +
+ '`' +
+ String.raw`]([^'` +
+ '`' +
+ String.raw`]+)['` +
+ '`' +
+ String.raw`],)?\s*body:\s*` +
+ '`' +
+ `(${stringPattern})` +
+ '`' +
+ String.raw`,\s*\}`,
+ 'g',
+ )
+
+ const entries: ParsedChangelogEntry[] = []
+ let match: RegExpExecArray | null
+ while ((match = entryRegex.exec(source)) !== null) {
+ entries.push({
+ date: match[1],
+ product: match[2] as Product,
+ version: match[3],
+ })
+ }
+
+ return entries
+}
+
+function detectNewAppVersion(baseRef: string): string | null {
+ const base = git(['show', `${baseRef}:${CHANGELOG_PATH}`])
+ const current = fs.readFileSync(CHANGELOG_PATH, 'utf-8')
+
+ const baseAppVersions = new Set(
+ parseChangelogEntries(base)
+ .filter((entry) => entry.product === 'app' && entry.version)
+ .map((entry) => entry.version!),
+ )
+ const newAppEntries = parseChangelogEntries(current).filter(
+ (entry) => entry.product === 'app' && entry.version && !baseAppVersions.has(entry.version),
+ )
+
+ if (newAppEntries.length === 0) {
+ return null
+ }
+
+ if (newAppEntries.length > 1) {
+ console.error(chalk.red('release:push found multiple new App changelog entries'))
+ for (const entry of newAppEntries) {
+ console.error(chalk.gray(`- ${entry.version}`))
+ }
+ process.exit(1)
+ }
+
+ return newAppEntries[0].version!
+}
+
+function ensureTagDoesNotExist(version: string): void {
+ const tag = `v${version}`
+
+ try {
+ git(['rev-parse', '--verify', `refs/tags/${tag}`])
+ console.error(chalk.red(`Tag ${tag} already exists locally`))
+ process.exit(1)
+ } catch {
+ // Missing local tag is expected.
+ }
+
+ const remote = spawnSync('git', ['ls-remote', '--exit-code', '--tags', 'origin', `refs/tags/${tag}`], {
+ stdio: 'ignore',
+ })
+ if (remote.status === 0) {
+ console.error(chalk.red(`Tag ${tag} already exists on origin`))
+ process.exit(1)
+ }
+ if (remote.status !== 2) {
+ console.error(chalk.red(`Could not check remote tag ${tag}`))
+ process.exit(remote.status ?? 1)
+ }
+}
+
+async function confirmTagPush(version: string): Promise {
+ const tag = `v${version}`
+ const rl = createInterface({ input, output })
+ try {
+ const answer = await rl.question(`Create and push app release tag ${tag}? [y/N] `)
+ return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes'
+ } finally {
+ rl.close()
+ }
+}
+
+function runGhJson(args: string[]): T {
+ const output = execFileSync('gh', args, { encoding: 'utf-8' }).trim()
+ return JSON.parse(output || 'null') as T
+}
+
+function ensureGhAvailable(): void {
+ try {
+ execFileSync('gh', ['--version'], { stdio: 'ignore' })
+ } catch {
+ console.error(chalk.red('GitHub CLI is required to wait for the app release workflows'))
+ console.error(chalk.gray('Install gh and run `gh auth login`, then re-run release:push if needed.'))
+ process.exit(1)
+ }
+}
+
+function findWorkflowRun(workflow: string, tag: string, event: string): GhRunListItem | null {
+ const runs = runGhJson([
+ 'run',
+ 'list',
+ '--workflow',
+ workflow,
+ '--json',
+ 'databaseId,status,conclusion,url,headBranch,event,createdAt',
+ '--limit',
+ '20',
+ ])
+
+ const matches = runs
+ .filter((run) => run.headBranch === tag && run.event === event)
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+
+ return matches[0] ?? null
+}
+
+function viewWorkflowRun(runId: number): GhRunView {
+ return runGhJson([
+ 'run',
+ 'view',
+ String(runId),
+ '--json',
+ 'status,conclusion,url,jobs',
+ ])
+}
+
+function formatDuration(ms: number): string {
+ const totalSeconds = Math.floor(ms / 1000)
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
+}
+
+function describeRun(run: GhRunView): string {
+ const activeJob =
+ run.jobs.find((job) => job.status === 'in_progress') ??
+ run.jobs.find((job) => job.status === 'queued') ??
+ run.jobs.find((job) => job.status !== 'completed')
+
+ if (!activeJob) {
+ return 'completed'
+ }
+
+ const activeStep =
+ activeJob.steps?.find((step) => step.status === 'in_progress') ??
+ activeJob.steps?.find((step) => step.status === 'queued') ??
+ activeJob.steps?.find((step) => step.status !== 'completed')
+
+ return activeStep ? `${activeJob.name} / ${activeStep.name}` : activeJob.name
+}
+
+async function sleep(ms: number): Promise {
+ await new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+async function waitForWorkflow(workflow: string, tag: string, event: string, label: string): Promise {
+ const startedAt = Date.now()
+ let runId: number | null = null
+ let runUrl: string | null = null
+
+ while (Date.now() - startedAt < WORKFLOW_TIMEOUT_MS) {
+ const elapsed = formatDuration(Date.now() - startedAt)
+
+ if (!runId) {
+ const run = findWorkflowRun(workflow, tag, event)
+ if (run) {
+ runId = run.databaseId
+ runUrl = run.url
+ console.log(chalk.cyan(`[${elapsed}] ${label}: found run ${runUrl}`))
+ } else {
+ console.log(chalk.gray(`[${elapsed}] ${label}: waiting for workflow run`))
+ await sleep(WORKFLOW_POLL_MS)
+ continue
+ }
+ }
+
+ const run = viewWorkflowRun(runId)
+ runUrl = run.url || runUrl
+
+ if (run.status === 'completed') {
+ if (run.conclusion === 'success') {
+ console.log(chalk.green(`[${elapsed}] ${label}: completed successfully`))
+ return
+ }
+
+ console.error(chalk.red(`[${elapsed}] ${label}: completed with ${run.conclusion ?? 'unknown result'}`))
+ if (runUrl) {
+ console.error(chalk.gray(runUrl))
+ }
+ process.exit(1)
+ }
+
+ console.log(chalk.cyan(`[${elapsed}] ${label}: ${run.status} - ${describeRun(run)}`))
+ await sleep(WORKFLOW_POLL_MS)
+ }
+
+ console.error(chalk.red(`${label} did not finish within ${formatDuration(WORKFLOW_TIMEOUT_MS)}`))
+ if (runUrl) {
+ console.error(chalk.gray(runUrl))
+ }
+ process.exit(1)
+}
+
+function playChime(): void {
+ process.stdout.write('\u0007')
+
+ if (process.platform === 'darwin') {
+ spawnSync('afplay', ['/System/Library/Sounds/Glass.aiff'], { stdio: 'ignore' })
+ }
+}
+
+function printDryRunPlan(appVersion: string | null): void {
+ const commitMessage = appVersion ? `Add changelog for ${appVersion}` : 'Add changelog entries'
+
+ console.log(chalk.yellow('Dry run: no git add, commit, tag, push, or workflow polling will run'))
+ console.log(chalk.gray(`Would stage ${CHANGELOG_PATH}`))
+ console.log(chalk.gray(`Would commit with message: ${commitMessage}`))
+ console.log(chalk.gray('Would push main to origin/main'))
+
+ if (appVersion) {
+ const tag = `v${appVersion}`
+ console.log(chalk.gray(`Would ask to create and push annotated tag ${tag}`))
+ console.log(chalk.gray(`Would wait for ${BUILD_WORKFLOW} and ${RELEASE_WORKFLOW}`))
+ }
+
+ console.log()
+ console.log(chalk.green('When ready:'))
+ console.log('git push origin main:prod')
+}
+
+async function main(): Promise {
+ const args = parseArgs(process.argv.slice(2))
+
+ ensureMainBranch(args.dryRun)
+ ensureOnlyChangelogChanged(args.dryRun)
+ if (!args.dryRun) {
+ ensureMainMatchesOrigin()
+ }
+
+ const baseRef = args.dryRun ? 'HEAD' : 'origin/main'
+ const appVersion = detectNewAppVersion(baseRef)
+ if (appVersion && !args.dryRun) {
+ ensureTagDoesNotExist(appVersion)
+ }
+
+ if (args.dryRun) {
+ printDryRunPlan(appVersion)
+ return
+ }
+
+ const commitMessage = appVersion ? `Add changelog for ${appVersion}` : 'Add changelog entries'
+ gitInherit(['add', CHANGELOG_PATH])
+ gitInherit(['commit', '-m', commitMessage])
+ gitInherit(['push', 'origin', 'main'])
+
+ if (appVersion) {
+ const shouldPushTag = await confirmTagPush(appVersion)
+ if (!shouldPushTag) {
+ console.log(chalk.yellow('Skipped app release tag creation'))
+ console.log()
+ console.log(chalk.green('When ready:'))
+ console.log('git push origin main:prod')
+ return
+ }
+
+ ensureGhAvailable()
+
+ const tag = `v${appVersion}`
+ gitInherit(['tag', '-a', tag, '-m', tag])
+ gitInherit(['push', 'origin', 'tag', tag])
+
+ await waitForWorkflow(BUILD_WORKFLOW, tag, 'push', 'App build')
+ await waitForWorkflow(RELEASE_WORKFLOW, tag, 'workflow_run', 'App release')
+ playChime()
+ }
+
+ console.log()
+ console.log(chalk.green('When ready:'))
+ console.log('git push origin main:prod')
+}
+
+main().catch((error) => {
+ console.error(error)
+ process.exit(1)
+})