Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/changeset-preview/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Changeset Preview
description: Generates comment on a PR showing expected version impact
runs:
using: composite
steps:
- name: Preview version bumps
shell: bash
run: node ${{ github.action_path }}/preview-changeset-versions.mjs --output /tmp/changeset-preview.md
- name: Post PR comment
shell: bash
run: |
node ${{ github.action_path }}/upsert-pr-comment.mjs \
--pr "${{ github.event.number }}" \
--body-file /tmp/changeset-preview.md \
--marker "<!-- changeset-version-preview -->"
env:
REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
197 changes: 197 additions & 0 deletions .github/changeset-preview/preview-changeset-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
#!/usr/bin/env node

/**
* Preview the version bumps that `changeset version` will produce.
*
* Workflow:
* 1. Snapshot every workspace package's current version
* 2. Run `changeset version` (mutates package.json files)
* 3. Diff against the snapshot
* 4. Print a markdown summary (or write to --output file)
*
* This script is meant to run in CI on a disposable checkout β€” it does NOT
* revert the changes it makes.
*/

import { execSync } from 'node:child_process'
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'

const ROOT = resolve(import.meta.dirname, '..', '..')

const PACKAGES_DIR = join(ROOT, 'packages')

function readPackageVersions() {
const versions = new Map()
for (const dir of readdirSync(PACKAGES_DIR, { withFileTypes: true })) {
if (!dir.isDirectory()) continue
const pkgPath = join(PACKAGES_DIR, dir.name, 'package.json')
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (pkg.name && pkg.version && pkg.private !== true) {
versions.set(pkg.name, pkg.version)
}
} catch {
// skip packages without a valid package.json
}
}
return versions
}

function readChangesetEntries() {
const changesetDir = join(ROOT, '.changeset')
const explicit = new Map()
for (const file of readdirSync(changesetDir)) {
if (file === 'config.json' || file === 'README.md' || !file.endsWith('.md'))
continue
const content = readFileSync(join(changesetDir, file), 'utf8')
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
if (!frontmatterMatch) continue
for (const line of frontmatterMatch[1].split('\n')) {
const match = line.match(/^['"]?([^'"]+)['"]?\s*:\s*(major|minor|patch)/)
if (match) {
const [, name, bump] = match
const existing = explicit.get(name)
// keep the highest bump if a package appears in multiple changesets
if (!existing || bumpRank(bump) > bumpRank(existing)) {
explicit.set(name, bump)
}
}
}
}
return explicit
}

function bumpRank(bump) {
return bump === 'major' ? 3 : bump === 'minor' ? 2 : 1
}

function bumpType(oldVersion, newVersion) {
const [oMaj, oMin] = oldVersion.split('.').map(Number)
const [nMaj, nMin] = newVersion.split('.').map(Number)
if (nMaj > oMaj) return 'major'
if (nMin > oMin) return 'minor'
return 'patch'
}

function main() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
output: { type: 'string', short: 'o' },
},
strict: true,
allowPositionals: false,
})

// 1. Read explicit changeset entries
const explicit = readChangesetEntries()

if (explicit.size === 0) {
const msg = 'No changeset entries found β€” nothing to preview.\n'
if (values.output) {
writeFileSync(values.output, msg)
} else {
process.stdout.write(msg)
}
return
}

// 2. Snapshot current versions
const before = readPackageVersions()

// 3. Temporarily swap changeset config to skip changelog generation
// (the GitHub changelog plugin requires a token we don't need for previews)
const configPath = join(ROOT, '.changeset', 'config.json')
const originalConfig = readFileSync(configPath, 'utf8')
try {
const config = JSON.parse(originalConfig)
config.changelog = false
writeFileSync(configPath, JSON.stringify(config, null, 2))

// 4. Run changeset version
execSync('pnpm changeset version', { cwd: ROOT, stdio: 'pipe' })
} finally {
// Always restore the original config
writeFileSync(configPath, originalConfig)
}

// 5. Read new versions
const after = readPackageVersions()

// 6. Diff
const bumps = []
for (const [name, newVersion] of after) {
const oldVersion = before.get(name)
if (!oldVersion || oldVersion === newVersion) continue
const bump = bumpType(oldVersion, newVersion)
const source = explicit.has(name) ? explicit.get(name) : 'dependency'
bumps.push({ name, oldVersion, newVersion, bump, source })
}

// Sort: major first, then minor, then patch; within each group alphabetical
bumps.sort(
(a, b) =>
bumpRank(b.bump) - bumpRank(a.bump) || a.name.localeCompare(b.name),
)

// 7. Build markdown
const lines = []
lines.push('<!-- changeset-version-preview -->')
lines.push('## Changeset Version Preview')
lines.push('')

if (bumps.length === 0) {
lines.push('No version changes detected.')
} else {
const explicitBumps = bumps.filter((b) => b.source !== 'dependency')
const dependencyBumps = bumps.filter((b) => b.source === 'dependency')

lines.push(
`**${explicitBumps.length}** package(s) bumped directly, **${dependencyBumps.length}** bumped as dependents.`,
)
lines.push('')

if (explicitBumps.length > 0) {
lines.push('### Direct bumps')
lines.push('')
lines.push('| Package | Bump | Version |')
lines.push('| --- | --- | --- |')
for (const b of explicitBumps) {
lines.push(
`| \`${b.name}\` | **${b.bump}** | ${b.oldVersion} β†’ ${b.newVersion} |`,
)
}
lines.push('')
}

if (dependencyBumps.length > 0) {
lines.push(
'<details>',
`<summary>Dependency bumps (${dependencyBumps.length})</summary>`,
'',
'| Package | Bump | Version |',
'| --- | --- | --- |',
)
for (const b of dependencyBumps) {
lines.push(
`| \`${b.name}\` | ${b.bump} | ${b.oldVersion} β†’ ${b.newVersion} |`,
)
}
lines.push('', '</details>')
}
}

lines.push('')

const md = lines.join('\n')
if (values.output) {
writeFileSync(values.output, md)
process.stdout.write(`Written to ${values.output}\n`)
} else {
process.stdout.write(md)
}
}

main()
159 changes: 159 additions & 0 deletions .github/changeset-preview/upsert-pr-comment.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env node

import { promises as fsp } from 'node:fs'
import path from 'node:path'
import { parseArgs as parseNodeArgs } from 'node:util'

const DEFAULT_MARKER = '<!-- bundle-size-benchmark -->'

function parseArgs(argv) {
const { values } = parseNodeArgs({
args: argv,
allowPositionals: false,
strict: true,
options: {
pr: { type: 'string' },
'body-file': { type: 'string' },
repo: { type: 'string' },
token: { type: 'string' },
marker: { type: 'string' },
'api-url': { type: 'string' },
},
})

const args = {
pr: values.pr ? Number.parseInt(values.pr, 10) : undefined,
bodyFile: values['body-file'],
repo: values.repo ?? process.env.GITHUB_REPOSITORY,
marker: values.marker ?? DEFAULT_MARKER,
token: values.token ?? (process.env.GITHUB_TOKEN || process.env.GH_TOKEN),
apiUrl:
values['api-url'] ??
(process.env.GITHUB_API_URL || 'https://api.github.com'),
}
Comment on lines +24 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Environment variable mismatch: REPOSITORY vs GITHUB_REPOSITORY.

The action.yml sets REPOSITORY: ${{ github.repository }} but this script reads process.env.GITHUB_REPOSITORY. While this works because GitHub Actions automatically provides GITHUB_REPOSITORY, the explicit env var in action.yml is unused and misleading.

Either update action.yml to use the standard GITHUB_REPOSITORY name, or update this script to also check REPOSITORY:

Option 1: Fix in action.yml
       env:
-        REPOSITORY: ${{ github.repository }}
+        GITHUB_REPOSITORY: ${{ github.repository }}
         GH_TOKEN: ${{ github.token }}
Option 2: Fix in this script
-    repo: values.repo ?? process.env.GITHUB_REPOSITORY,
+    repo: values.repo ?? process.env.GITHUB_REPOSITORY ?? process.env.REPOSITORY,
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const args = {
pr: values.pr ? Number.parseInt(values.pr, 10) : undefined,
bodyFile: values['body-file'],
repo: values.repo ?? process.env.GITHUB_REPOSITORY,
marker: values.marker ?? DEFAULT_MARKER,
token: values.token ?? (process.env.GITHUB_TOKEN || process.env.GH_TOKEN),
apiUrl:
values['api-url'] ??
(process.env.GITHUB_API_URL || 'https://api.github.com'),
}
const args = {
pr: values.pr ? Number.parseInt(values.pr, 10) : undefined,
bodyFile: values['body-file'],
repo: values.repo ?? process.env.GITHUB_REPOSITORY ?? process.env.REPOSITORY,
marker: values.marker ?? DEFAULT_MARKER,
token: values.token ?? (process.env.GITHUB_TOKEN || process.env.GH_TOKEN),
apiUrl:
values['api-url'] ??
(process.env.GITHUB_API_URL || 'https://api.github.com'),
}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/changeset-preview/upsert-pr-comment.mjs around lines 24 - 33, The
args object currently sets repo using process.env.GITHUB_REPOSITORY which
mismatches the action.yml env name REPOSITORY; update the repo assignment in the
args construction (the repo property in the args object) to prefer values.repo,
then process.env.GITHUB_REPOSITORY, and also fallback to process.env.REPOSITORY
(or vice versa) so the script accepts the explicitly-passed REPOSITORY env from
action.yml; keep the existing DEFAULT_MARKER/token/api-url logic intact and only
modify the repo lookup in this file.


if (!Number.isFinite(args.pr) || args.pr <= 0) {
throw new Error('Missing required argument: --pr')
}

if (!args.bodyFile) {
throw new Error('Missing required argument: --body-file')
}

if (!args.repo || !args.repo.includes('/')) {
throw new Error(
'Missing repository context. Provide --repo or GITHUB_REPOSITORY.',
)
}

if (!args.token) {
throw new Error('Missing token. Provide --token or GITHUB_TOKEN.')
}

return args
}

async function githubRequest({ apiUrl, token, method, endpoint, body }) {
const url = `${apiUrl.replace(/\/$/, '')}${endpoint}`
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'User-Agent': 'tanstack-router-bundle-size-bot',
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})

if (!response.ok) {
const text = await response.text()
throw new Error(
`${method} ${endpoint} failed (${response.status} ${response.statusText}): ${text}`,
)
}

if (response.status === 204) {
return undefined
}

return response.json()
}

async function listIssueComments({ apiUrl, token, repo, pr }) {
const comments = []
let page = 1
const perPage = 100

for (;;) {
const data = await githubRequest({
apiUrl,
token,
method: 'GET',
endpoint: `/repos/${repo}/issues/${pr}/comments?per_page=${perPage}&page=${page}`,
})

comments.push(...data)

if (!Array.isArray(data) || data.length < perPage) {
break
}

page += 1
}

return comments
}

async function main() {
const args = parseArgs(process.argv.slice(2))
const bodyPath = path.resolve(args.bodyFile)
const rawBody = await fsp.readFile(bodyPath, 'utf8')
const body = rawBody.includes(args.marker)
? rawBody
: `${args.marker}\n${rawBody}`

const comments = await listIssueComments({
apiUrl: args.apiUrl,
token: args.token,
repo: args.repo,
pr: args.pr,
})

const existing = comments.find(
(comment) =>
typeof comment?.body === 'string' && comment.body.includes(args.marker),
)

if (existing) {
await githubRequest({
apiUrl: args.apiUrl,
token: args.token,
method: 'PATCH',
endpoint: `/repos/${args.repo}/issues/comments/${existing.id}`,
body: { body },
})

process.stdout.write(
`Updated PR #${args.pr} bundle-size comment (${existing.id}).\n`,
)
return
}

const created = await githubRequest({
apiUrl: args.apiUrl,
token: args.token,
method: 'POST',
endpoint: `/repos/${args.repo}/issues/${args.pr}/comments`,
body: { body },
})

process.stdout.write(
`Created PR #${args.pr} bundle-size comment (${created?.id ?? 'unknown'}).\n`,
)
}

main().catch((error) => {
console.error(error)
process.exit(1)
})
Loading
Loading