diff --git a/.github/changeset-preview/action.yml b/.github/changeset-preview/action.yml new file mode 100644 index 00000000..8a32dde8 --- /dev/null +++ b/.github/changeset-preview/action.yml @@ -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 "" + env: + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} diff --git a/.github/changeset-preview/preview-changeset-versions.mjs b/.github/changeset-preview/preview-changeset-versions.mjs new file mode 100644 index 00000000..3f85842f --- /dev/null +++ b/.github/changeset-preview/preview-changeset-versions.mjs @@ -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('') + 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( + '
', + `Dependency bumps (${dependencyBumps.length})`, + '', + '| Package | Bump | Version |', + '| --- | --- | --- |', + ) + for (const b of dependencyBumps) { + lines.push( + `| \`${b.name}\` | ${b.bump} | ${b.oldVersion} → ${b.newVersion} |`, + ) + } + lines.push('', '
') + } + } + + 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() diff --git a/.github/changeset-preview/upsert-pr-comment.mjs b/.github/changeset-preview/upsert-pr-comment.mjs new file mode 100644 index 00000000..9637e213 --- /dev/null +++ b/.github/changeset-preview/upsert-pr-comment.mjs @@ -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 = '' + +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) +}) diff --git a/.github/workflows/changeset-preview.yml b/.github/workflows/changeset-preview.yml new file mode 100644 index 00000000..3eb706ed --- /dev/null +++ b/.github/workflows/changeset-preview.yml @@ -0,0 +1,26 @@ +name: Changeset Preview + +on: + pull_request: + paths: + - '.changeset/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + preview: + name: Version Preview + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6.0.1 + - name: Setup Tools + uses: tanstack/config/.github/setup@main + - name: Changeset Preview + uses: ./.github/changeset-preview