-
-
Notifications
You must be signed in to change notification settings - Fork 40
ci: changeset-preview #356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+400
β0
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
197
.github/changeset-preview/preview-changeset-versions.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'), | ||
| } | ||
|
|
||
| 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) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Environment variable mismatch:
REPOSITORYvsGITHUB_REPOSITORY.The action.yml sets
REPOSITORY: ${{ github.repository }}but this script readsprocess.env.GITHUB_REPOSITORY. While this works because GitHub Actions automatically providesGITHUB_REPOSITORY, the explicit env var in action.yml is unused and misleading.Either update action.yml to use the standard
GITHUB_REPOSITORYname, or update this script to also checkREPOSITORY:Option 1: Fix in action.yml
Option 2: Fix in this script
π Committable suggestion
π€ Prompt for AI Agents