Skip to content

Commit e6dd8de

Browse files
ci: changeset-preview (#356)
* WIP: changeset-preview * Add workflow * Add shell * Fix root dir * Remove test code
1 parent 972e400 commit e6dd8de

4 files changed

Lines changed: 400 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Changeset Preview
2+
description: Generates comment on a PR showing expected version impact
3+
runs:
4+
using: composite
5+
steps:
6+
- name: Preview version bumps
7+
shell: bash
8+
run: node ${{ github.action_path }}/preview-changeset-versions.mjs --output /tmp/changeset-preview.md
9+
- name: Post PR comment
10+
shell: bash
11+
run: |
12+
node ${{ github.action_path }}/upsert-pr-comment.mjs \
13+
--pr "${{ github.event.number }}" \
14+
--body-file /tmp/changeset-preview.md \
15+
--marker "<!-- changeset-version-preview -->"
16+
env:
17+
REPOSITORY: ${{ github.repository }}
18+
GH_TOKEN: ${{ github.token }}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Preview the version bumps that `changeset version` will produce.
5+
*
6+
* Workflow:
7+
* 1. Snapshot every workspace package's current version
8+
* 2. Run `changeset version` (mutates package.json files)
9+
* 3. Diff against the snapshot
10+
* 4. Print a markdown summary (or write to --output file)
11+
*
12+
* This script is meant to run in CI on a disposable checkout — it does NOT
13+
* revert the changes it makes.
14+
*/
15+
16+
import { execSync } from 'node:child_process'
17+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
18+
import { join, resolve } from 'node:path'
19+
import { parseArgs } from 'node:util'
20+
21+
const ROOT = resolve(import.meta.dirname, '..', '..')
22+
23+
const PACKAGES_DIR = join(ROOT, 'packages')
24+
25+
function readPackageVersions() {
26+
const versions = new Map()
27+
for (const dir of readdirSync(PACKAGES_DIR, { withFileTypes: true })) {
28+
if (!dir.isDirectory()) continue
29+
const pkgPath = join(PACKAGES_DIR, dir.name, 'package.json')
30+
try {
31+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
32+
if (pkg.name && pkg.version && pkg.private !== true) {
33+
versions.set(pkg.name, pkg.version)
34+
}
35+
} catch {
36+
// skip packages without a valid package.json
37+
}
38+
}
39+
return versions
40+
}
41+
42+
function readChangesetEntries() {
43+
const changesetDir = join(ROOT, '.changeset')
44+
const explicit = new Map()
45+
for (const file of readdirSync(changesetDir)) {
46+
if (file === 'config.json' || file === 'README.md' || !file.endsWith('.md'))
47+
continue
48+
const content = readFileSync(join(changesetDir, file), 'utf8')
49+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
50+
if (!frontmatterMatch) continue
51+
for (const line of frontmatterMatch[1].split('\n')) {
52+
const match = line.match(/^['"]?([^'"]+)['"]?\s*:\s*(major|minor|patch)/)
53+
if (match) {
54+
const [, name, bump] = match
55+
const existing = explicit.get(name)
56+
// keep the highest bump if a package appears in multiple changesets
57+
if (!existing || bumpRank(bump) > bumpRank(existing)) {
58+
explicit.set(name, bump)
59+
}
60+
}
61+
}
62+
}
63+
return explicit
64+
}
65+
66+
function bumpRank(bump) {
67+
return bump === 'major' ? 3 : bump === 'minor' ? 2 : 1
68+
}
69+
70+
function bumpType(oldVersion, newVersion) {
71+
const [oMaj, oMin] = oldVersion.split('.').map(Number)
72+
const [nMaj, nMin] = newVersion.split('.').map(Number)
73+
if (nMaj > oMaj) return 'major'
74+
if (nMin > oMin) return 'minor'
75+
return 'patch'
76+
}
77+
78+
function main() {
79+
const { values } = parseArgs({
80+
args: process.argv.slice(2),
81+
options: {
82+
output: { type: 'string', short: 'o' },
83+
},
84+
strict: true,
85+
allowPositionals: false,
86+
})
87+
88+
// 1. Read explicit changeset entries
89+
const explicit = readChangesetEntries()
90+
91+
if (explicit.size === 0) {
92+
const msg = 'No changeset entries found — nothing to preview.\n'
93+
if (values.output) {
94+
writeFileSync(values.output, msg)
95+
} else {
96+
process.stdout.write(msg)
97+
}
98+
return
99+
}
100+
101+
// 2. Snapshot current versions
102+
const before = readPackageVersions()
103+
104+
// 3. Temporarily swap changeset config to skip changelog generation
105+
// (the GitHub changelog plugin requires a token we don't need for previews)
106+
const configPath = join(ROOT, '.changeset', 'config.json')
107+
const originalConfig = readFileSync(configPath, 'utf8')
108+
try {
109+
const config = JSON.parse(originalConfig)
110+
config.changelog = false
111+
writeFileSync(configPath, JSON.stringify(config, null, 2))
112+
113+
// 4. Run changeset version
114+
execSync('pnpm changeset version', { cwd: ROOT, stdio: 'pipe' })
115+
} finally {
116+
// Always restore the original config
117+
writeFileSync(configPath, originalConfig)
118+
}
119+
120+
// 5. Read new versions
121+
const after = readPackageVersions()
122+
123+
// 6. Diff
124+
const bumps = []
125+
for (const [name, newVersion] of after) {
126+
const oldVersion = before.get(name)
127+
if (!oldVersion || oldVersion === newVersion) continue
128+
const bump = bumpType(oldVersion, newVersion)
129+
const source = explicit.has(name) ? explicit.get(name) : 'dependency'
130+
bumps.push({ name, oldVersion, newVersion, bump, source })
131+
}
132+
133+
// Sort: major first, then minor, then patch; within each group alphabetical
134+
bumps.sort(
135+
(a, b) =>
136+
bumpRank(b.bump) - bumpRank(a.bump) || a.name.localeCompare(b.name),
137+
)
138+
139+
// 7. Build markdown
140+
const lines = []
141+
lines.push('<!-- changeset-version-preview -->')
142+
lines.push('## Changeset Version Preview')
143+
lines.push('')
144+
145+
if (bumps.length === 0) {
146+
lines.push('No version changes detected.')
147+
} else {
148+
const explicitBumps = bumps.filter((b) => b.source !== 'dependency')
149+
const dependencyBumps = bumps.filter((b) => b.source === 'dependency')
150+
151+
lines.push(
152+
`**${explicitBumps.length}** package(s) bumped directly, **${dependencyBumps.length}** bumped as dependents.`,
153+
)
154+
lines.push('')
155+
156+
if (explicitBumps.length > 0) {
157+
lines.push('### Direct bumps')
158+
lines.push('')
159+
lines.push('| Package | Bump | Version |')
160+
lines.push('| --- | --- | --- |')
161+
for (const b of explicitBumps) {
162+
lines.push(
163+
`| \`${b.name}\` | **${b.bump}** | ${b.oldVersion}${b.newVersion} |`,
164+
)
165+
}
166+
lines.push('')
167+
}
168+
169+
if (dependencyBumps.length > 0) {
170+
lines.push(
171+
'<details>',
172+
`<summary>Dependency bumps (${dependencyBumps.length})</summary>`,
173+
'',
174+
'| Package | Bump | Version |',
175+
'| --- | --- | --- |',
176+
)
177+
for (const b of dependencyBumps) {
178+
lines.push(
179+
`| \`${b.name}\` | ${b.bump} | ${b.oldVersion}${b.newVersion} |`,
180+
)
181+
}
182+
lines.push('', '</details>')
183+
}
184+
}
185+
186+
lines.push('')
187+
188+
const md = lines.join('\n')
189+
if (values.output) {
190+
writeFileSync(values.output, md)
191+
process.stdout.write(`Written to ${values.output}\n`)
192+
} else {
193+
process.stdout.write(md)
194+
}
195+
}
196+
197+
main()
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#!/usr/bin/env node
2+
3+
import { promises as fsp } from 'node:fs'
4+
import path from 'node:path'
5+
import { parseArgs as parseNodeArgs } from 'node:util'
6+
7+
const DEFAULT_MARKER = '<!-- bundle-size-benchmark -->'
8+
9+
function parseArgs(argv) {
10+
const { values } = parseNodeArgs({
11+
args: argv,
12+
allowPositionals: false,
13+
strict: true,
14+
options: {
15+
pr: { type: 'string' },
16+
'body-file': { type: 'string' },
17+
repo: { type: 'string' },
18+
token: { type: 'string' },
19+
marker: { type: 'string' },
20+
'api-url': { type: 'string' },
21+
},
22+
})
23+
24+
const args = {
25+
pr: values.pr ? Number.parseInt(values.pr, 10) : undefined,
26+
bodyFile: values['body-file'],
27+
repo: values.repo ?? process.env.GITHUB_REPOSITORY,
28+
marker: values.marker ?? DEFAULT_MARKER,
29+
token: values.token ?? (process.env.GITHUB_TOKEN || process.env.GH_TOKEN),
30+
apiUrl:
31+
values['api-url'] ??
32+
(process.env.GITHUB_API_URL || 'https://api.github.com'),
33+
}
34+
35+
if (!Number.isFinite(args.pr) || args.pr <= 0) {
36+
throw new Error('Missing required argument: --pr')
37+
}
38+
39+
if (!args.bodyFile) {
40+
throw new Error('Missing required argument: --body-file')
41+
}
42+
43+
if (!args.repo || !args.repo.includes('/')) {
44+
throw new Error(
45+
'Missing repository context. Provide --repo or GITHUB_REPOSITORY.',
46+
)
47+
}
48+
49+
if (!args.token) {
50+
throw new Error('Missing token. Provide --token or GITHUB_TOKEN.')
51+
}
52+
53+
return args
54+
}
55+
56+
async function githubRequest({ apiUrl, token, method, endpoint, body }) {
57+
const url = `${apiUrl.replace(/\/$/, '')}${endpoint}`
58+
const response = await fetch(url, {
59+
method,
60+
headers: {
61+
Authorization: `Bearer ${token}`,
62+
Accept: 'application/vnd.github+json',
63+
'User-Agent': 'tanstack-router-bundle-size-bot',
64+
'Content-Type': 'application/json',
65+
},
66+
body: body ? JSON.stringify(body) : undefined,
67+
})
68+
69+
if (!response.ok) {
70+
const text = await response.text()
71+
throw new Error(
72+
`${method} ${endpoint} failed (${response.status} ${response.statusText}): ${text}`,
73+
)
74+
}
75+
76+
if (response.status === 204) {
77+
return undefined
78+
}
79+
80+
return response.json()
81+
}
82+
83+
async function listIssueComments({ apiUrl, token, repo, pr }) {
84+
const comments = []
85+
let page = 1
86+
const perPage = 100
87+
88+
for (;;) {
89+
const data = await githubRequest({
90+
apiUrl,
91+
token,
92+
method: 'GET',
93+
endpoint: `/repos/${repo}/issues/${pr}/comments?per_page=${perPage}&page=${page}`,
94+
})
95+
96+
comments.push(...data)
97+
98+
if (!Array.isArray(data) || data.length < perPage) {
99+
break
100+
}
101+
102+
page += 1
103+
}
104+
105+
return comments
106+
}
107+
108+
async function main() {
109+
const args = parseArgs(process.argv.slice(2))
110+
const bodyPath = path.resolve(args.bodyFile)
111+
const rawBody = await fsp.readFile(bodyPath, 'utf8')
112+
const body = rawBody.includes(args.marker)
113+
? rawBody
114+
: `${args.marker}\n${rawBody}`
115+
116+
const comments = await listIssueComments({
117+
apiUrl: args.apiUrl,
118+
token: args.token,
119+
repo: args.repo,
120+
pr: args.pr,
121+
})
122+
123+
const existing = comments.find(
124+
(comment) =>
125+
typeof comment?.body === 'string' && comment.body.includes(args.marker),
126+
)
127+
128+
if (existing) {
129+
await githubRequest({
130+
apiUrl: args.apiUrl,
131+
token: args.token,
132+
method: 'PATCH',
133+
endpoint: `/repos/${args.repo}/issues/comments/${existing.id}`,
134+
body: { body },
135+
})
136+
137+
process.stdout.write(
138+
`Updated PR #${args.pr} bundle-size comment (${existing.id}).\n`,
139+
)
140+
return
141+
}
142+
143+
const created = await githubRequest({
144+
apiUrl: args.apiUrl,
145+
token: args.token,
146+
method: 'POST',
147+
endpoint: `/repos/${args.repo}/issues/${args.pr}/comments`,
148+
body: { body },
149+
})
150+
151+
process.stdout.write(
152+
`Created PR #${args.pr} bundle-size comment (${created?.id ?? 'unknown'}).\n`,
153+
)
154+
}
155+
156+
main().catch((error) => {
157+
console.error(error)
158+
process.exit(1)
159+
})

0 commit comments

Comments
 (0)