Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 1d2f68532d fix: caching 2026-04-29 19:50:21 +01:00
Calum H. (IMB11) 27ad453f2d feat: changes to chglog system 2026-04-29 19:44:34 +01:00
11 changed files with 954 additions and 43 deletions
+10
View File
@@ -26,11 +26,21 @@ jobs:
const sections = ['### Added', '', '### Changed', '', '### Deprecated', '', '### Removed', '', '### Fixed', '', '### Security'].join('\n');
const productBlock = (name) => `<details>\n<summary>${name}</summary>\n\n${sections}\n\n</details>`;
const bakeStatus = [
'<!-- changelog:bake-status -->',
'> [!NOTE]',
'> Changelog bake status:',
'> - [ ] App <!-- changelog:product:app -->',
'> - [ ] Website <!-- changelog:product:web -->',
'> - [ ] Hosting <!-- changelog:product:hosting -->',
].join('\n');
const template = [
marker,
'## Pull request changelog',
'',
bakeStatus,
'',
'<!-- Fill in the changelog under each product area this PR affects.',
' Empty sections are ignored. Leave a product collapsed/empty',
' if it doesn\'t apply. -->',
+55
View File
@@ -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<string, AppRelease>,
): 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)
})
}
@@ -1,26 +1,31 @@
<script setup lang="ts">
import { ChevronLeftIcon } from '@modrinth/assets'
import { getChangelog } from '@modrinth/blog'
import { ChangelogEntry, Timeline } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import {
findChangelogEntry,
resolveChangelogEntries,
type AppRelease,
} from '~/helpers/changelog'
const route = useRoute()
const { data: appReleases, suspense: appReleasesSuspense } = useQuery({
queryKey: ['changelog', 'app-releases'],
queryFn: () => $fetch<AppRelease[]>('/api/changelog/app-releases'),
})
await appReleasesSuspense().catch(() => {})
const changelogEntries = computed(() => resolveChangelogEntries(appReleases.value ?? []))
const changelogEntry = computed(() =>
route.params.date
? getChangelog().find((x) => {
if (x.product === route.params.product) {
if (x.version && x.version === route.params.date) {
return x
} else if (x.date.unix() === Number(route.params.date as string)) {
return x
}
}
return undefined
})
route.params.date && route.params.product
? findChangelogEntry(changelogEntries.value, route.params.product, route.params.date)
: undefined,
)
const isFirst = computed(() => changelogEntry.value?.date === getChangelog()[0].date)
const isFirst = computed(() => changelogEntry.value === changelogEntries.value[0])
if (!changelogEntry.value) {
throw createError({ statusCode: 404, statusMessage: 'Version not found' })
@@ -1,12 +1,22 @@
<script setup lang="ts">
import { getChangelog, type Product } from '@modrinth/blog'
import type { Product } from '@modrinth/blog'
import { ChangelogEntry, NavTabs } from '@modrinth/ui'
import Timeline from '@modrinth/ui/src/components/base/Timeline.vue'
import { useQuery } from '@tanstack/vue-query'
import { resolveChangelogEntries, type AppRelease } from '~/helpers/changelog'
const route = useRoute()
const filter = ref<Product | undefined>(undefined)
const allChangelogEntries = ref(getChangelog())
const { data: appReleases, suspense: appReleasesSuspense } = useQuery({
queryKey: ['changelog', 'app-releases'],
queryFn: () => $fetch<AppRelease[]>('/api/changelog/app-releases'),
})
onServerPrefetch(async () => {
await appReleasesSuspense().catch(() => {})
})
function updateFilter() {
if (route.query.filter) {
@@ -24,7 +34,9 @@ watch(
)
const changelogEntries = computed(() =>
allChangelogEntries.value.filter((x) => !filter.value || x.product === filter.value),
resolveChangelogEntries(appReleases.value ?? []).filter(
(entry) => !filter.value || entry.product === filter.value,
),
)
</script>
@@ -54,7 +66,7 @@ const changelogEntries = computed(() =>
<Timeline fade-out-end>
<ChangelogEntry
v-for="(entry, index) in changelogEntries"
:key="entry.date"
:key="`${entry.product}-${entry.version ?? entry.date.unix()}`"
:entry="entry"
:first="index === 0"
:show-type="filter === undefined"
@@ -0,0 +1,65 @@
const CACHE_MAX_AGE = 60 * 30
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/modrinth/code/releases'
const PAGE_SIZE = 100
interface GitHubRelease {
tag_name: string
html_url: string
published_at: string | null
created_at: string
}
export interface AppRelease {
version: string
publishedAt: string
url: string
}
export default defineCachedEventHandler(
async (): Promise<AppRelease[]> => {
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',
},
)
+2
View File
@@ -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": {
+14 -4
View File
@@ -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<ChangelogEntry, 'date'> & {
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
+2 -2
View File
@@ -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) {
+157 -20
View File
@@ -6,6 +6,8 @@ import * as path from 'path'
type Product = 'web' | 'app' | 'hosting'
const CHANGELOG_MARKER = '<!-- changelog -->'
const BAKE_STATUS_MARKER = '<!-- changelog:bake-status -->'
const LEGACY_BAKED_NOTICE = '> This changelog has been baked.'
const SECTION_ORDER = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const
const SECTION_HEADERS: Record<string, string> = {
added: '## Added',
@@ -22,6 +24,18 @@ const PRODUCT_SUMMARY_MAP: Record<string, Product> = {
Hosting: 'hosting',
}
const PRODUCT_LABELS: Record<Product, string> = {
app: 'App',
web: 'Website',
hosting: 'Hosting',
}
const PRODUCT_BAKE_MARKERS: Record<Product, string> = {
app: '<!-- changelog:product:app -->',
web: '<!-- changelog:product:web -->',
hosting: '<!-- changelog:product: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<Product> {
if (body.includes(LEGACY_BAKED_NOTICE)) {
return new Set<Product>(['app', 'web', 'hosting'])
}
const baked = new Set<Product>()
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<Product>): 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<Product>): 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<Product>()
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<Product, Map<string, string[]>>()
const detailsRegex = /<details>\s*<summary>(.*?)<\/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(/<!--[\s\S]*?-->/g, '').trim()
const firstSection = content.search(/^### /m)
@@ -242,9 +350,18 @@ async function fetchBotComment(token: string, prNumber: number): Promise<Comment
return { id: comment.id, body: comment.body }
}
async function markCommentBaked(token: string, commentId: number, currentBody: string): Promise<void> {
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<Product>,
): Promise<void> {
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<Product>(args.version ? ['app', 'web', 'hosting'] : ['web', 'hosting'])
let prs: PRInfo[]
@@ -321,7 +439,8 @@ async function main() {
}
const allEntries = new Map<Product, { sections: Map<string, string[]>; prNumber: number }[]>()
const processedComments: { commentId: number; body: string }[] = []
const processedComments = new Map<number, { body: string; products: Set<Product> }>()
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 })
}
processedComments.push({ commentId: comment.id, body: comment.body })
const processedComment = processedComments.get(comment.id) ?? {
body: comment.body,
products: new Set<Product>(),
}
processedComment.products.add(product)
processedComments.set(comment.id, processedComment)
}
}
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()
+172
View File
@@ -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()
+443
View File
@@ -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<boolean> {
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<T>(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<GhRunListItem[]>([
'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<GhRunView>([
'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<void> {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function waitForWorkflow(workflow: string, tag: string, event: string, label: string): Promise<void> {
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<void> {
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)
})