From 8b56b7f7edadb0742cb65b83779ae2af23dcf4fb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 12:37:24 -0400 Subject: [PATCH 1/3] feat: add reusable changeset-hygiene workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable workflow + script that any pnpm/changesets monorepo can call to surface changeset issues as a sticky PR comment. Never blocks the PR — output is informational. Detects: - packages modified but not declared in any changeset - changesets declaring packages that don't exist in the workspace (typo) - changesets declaring packages with no source changes - source modified but no changeset added at all - (opt-in via forbidden-major-packages input) major bumps for autoload-style packages where breaking changes break consumers --- .github/scripts/check-changeset-coverage.mjs | 202 +++++++++++++++++++ .github/workflows/changeset-hygiene.yml | 111 ++++++++++ 2 files changed, 313 insertions(+) create mode 100644 .github/scripts/check-changeset-coverage.mjs create mode 100644 .github/workflows/changeset-hygiene.yml diff --git a/.github/scripts/check-changeset-coverage.mjs b/.github/scripts/check-changeset-coverage.mjs new file mode 100644 index 0000000..f7a3daa --- /dev/null +++ b/.github/scripts/check-changeset-coverage.mjs @@ -0,0 +1,202 @@ +#!/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. Set FORBIDDEN_MAJOR_PACKAGES (comma-separated) to surface a warning +// when a changeset declares `: major` for one of those packages, e.g. +// for autoload-style packages where major bumps would break consumers. + +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; + +const baseRef = process.env.BASE_REF || 'main'; +const forbiddenMajorPackages = (process.env.FORBIDDEN_MAJOR_PACKAGES || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +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 forbiddenMajorHits = forbiddenMajorPackages.filter( + (name) => declared.get(name) === 'major', +); + +const hasIssue = + missing.length > 0 || + extra.length > 0 || + unknownDeclared.length > 0 || + noChangesetButPackagesModified || + forbiddenMajorHits.length > 0; + +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 (forbiddenMajorHits.length === 1 && !missing.length && !extra.length && !unknownDeclared.length) { + return `\`${forbiddenMajorHits[0]}\` should not receive a major bump`; + } + if (forbiddenMajorHits.length > 0) { + return `Forbidden major bump${forbiddenMajorHits.length > 1 ? 's' : ''} declared (${forbiddenMajorHits.length}) plus other issues`; + } + 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 (forbiddenMajorHits.length > 0) { + inner.push('**Major bump declared for a package that should never get one:**'); + for (const n of forbiddenMajorHits) inner.push(`- \`${n}\``); + inner.push(''); + inner.push( + "These packages are configured (via `forbidden-major-packages` in the workflow) to never receive a major bump — typically because they're autoloaded or pinned by consumers and a breaking change would break everyone. Change the bump level to `minor` or `patch`, or reconsider whether the change can be made backwards-compatible.", + ); + 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..be3a9ba --- /dev/null +++ b/.github/workflows/changeset-hygiene.yml @@ -0,0 +1,111 @@ +name: 'Changeset hygiene' + +# Reusable workflow that surfaces changeset issues as a sticky PR comment. +# Never blocks merging — output is informational. See README for caller setup. +# +# Inputs: +# forbidden-major-packages — comma-separated list of package names that +# should never receive a major bump (e.g., autoload-style packages where +# a breaking change would break every consumer). +# script-ref — ref of PostHog/.github to fetch the script from. Defaults +# to main; override only when iterating on the workflow itself. + +on: + workflow_call: + inputs: + forbidden-major-packages: + description: 'Comma-separated package names that should warn on major bumps.' + required: false + type: string + default: '' + 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 }} + FORBIDDEN_MAJOR_PACKAGES: ${{ inputs.forbidden-major-packages }} + 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'); + } From fb5807f8fff251add4465ca004b7a955692a6979 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 12:50:09 -0400 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20drop=20forbidden-major-packages=20?= =?UTF-8?q?=E2=80=94=20coverage=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/check-changeset-coverage.mjs | 28 +------------------- .github/workflows/changeset-hygiene.yml | 16 ----------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/.github/scripts/check-changeset-coverage.mjs b/.github/scripts/check-changeset-coverage.mjs index f7a3daa..ad280ad 100644 --- a/.github/scripts/check-changeset-coverage.mjs +++ b/.github/scripts/check-changeset-coverage.mjs @@ -4,18 +4,12 @@ // 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. Set FORBIDDEN_MAJOR_PACKAGES (comma-separated) to surface a warning -// when a changeset declares `: major` for one of those packages, e.g. -// for autoload-style packages where major bumps would break consumers. +// the PR. import { execSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; const baseRef = process.env.BASE_REF || 'main'; -const forbiddenMajorPackages = (process.env.FORBIDDEN_MAJOR_PACKAGES || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim(); @@ -85,15 +79,11 @@ const missing = [...affected].filter((n) => !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 forbiddenMajorHits = forbiddenMajorPackages.filter( - (name) => declared.get(name) === 'major', -); const hasIssue = missing.length > 0 || extra.length > 0 || unknownDeclared.length > 0 || - noChangesetButPackagesModified || forbiddenMajorHits.length > 0; if (!hasIssue) { @@ -107,12 +97,6 @@ const declaredList = [...declared] .join('\n'); const summary = (() => { - if (forbiddenMajorHits.length === 1 && !missing.length && !extra.length && !unknownDeclared.length) { - return `\`${forbiddenMajorHits[0]}\` should not receive a major bump`; - } - if (forbiddenMajorHits.length > 0) { - return `Forbidden major bump${forbiddenMajorHits.length > 1 ? 's' : ''} declared (${forbiddenMajorHits.length}) plus other issues`; - } if (noChangesetButPackagesModified) { const arr = [...affected].sort(); return arr.length === 1 @@ -141,16 +125,6 @@ inner.push( ); inner.push(''); -if (forbiddenMajorHits.length > 0) { - inner.push('**Major bump declared for a package that should never get one:**'); - for (const n of forbiddenMajorHits) inner.push(`- \`${n}\``); - inner.push(''); - inner.push( - "These packages are configured (via `forbidden-major-packages` in the workflow) to never receive a major bump — typically because they're autoloaded or pinned by consumers and a breaking change would break everyone. Change the bump level to `minor` or `patch`, or reconsider whether the change can be made backwards-compatible.", - ); - inner.push(''); -} - if (noChangesetButPackagesModified) { inner.push('**Modified in this PR but no changeset added:**'); for (const n of [...affected].sort()) inner.push(`- \`${n}\``); diff --git a/.github/workflows/changeset-hygiene.yml b/.github/workflows/changeset-hygiene.yml index be3a9ba..9695c0b 100644 --- a/.github/workflows/changeset-hygiene.yml +++ b/.github/workflows/changeset-hygiene.yml @@ -1,23 +1,8 @@ name: 'Changeset hygiene' -# Reusable workflow that surfaces changeset issues as a sticky PR comment. -# Never blocks merging — output is informational. See README for caller setup. -# -# Inputs: -# forbidden-major-packages — comma-separated list of package names that -# should never receive a major bump (e.g., autoload-style packages where -# a breaking change would break every consumer). -# script-ref — ref of PostHog/.github to fetch the script from. Defaults -# to main; override only when iterating on the workflow itself. - on: workflow_call: inputs: - forbidden-major-packages: - description: 'Comma-separated package names that should warn on major bumps.' - required: false - type: string - default: '' script-ref: description: 'Ref of PostHog/.github to fetch the script from. Pin only for testing.' required: false @@ -58,7 +43,6 @@ jobs: id: hygiene env: BASE_REF: ${{ github.event.pull_request.base.ref }} - FORBIDDEN_MAJOR_PACKAGES: ${{ inputs.forbidden-major-packages }} run: node _shared/.github/scripts/check-changeset-coverage.mjs >> "$GITHUB_OUTPUT" - name: Upsert or delete sticky PR comment From ad99d5eee23b6229b395fd2deafe3880dc91b96b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 12:59:19 -0400 Subject: [PATCH 3/3] fix: remove lingering forbiddenMajorHits reference --- .github/scripts/check-changeset-coverage.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/check-changeset-coverage.mjs b/.github/scripts/check-changeset-coverage.mjs index ad280ad..ee68f38 100644 --- a/.github/scripts/check-changeset-coverage.mjs +++ b/.github/scripts/check-changeset-coverage.mjs @@ -84,7 +84,7 @@ const hasIssue = missing.length > 0 || extra.length > 0 || unknownDeclared.length > 0 || - forbiddenMajorHits.length > 0; + noChangesetButPackagesModified; if (!hasIssue) { writeOutput('');