mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d2f68532d | ||
|
|
27ad453f2d |
@@ -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. -->',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user