devex: changelog system (#5309)

* devex: changelog system

* feat: changelog CIs

* feat: web alias for platform + hosting

* feat: upload binaries to gh release

* feat: improve copy text

* fix: release workflow

* fix: changelog CIs + PR health check comment

* fix: action

* fix: comment style

* fix: comment

* fix: remove health

* fix: deploy use Modrinth bot machine account

* feat: new system

* fix: pr comment structure
This commit is contained in:
Calum H.
2026-03-23 17:45:43 +00:00
committed by GitHub
parent 67fd759d9b
commit c09f7fd5e6
16 changed files with 696 additions and 164 deletions

121
.github/workflows/changelog-comment.yml vendored Normal file
View File

@@ -0,0 +1,121 @@
name: Changelog Comment
on:
pull_request:
types: [opened, reopened]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to post the changelog comment on (for testing)'
required: true
type: number
jobs:
comment:
name: Post changelog comment
runs-on: ubuntu-latest
steps:
- name: 💬 Post or update changelog comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
script: |
const marker = '<!-- changelog -->';
const mergedMarker = '<!-- changelog-merged -->';
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 template = [
marker,
'## Pull request changelog',
'',
'<!-- 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. -->',
'',
productBlock('App'),
'',
productBlock('Website'),
'',
productBlock('Hosting'),
].join('\n');
// Resolve PR number from event or workflow_dispatch input
const prNumber = context.payload.pull_request?.number
?? parseInt('${{ github.event.inputs.pr_number }}', 10);
if (!prNumber || isNaN(prNumber)) {
core.setFailed('Could not determine PR number');
return;
}
// Get PR details (need base ref for child PR detection)
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
// Check if bot comment already exists
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existingComment = comments.find(c => c.body.includes(marker));
if (existingComment) {
core.info('Changelog comment already exists, skipping');
return;
}
// Post the template comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: template,
});
core.info(`Posted changelog comment on PR #${prNumber}`);
// Detect child PR: check if this PR's base branch is another open PR's head branch
const baseRef = pr.base.ref;
if (baseRef === 'main' || baseRef === 'prod') {
return;
}
// Look for a parent PR whose head branch matches our base branch
const { data: candidatePRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${baseRef}`,
});
if (candidatePRs.length === 0) {
return;
}
const parentPR = candidatePRs[0];
core.info(`Detected parent PR #${parentPR.number} for child PR #${prNumber}`);
// Add admonition to child PR's changelog comment
const { data: childComments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const childChangelogComment = childComments.find(c => c.body.includes(marker));
if (childChangelogComment && !childChangelogComment.body.includes(mergedMarker)) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: childChangelogComment.id,
body: `${mergedMarker}\n> [!NOTE]\n> This changelog has been merged into the changelog for #${parentPR.number}\n\n${childChangelogComment.body}`,
});
}

View File

@@ -50,14 +50,15 @@ jobs:
uses: peter-evans/find-comment@v3
id: fc
with:
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: Frontend previews
- name: Comment deploy URL on PR
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v5
with:
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |

View File

@@ -1,47 +1,43 @@
name: Modrinth App release
on:
workflow_dispatch:
inputs:
version-tag:
description: Version tag to release to the wide public
type: string
required: true
release-notes:
description: Release notes to include in the Tauri version manifest
default: A new release of the Modrinth App is available!
type: string
required: true
workflow_run:
workflows: ['Modrinth App build']
types: [completed]
jobs:
release:
name: Release Modrinth App
if: >-
github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
env:
VERSION_TAG: ${{ github.event.workflow_run.head_branch }}
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
steps:
- name: 📥 Check out code
uses: actions/checkout@v4
- name: 📥 Download Modrinth App artifacts
uses: dawidd6/action-download-artifact@v11
with:
workflow: theseus-build.yml
workflow_conclusion: success
event: push
branch: ${{ inputs.version-tag }}
branch: ${{ env.VERSION_TAG }}
use_unzip: true
- name: 🛠️ Generate version manifest
env:
VERSION_TAG: ${{ inputs.version-tag }}
RELEASE_NOTES: ${{ inputs.release-notes }}
run: |
# Reference: https://tauri.app/plugin/updater/#server-support
jq -nc \
--arg versionTag "${VERSION_TAG#v}" \
--arg releaseNotes "$RELEASE_NOTES" \
--arg releaseNotes "See the full changelog at https://modrinth.com/news/changelog" \
--rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
--rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
--rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \
@@ -83,7 +79,6 @@ jobs:
- name: 📤 Upload release artifacts
env:
VERSION_TAG: ${{ inputs.version-tag }}
AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }}
@@ -116,3 +111,18 @@ jobs:
done
aws s3 cp updates.json "s3://${AWS_BUCKET}"
- name: 🏷️ Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${VERSION_TAG#v}"
gh release create "$VERSION_TAG" \
--title "Modrinth App ${VERSION}" \
--notes "See the full changelog at https://modrinth.com/news/changelog" \
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION}_x64-setup.exe" \
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/dmg/Modrinth App_${VERSION}_universal.dmg" \
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION}_amd64.AppImage" \
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/deb/Modrinth App_${VERSION}_amd64.deb" \
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/rpm/Modrinth App-${VERSION}-1.x86_64.rpm"

View File

@@ -115,7 +115,7 @@ export default defineNuxtConfig({
await import('./src/templates/docs/index.ts').then((m) => m.default),
)
const blogArticles = await import('@modrinth/blog').then((m) => m.articles)
const { getChangelog } = await import('@modrinth/utils')
const { getChangelog } = await import('@modrinth/blog')
nitroConfig.prerender = nitroConfig.prerender || {}
nitroConfig.prerender.routes = nitroConfig.prerender.routes || []

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ChevronLeftIcon } from '@modrinth/assets'
import { getChangelog } from '@modrinth/blog'
import { ChangelogEntry, Timeline } from '@modrinth/ui'
import { getChangelog } from '@modrinth/utils'
const route = useRoute()

View File

@@ -1,24 +1,18 @@
<script setup lang="ts">
import { getChangelog, type Product } from '@modrinth/blog'
import { ChangelogEntry } from '@modrinth/ui'
import Timeline from '@modrinth/ui/src/components/base/Timeline.vue'
import { getChangelog, type Product } from '@modrinth/utils'
import NavTabs from '~/components/ui/NavTabs.vue'
const route = useRoute()
const router = useRouter()
const filter = ref<Product | undefined>(undefined)
const allChangelogEntries = ref(getChangelog())
function updateFilter() {
if (route.query.filter) {
let value = route.query.filter
if (route.query.filter === 'servers') {
router.push({ query: { ...route.query, filter: 'hosting' } })
value = 'hosting'
}
filter.value = value as Product
filter.value = route.query.filter as Product
} else {
filter.value = undefined
}
@@ -44,7 +38,7 @@ const changelogEntries = computed(() =>
href: '',
},
{
label: 'Website',
label: 'Platform',
href: 'web',
},
{

View File

@@ -24,9 +24,11 @@
"storybook": "pnpm --filter @modrinth/ui storybook",
"build-storybook": "pnpm --filter @modrinth/ui build-storybook",
"icons:add": "pnpm --filter @modrinth/assets icons:add",
"changelog:collect": "node scripts/run.mjs collect-changelog",
"scripts": "node scripts/run.mjs"
},
"devDependencies": {
"@clack/prompts": "^1.0.0",
"@modrinth/tooling-config": "workspace:*",
"@tailwindcss/container-queries": "^0.1.1",
"@types/node": "^20.1.0",
@@ -34,9 +36,11 @@
"@vue/compiler-sfc": "^3.5.26",
"chalk": "^5.6.2",
"if-ci": "^3.0.0",
"keep-a-changelog": "^3.0.2",
"prettier": "^3.3.2",
"turbo": "^2.5.4",
"vue": "^3.5.13"
"vue": "^3.5.13",
"yaml": "^2.8.2"
},
"packageManager": "pnpm@9.15.0",
"pnpm": {

View File

@@ -1,6 +1,6 @@
import dayjs from 'dayjs'
export type Product = 'web' | 'hosting' | 'api' | 'app'
export type Product = 'web' | 'hosting' | 'app'
export type VersionEntry = {
date: dayjs.Dayjs

View File

@@ -1 +1,2 @@
export * from './changelog'
export { articles } from './compiled'

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@modrinth/utils": "workspace:*",
"dayjs": "^1.11.10",
"glob": "^10.2.7",
"gray-matter": "^4.0.3",
"html-minifier-terser": "^7.2.0",

View File

@@ -56,6 +56,7 @@
"@codemirror/view": "^6.22.1",
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
"@modrinth/blog": "workspace:*",
"@modrinth/utils": "workspace:*",
"@tanstack/vue-query": "^5.90.7",
"@tresjs/cientos": "^4.3.0",

View File

@@ -42,8 +42,8 @@
</template>
<script setup lang="ts">
import type { VersionEntry } from '@modrinth/blog/changelog'
import { renderHighlightedString } from '@modrinth/utils'
import type { VersionEntry } from '@modrinth/utils/changelog'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
@@ -87,7 +87,7 @@ const versionName = computed(() => props.entry.version ?? longDate.value)
const messages = defineMessages({
web: {
id: 'changelog.product.web',
defaultMessage: 'Website',
defaultMessage: 'Platform',
},
hosting: {
id: 'changelog.product.hosting',
@@ -97,10 +97,6 @@ const messages = defineMessages({
id: 'changelog.product.app',
defaultMessage: 'App',
},
api: {
id: 'changelog.product.api',
defaultMessage: 'API',
},
justNow: {
id: 'changelog.justNow',
defaultMessage: 'Just now',

View File

@@ -182,9 +182,6 @@
"changelog.justNow": {
"defaultMessage": "Just now"
},
"changelog.product.api": {
"defaultMessage": "API"
},
"changelog.product.app": {
"defaultMessage": "App"
},
@@ -192,7 +189,7 @@
"defaultMessage": "Hosting"
},
"changelog.product.web": {
"defaultMessage": "Website"
"defaultMessage": "Platform"
},
"collections.label.private": {
"defaultMessage": "Private"

View File

@@ -1,5 +1,4 @@
export * from './billing'
export * from './changelog'
export * from './highlightjs'
export * from './licenses'
export * from './parse'

226
pnpm-lock.yaml generated
View File

@@ -13,6 +13,9 @@ importers:
.:
devDependencies:
'@clack/prompts':
specifier: ^1.0.0
version: 1.1.0
'@modrinth/tooling-config':
specifier: workspace:*
version: link:packages/tooling-config
@@ -34,6 +37,9 @@ importers:
if-ci:
specifier: ^3.0.0
version: 3.0.0
keep-a-changelog:
specifier: ^3.0.2
version: 3.0.2
prettier:
specifier: ^3.3.2
version: 3.8.1
@@ -43,6 +49,9 @@ importers:
vue:
specifier: ^3.5.13
version: 3.5.27(typescript@5.9.3)
yaml:
specifier: ^2.8.2
version: 2.8.2
apps/app:
dependencies:
@@ -152,7 +161,7 @@ importers:
devDependencies:
'@eslint/compat':
specifier: ^1.1.1
version: 1.4.1(eslint@9.39.2(jiti@1.21.7))
version: 1.4.1(eslint@9.39.2(jiti@2.6.1))
'@formatjs/cli':
specifier: ^6.2.12
version: 6.12.2(@vue/compiler-core@3.5.27)(vue@3.5.27(typescript@5.9.3))
@@ -161,22 +170,22 @@ importers:
version: link:../../packages/tooling-config
'@nuxt/eslint-config':
specifier: ^0.5.6
version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@taijased/vue-render-tracker':
specifier: ^1.0.7
version: 1.0.7(vue@3.5.27(typescript@5.9.3))
'@vitejs/plugin-vue':
specifier: ^6.0.3
version: 6.0.4(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))
version: 6.0.4(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))
autoprefixer:
specifier: ^10.4.19
version: 10.4.24(postcss@8.5.6)
eslint:
specifier: ^9.9.1
version: 9.39.2(jiti@1.21.7)
version: 9.39.2(jiti@2.6.1)
eslint-plugin-turbo:
specifier: ^2.5.4
version: 2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2)
version: 2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2)
postcss:
specifier: ^8.4.39
version: 8.5.6
@@ -194,7 +203,7 @@ importers:
version: 5.9.3
vite:
specifier: ^6.0.0
version: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
version: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue-component-type-helpers:
specifier: ^3.1.8
version: 3.2.4
@@ -467,6 +476,9 @@ importers:
'@modrinth/utils':
specifier: workspace:*
version: link:../utils
dayjs:
specifier: ^1.11.10
version: 1.11.19
glob:
specifier: ^10.2.7
version: 10.5.0
@@ -601,6 +613,9 @@ importers:
'@modrinth/assets':
specifier: workspace:*
version: link:../assets
'@modrinth/blog':
specifier: workspace:*
version: link:../blog
'@modrinth/utils':
specifier: workspace:*
version: link:../utils
@@ -718,7 +733,7 @@ importers:
version: 5.2.4(vite@5.4.21(@types/node@20.19.31)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3))
eslint-plugin-storybook:
specifier: ^10.1.10
version: 10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
version: 10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
storybook:
specifier: ^10.1.10
version: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1018,9 +1033,15 @@ packages:
'@clack/core@1.0.0-alpha.7':
resolution: {integrity: sha512-3vdh6Ar09D14rVxJZIm3VQJkU+ZOKKT5I5cC0cOVazy70CNyYYjiwRj9unwalhESndgxx6bGc/m6Hhs4EKF5XQ==}
'@clack/core@1.1.0':
resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==}
'@clack/prompts@1.0.0-alpha.9':
resolution: {integrity: sha512-sKs0UjiHFWvry4SiRfBi5Qnj0C/6AYx8aKkFPZQSuUZXgAram25ZDmhQmP7vj1aFyLpfHWtLQjWvOvcat0TOLg==}
'@clack/prompts@1.1.0':
resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==}
'@cloudflare/kv-asset-handler@0.4.2':
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
@@ -6412,6 +6433,10 @@ packages:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
ini@6.0.0:
resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==}
engines: {node: ^20.17.0 || >=22.9.0}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -6662,6 +6687,10 @@ packages:
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keep-a-changelog@3.0.2:
resolution: {integrity: sha512-MKk4RWduGopP7MoA8bkzdXQk9CV6cbqNjMzpClM/mA2kT+jsbvO6co2h3UuYPe6/bX/UITemzLBNEanplrIOmQ==}
hasBin: true
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -8345,6 +8374,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -10098,12 +10132,21 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/core@1.1.0':
dependencies:
sisteransi: 1.0.5
'@clack/prompts@1.0.0-alpha.9':
dependencies:
'@clack/core': 1.0.0-alpha.7
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/prompts@1.1.0':
dependencies:
'@clack/core': 1.1.0
sisteransi: 1.0.5
'@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260131.0)':
@@ -10686,11 +10729,11 @@ snapshots:
'@eslint-community/regexpp@4.12.2': {}
'@eslint/compat@1.4.1(eslint@9.39.2(jiti@1.21.7))':
'@eslint/compat@1.4.1(eslint@9.39.2(jiti@2.6.1))':
dependencies:
'@eslint/core': 0.17.0
optionalDependencies:
eslint: 9.39.2(jiti@1.21.7)
eslint: 9.39.2(jiti@2.6.1)
'@eslint/config-array@0.21.1':
dependencies:
@@ -11311,36 +11354,36 @@ snapshots:
- utf-8-validate
- vue
'@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
'@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint/js': 9.39.2
'@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@1.21.7))
'@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@2.6.1))
eslint-flat-config-utils: 0.4.0
eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@2.6.1))
globals: 15.15.0
local-pkg: 0.5.1
pathe: 1.1.2
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7))
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- '@typescript-eslint/utils'
- eslint-import-resolver-node
- supports-color
- typescript
'@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
'@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
transitivePeerDependencies:
- supports-color
- typescript
@@ -12764,18 +12807,6 @@ snapshots:
'@stripe/stripe-js@7.9.0': {}
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
eslint-visitor-keys: 4.2.1
espree: 10.4.0
estraverse: 5.3.0
picomatch: 4.0.3
transitivePeerDependencies:
- supports-color
- typescript
'@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -12787,7 +12818,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- typescript
optional: true
'@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)':
dependencies:
@@ -13185,22 +13215,6 @@ snapshots:
dependencies:
'@types/node': 20.19.31
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
eslint: 9.39.2(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -13217,18 +13231,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
@@ -13259,18 +13261,6 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
@@ -13435,10 +13425,10 @@ snapshots:
vite: 5.4.21(@types/node@20.19.31)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
vue: 3.5.27(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.4(vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.4(vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
vite: 6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vite: 6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)
vue: 3.5.27(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))':
@@ -14964,10 +14954,10 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@1.21.7)):
eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@eslint/compat': 1.4.1(eslint@9.39.2(jiti@1.21.7))
eslint: 9.39.2(jiti@1.21.7)
'@eslint/compat': 1.4.1(eslint@9.39.2(jiti@2.6.1))
eslint: 9.39.2(jiti@2.6.1)
find-up-simple: 1.0.1
eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
@@ -14985,12 +14975,12 @@ snapshots:
optionalDependencies:
unrs-resolver: 1.11.1
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)):
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@typescript-eslint/types': 8.54.0
comment-parser: 1.4.5
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
eslint: 9.39.2(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 10.1.2
@@ -14998,18 +14988,18 @@ snapshots:
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@1.21.7)):
eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@es-joy/jsdoccomment': 0.50.2
are-docs-informative: 0.0.2
comment-parser: 1.4.1
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint: 9.39.2(jiti@1.21.7)
eslint: 9.39.2(jiti@2.6.1)
espree: 10.4.0
esquery: 1.7.0
parse-imports-exports: 0.2.4
@@ -15027,12 +15017,12 @@ snapshots:
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@1.21.7)):
eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2
comment-parser: 1.4.5
eslint: 9.39.2(jiti@1.21.7)
eslint: 9.39.2(jiti@2.6.1)
jsdoc-type-pratt-parser: 4.8.0
refa: 0.12.1
regexp-ast-analysis: 0.7.1
@@ -15042,29 +15032,29 @@ snapshots:
dependencies:
eslint: 9.39.2(jiti@2.6.1)
eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- supports-color
- typescript
eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2):
eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2):
dependencies:
dotenv: 16.0.3
eslint: 9.39.2(jiti@1.21.7)
eslint: 9.39.2(jiti@2.6.1)
turbo: 2.8.2
eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@1.21.7)):
eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@babel/helper-validator-identifier': 7.28.5
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
ci-info: 4.4.0
clean-regexp: 1.0.0
core-js-compat: 3.48.0
eslint: 9.39.2(jiti@1.21.7)
eslint: 9.39.2(jiti@2.6.1)
esquery: 1.7.0
globals: 15.15.0
indent-string: 4.0.0
@@ -15091,16 +15081,16 @@ snapshots:
'@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@1.21.7)):
eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
eslint: 9.39.2(jiti@1.21.7)
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
eslint: 9.39.2(jiti@2.6.1)
globals: 13.24.0
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.2
semver: 7.7.3
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7))
vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1))
xml-name-validator: 4.0.0
transitivePeerDependencies:
- supports-color
@@ -15895,6 +15885,8 @@ snapshots:
ini@4.1.1: {}
ini@6.0.0: {}
inline-style-parser@0.2.7: {}
intl-messageformat@10.7.18:
@@ -16114,6 +16106,11 @@ snapshots:
readable-stream: 2.3.8(patch_hash=h52dazg37p4h3yox67pw36akse)
setimmediate: 1.0.5
keep-a-changelog@3.0.2:
dependencies:
ini: 6.0.0
semver: 7.7.4
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -18367,6 +18364,8 @@ snapshots:
semver@7.7.3: {}
semver@7.7.4: {}
send@1.2.1:
dependencies:
debug: 4.4.3
@@ -19358,23 +19357,6 @@ snapshots:
sass: 1.97.3
terser: 5.46.0
vite@6.4.1(@types/node@20.19.31)(jiti@1.21.7)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.57.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 20.19.31
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.30.2
sass: 1.97.3
terser: 5.46.0
yaml: 2.8.2
vite@6.4.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2):
dependencies:
esbuild: 0.25.12
@@ -19569,10 +19551,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@1.21.7)):
vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
eslint: 9.39.2(jiti@2.6.1)
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1

View File

@@ -0,0 +1,425 @@
import { execSync } from 'child_process'
import chalk from 'chalk'
import * as fs from 'fs'
import * as path from 'path'
type Product = 'web' | 'app' | 'hosting'
const CHANGELOG_MARKER = '<!-- changelog -->'
const SECTION_ORDER = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const
const SECTION_HEADERS: Record<string, string> = {
added: '## Added',
changed: '## Changed',
deprecated: '## Deprecated',
removed: '## Removed',
fixed: '## Fixed',
security: '## Security',
}
const PRODUCT_SUMMARY_MAP: Record<string, Product> = {
App: 'app',
Website: 'web',
Hosting: 'hosting',
}
const GITHUB_API = 'https://api.github.com'
const REPO = 'modrinth/code'
interface ParsedChangelog {
entries: Map<Product, Map<string, string[]>>
}
interface PRInfo {
number: number
mergedAt: string
}
interface CommentInfo {
id: number
body: string
}
function parseArgs(argv: string[]): {
version?: string
dryRun: boolean
} {
let version: string | undefined
let dryRun = false
let i = 0
while (i < argv.length) {
if (argv[i] === '--') {
i++
continue
}
if (argv[i] === '--version') {
i++
if (i >= argv.length || argv[i].startsWith('--')) {
console.error(chalk.red('--version requires a value'))
process.exit(1)
}
version = argv[i]
i++
} else if (argv[i] === '--dry-run') {
dryRun = true
i++
} else {
console.error(chalk.red(`Unknown argument: ${argv[i]}`))
process.exit(1)
}
}
return { version, dryRun }
}
async function getParser() {
const mod = await import('keep-a-changelog')
return mod.parser
}
function parseChangelogComment(body: string, parse: Function): ParsedChangelog | null {
if (!body.includes(CHANGELOG_MARKER)) return null
const entries = new Map<Product, Map<string, string[]>>()
const detailsRegex = /<details>\s*<summary>(.*?)<\/summary>([\s\S]*?)<\/details>/g
let match
while ((match = detailsRegex.exec(body)) !== null) {
const summaryLabel = match[1].trim()
const product = PRODUCT_SUMMARY_MAP[summaryLabel]
if (!product) continue
const content = match[2].replace(/<!--[\s\S]*?-->/g, '').trim()
const firstSection = content.search(/^### /m)
if (firstSection === -1) continue
const changelogMd = `# Changelog\n\n## [Unreleased]\n${content.slice(firstSection)}`
let changelog
try {
changelog = parse(changelogMd)
} catch {
continue
}
const unreleased = changelog.findRelease()
if (!unreleased || unreleased.isEmpty()) continue
const sections = new Map<string, string[]>()
for (const type of SECTION_ORDER) {
const changes = unreleased.changes.get(type)
if (changes && changes.length > 0) {
sections.set(type, changes.map((c: { title: string }) => c.title))
}
}
if (sections.size > 0) {
entries.set(product, sections)
}
}
if (entries.size === 0) return null
return { entries }
}
function linkifyIssues(text: string): string {
return text.replace(
/\[#(\d+)\]/g,
'[#$1](https://github.com/modrinth/code/issues/$1)',
)
}
function buildBody(sections: Map<string, string[]>): string {
const parts: string[] = []
for (const type of SECTION_ORDER) {
const entries = sections.get(type)
if (!entries || entries.length === 0) continue
const lines = entries.map((e) => `- ${linkifyIssues(e)}`)
parts.push(`${SECTION_HEADERS[type]}\n${lines.join('\n')}`)
}
return parts.join('\n\n')
}
function mergeSections(
allEntries: { sections: Map<string, string[]>; prNumber: number }[],
): Map<string, string[]> {
const merged = new Map<string, string[]>()
for (const { sections } of allEntries) {
for (const [type, entries] of sections) {
if (!merged.has(type)) {
merged.set(type, [])
}
merged.get(type)!.push(...entries)
}
}
return merged
}
async function githubFetch(endpoint: string, token: string, options?: RequestInit): Promise<Response> {
const url = endpoint.startsWith('http') ? endpoint : `${GITHUB_API}${endpoint}`
return fetch(url, {
...options,
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github.v3+json',
...options?.headers,
},
})
}
async function fetchMergedPRs(token: string, sinceDate: string): Promise<PRInfo[]> {
const prs: PRInfo[] = []
let page = 1
while (true) {
const res = await githubFetch(
`/repos/${REPO}/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=${page}`,
token,
)
if (!res.ok) {
console.error(chalk.red(`GitHub API error: ${res.status} ${res.statusText}`))
process.exit(1)
}
const data = (await res.json()) as Array<{
number: number
merged_at: string | null
updated_at: string
}>
if (data.length === 0) break
let foundOlder = false
for (const pr of data) {
if (!pr.merged_at) continue
if (new Date(pr.merged_at) > new Date(sinceDate)) {
prs.push({ number: pr.number, mergedAt: pr.merged_at })
} else {
foundOlder = true
}
}
if (foundOlder || data.length < 100) break
page++
}
return prs
}
async function fetchBotComment(token: string, prNumber: number): Promise<CommentInfo | null> {
const res = await githubFetch(
`/repos/${REPO}/issues/${prNumber}/comments?per_page=100`,
token,
)
if (!res.ok) return null
const comments = (await res.json()) as Array<{ id: number; body: string }>
const comment = comments.find((c) => c.body.includes(CHANGELOG_MARKER))
if (!comment) return null
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
await githubFetch(`/repos/${REPO}/issues/comments/${commentId}`, token, {
method: 'PATCH',
body: JSON.stringify({ body: newBody }),
})
}
function getCurrentPacificISO(): string {
const now = new Date()
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
const parts = formatter.formatToParts(now)
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
const year = get('year')
const month = get('month')
const day = get('day')
const hour = get('hour')
const minute = get('minute')
const second = get('second')
const jan = new Date(now.getFullYear(), 0, 1)
const jul = new Date(now.getFullYear(), 6, 1)
const stdOffset = Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset())
const pacificNow = new Date(
now.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }),
)
const utcNow = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))
const offsetMinutes = (utcNow.getTime() - pacificNow.getTime()) / 60000
const isDST = offsetMinutes !== stdOffset
const offsetStr = isDST ? '-07:00' : '-08:00'
return `${year}-${month}-${day}T${hour}:${minute}:${second}${offsetStr}`
}
function generateEntry(product: string, body: string, version?: string): string {
const dateStr = getCurrentPacificISO()
const versionLine = version ? `\n\t\tversion: '${version}',` : ''
return `\t{
\t\tdate: \`${dateStr}\`,
\t\tproduct: '${product}',${versionLine}
\t\tbody: \`${body}\`,
\t},`
}
function insertIntoChangelog(changelogPath: string, entryString: string): void {
const content = fs.readFileSync(changelogPath, 'utf-8')
const marker = 'const VERSIONS: VersionEntry[] = ['
const markerIndex = content.indexOf(marker)
if (markerIndex === -1) {
console.error(chalk.red('Could not find VERSIONS array in changelog.ts'))
process.exit(1)
}
const insertPos = content.indexOf('\n', markerIndex) + 1
const newContent = content.slice(0, insertPos) + entryString + '\n' + content.slice(insertPos)
fs.writeFileSync(changelogPath, newContent, 'utf-8')
}
async function main() {
const args = parseArgs(process.argv.slice(2))
let token = process.env.GITHUB_TOKEN
if (!token) {
try {
token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
} catch {
console.error(chalk.red('GITHUB_TOKEN not set and `gh auth token` failed. Run `gh auth login` or set GITHUB_TOKEN.'))
process.exit(1)
}
}
const parse = await getParser()
const rootDir = path.resolve(__dirname, '..')
const changelogPath = path.join(rootDir, 'packages', 'blog', 'changelog.ts')
const prodDate = execSync('git log -1 --format=%aI origin/prod', { encoding: 'utf-8' }).trim()
console.log(chalk.gray(`Last prod commit: ${prodDate}`))
const prs = await fetchMergedPRs(token, prodDate)
console.log(chalk.gray(`Found ${prs.length} merged PR(s) since last prod deploy`))
if (prs.length === 0) {
console.log(chalk.yellow('No merged PRs found since last prod deploy'))
return
}
const allEntries = new Map<Product, { sections: Map<string, string[]>; prNumber: number }[]>()
const processedComments: { commentId: number; body: string }[] = []
for (const pr of prs) {
const comment = await fetchBotComment(token, pr.number)
if (!comment) {
console.log(chalk.gray(`PR #${pr.number}: no changelog comment, skipping`))
continue
}
if (comment.body.includes('> This changelog has been baked.')) {
console.log(chalk.gray(`PR #${pr.number}: already baked, skipping`))
continue
}
const parsed = parseChangelogComment(comment.body, parse)
if (!parsed) {
console.log(chalk.gray(`PR #${pr.number}: no changelog entries, skipping`))
continue
}
const products = [...parsed.entries.keys()]
const types = [...new Set([...parsed.entries.values()].flatMap((s) => [...s.keys()]))]
console.log(chalk.cyan(`PR #${pr.number}: ${products.join(', ')}${types.join(', ')}`))
for (const [product, sections] of parsed.entries) {
if (!allEntries.has(product)) {
allEntries.set(product, [])
}
allEntries.get(product)!.push({ sections, prNumber: pr.number })
}
processedComments.push({ commentId: comment.id, body: comment.body })
}
if (allEntries.size === 0) {
console.log(chalk.yellow('No changelog entries found in merged PRs'))
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)!
const mergedSections = mergeSections(productEntries)
const body = buildBody(mergedSections)
if (body.includes('`')) {
console.error(
chalk.yellow(
`Warning: Changelog body for ${product} contains backticks — this may break the template literal in changelog.ts`,
),
)
}
const version = product === 'app' ? args.version : undefined
const entryString = generateEntry(product, body, version)
if (args.dryRun) {
console.log(chalk.cyan(`\n[dry-run] Would insert for ${product}:`))
console.log(entryString)
} else {
insertIntoChangelog(changelogPath, entryString)
console.log(chalk.green(`Inserted changelog entry for ${product}`))
}
}
if (!args.dryRun) {
for (const { commentId, body } of processedComments) {
await markCommentBaked(token, commentId, body)
}
console.log(chalk.gray(`Marked ${processedComments.length} comment(s) as baked`))
}
console.log()
if (args.dryRun) {
console.log(chalk.cyan('Dry run complete — no changes written'))
} else {
console.log(chalk.green('Done! Review the changes in packages/blog/changelog.ts'))
}
}
main()