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) +})