diff --git a/.github/scripts/check-changeset-coverage.mjs b/.github/scripts/check-changeset-coverage.mjs new file mode 100644 index 0000000..ee68f38 --- /dev/null +++ b/.github/scripts/check-changeset-coverage.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +// Reusable changeset-hygiene check, called from PostHog/.github/.github/workflows/changeset-hygiene.yml. +// +// Compares packages modified in a PR against packages declared in any changeset +// added/modified in that PR, and surfaces issues as a sticky comment. Always +// exits 0 — output is purely informational, the caller workflow does not gate +// the PR. + +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; + +const baseRef = process.env.BASE_REF || 'main'; + +const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim(); + +// 1. Workspace package directories → package names. Use pnpm itself as the +// source of truth so we handle globs, exclusions, and the catalog correctly. +const cwd = process.cwd(); +const workspaceListing = JSON.parse(sh('pnpm m ls --json --depth=-1')); +const dirToName = {}; +for (const entry of workspaceListing) { + if (!entry.name) continue; // workspace root has no name field + if (entry.path === cwd) continue; + const relPath = entry.path.startsWith(cwd + '/') + ? entry.path.slice(cwd.length + 1) + : entry.path; + dirToName[relPath] = entry.name; +} +const knownNames = new Set(Object.values(dirToName)); + +// 2. Diff vs base. +const mergeBase = sh(`git merge-base origin/${baseRef} HEAD`); +const changedFiles = sh(`git diff --name-only ${mergeBase}...HEAD`).split('\n').filter(Boolean); + +// 3. Map changed files → affected packages. +const ignoreSuffixes = ['/CHANGELOG.md', '/package.json']; +const affected = new Set(); +for (const file of changedFiles) { + if (file.startsWith('.changeset/')) continue; + if (ignoreSuffixes.some((s) => file.endsWith(s))) continue; + for (const [dir, name] of Object.entries(dirToName)) { + if (file === dir || file.startsWith(dir + '/')) { + affected.add(name); + break; + } + } +} + +// 4. Find changeset files added or modified in this PR. +const changesetFiles = sh( + `git diff --name-only --diff-filter=AM ${mergeBase}...HEAD -- .changeset/`, +) + .split('\n') + .filter((f) => f.endsWith('.md') && !f.endsWith('README.md')); + +const writeOutput = (body) => { + if (!body) { + process.stdout.write('body=\n'); + } else { + process.stdout.write(`body< !declared.has(n)).sort(); +const extra = [...declared.keys()].filter((n) => !affected.has(n) && knownNames.has(n)).sort(); +const unknownDeclared = [...declared.keys()].filter((n) => !knownNames.has(n)).sort(); +const noChangesetButPackagesModified = changesetFiles.length === 0 && affected.size > 0; + +const hasIssue = + missing.length > 0 || + extra.length > 0 || + unknownDeclared.length > 0 || + noChangesetButPackagesModified; + +if (!hasIssue) { + writeOutput(''); + process.exit(0); +} + +const declaredList = [...declared] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([n, b]) => `- \`${n}\` — ${b}`) + .join('\n'); + +const summary = (() => { + if (noChangesetButPackagesModified) { + const arr = [...affected].sort(); + return arr.length === 1 + ? `\`${arr[0]}\` is modified but this PR has no changeset` + : `${arr.length} packages modified but this PR has no changeset`; + } + const issues = []; + if (missing.length) issues.push(`${missing.length} undeclared`); + if (unknownDeclared.length) issues.push(`${unknownDeclared.length} unknown`); + if (extra.length) issues.push(`${extra.length} extra`); + if (unknownDeclared.length === 1 && !missing.length && !extra.length) { + return `Changeset declares \`${unknownDeclared[0]}\` which isn't a known workspace package`; + } + if (missing.length === 1 && !extra.length && !unknownDeclared.length) { + return `\`${missing[0]}\` is modified but not declared in any changeset`; + } + if (extra.length === 1 && !missing.length && !unknownDeclared.length) { + return `Changeset declares \`${extra[0]}\` but no source files in that package changed`; + } + return `Possible changeset mismatch — ${issues.join(', ')}`; +})(); + +const inner = []; +inner.push( + 'This is informational — the PR is not blocked. Click the triangle above to collapse, or push a fix and this comment will auto-delete.', +); +inner.push(''); + +if (noChangesetButPackagesModified) { + inner.push('**Modified in this PR but no changeset added:**'); + for (const n of [...affected].sort()) inner.push(`- \`${n}\``); + inner.push(''); + inner.push('If this change should ship, run `pnpm changeset` and select a bump level.'); + inner.push( + "If it isn't user-facing (refactor with no behavior change, internal tooling, generated files), no action needed.", + ); +} else { + if (missing.length > 0) { + inner.push('**Modified in this PR but not in any changeset:**'); + for (const n of missing) inner.push(`- \`${n}\``); + inner.push(''); + inner.push('If this package should ship the change, add it to the changeset frontmatter:'); + inner.push(''); + inner.push('```'); + inner.push('---'); + for (const n of missing) inner.push(`"${n}": patch`); + inner.push('---'); + inner.push('```'); + inner.push(''); + } + if (unknownDeclared.length > 0) { + inner.push('**Declared in a changeset but not a known workspace package (typo?):**'); + for (const n of unknownDeclared) inner.push(`- \`${n}\``); + inner.push(''); + const sample = [...knownNames].sort().slice(0, 5); + inner.push( + `Valid workspace package names: \`${sample.join('`, `')}\`${knownNames.size > 5 ? ', …' : ''}`, + ); + inner.push(''); + } + if (extra.length > 0) { + inner.push('**Declared in a changeset but no source files modified:**'); + for (const n of extra) inner.push(`- \`${n}\``); + inner.push(''); + inner.push( + 'Double-check this is intentional — for example, releasing a previously-merged change.', + ); + inner.push(''); + } + if (declared.size > 0) { + inner.push('**Changesets in this PR:**'); + inner.push(declaredList || '_(none)_'); + } +} + +const body = `\n
\n⚠️ ${summary}\n\n${inner.join('\n')}\n\n
`; +writeOutput(body); diff --git a/.github/workflows/changeset-hygiene.yml b/.github/workflows/changeset-hygiene.yml new file mode 100644 index 0000000..9695c0b --- /dev/null +++ b/.github/workflows/changeset-hygiene.yml @@ -0,0 +1,95 @@ +name: 'Changeset hygiene' + +on: + workflow_call: + inputs: + script-ref: + description: 'Ref of PostHog/.github to fetch the script from. Pin only for testing.' + required: false + type: string + default: 'main' + +permissions: + contents: read + pull-requests: write + +jobs: + check: + name: Check changeset hygiene + runs-on: ubuntu-latest + steps: + - name: Checkout caller repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Checkout PostHog/.github (for the script) + uses: actions/checkout@v6 + with: + repository: PostHog/.github + ref: ${{ inputs.script-ref }} + path: _shared + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # pin v4.2.0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Compute hygiene report + id: hygiene + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: node _shared/.github/scripts/check-changeset-coverage.mjs >> "$GITHUB_OUTPUT" + + - name: Upsert or delete sticky PR comment + uses: actions/github-script@v7 + env: + COMMENT_BODY: ${{ steps.hygiene.outputs.body }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = ''; + const body = process.env.COMMENT_BODY || ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find( + (c) => c.user.type === 'Bot' && c.body.includes(marker), + ); + + if (!body) { + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + console.log('Deleted stale changeset-hygiene comment'); + } + return; + } + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + console.log('Updated existing changeset-hygiene comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + console.log('Created changeset-hygiene comment'); + }