-
Notifications
You must be signed in to change notification settings - Fork 1
ci: reusable changeset-hygiene workflow #39
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
Merged
Changes from all commits
Commits
Show all changes
3 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,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<<CHANGESET_HYGIENE_EOF\n${body}\nCHANGESET_HYGIENE_EOF\n`); | ||
| } | ||
| }; | ||
|
|
||
| // 5. Parse frontmatter from each changeset file. | ||
| const declared = new Map(); | ||
| for (const file of changesetFiles) { | ||
| if (!existsSync(file)) continue; | ||
| const content = readFileSync(file, 'utf8'); | ||
| const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); | ||
| if (!fm) continue; | ||
| for (const line of fm[1].split(/\r?\n/)) { | ||
| const m = line.match(/^\s*["']?([^"'\s:]+)["']?\s*:\s*(patch|minor|major)\s*$/); | ||
| if (m) declared.set(m[1], m[2]); | ||
| } | ||
| } | ||
|
|
||
| // 6. Compare. | ||
| 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 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 = `<!-- changeset-hygiene -->\n<details open>\n<summary>⚠️ ${summary}</summary>\n\n${inner.join('\n')}\n\n</details>`; | ||
| writeOutput(body); |
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,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 = '<!-- changeset-hygiene -->'; | ||
| 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'); | ||
| } | ||
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.
nit: use node 24 since it's the current LTS?