From ddcd283ea95b5d5bb14e4e90f774e1b29df78af9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 10:47:39 -0400 Subject: [PATCH 1/5] ci: warn when changesets don't cover modified packages --- .github/workflows/changeset-coverage.yml | 49 ++++++++ scripts/check-changeset-coverage.mjs | 140 +++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 .github/workflows/changeset-coverage.yml create mode 100644 scripts/check-changeset-coverage.mjs diff --git a/.github/workflows/changeset-coverage.yml b/.github/workflows/changeset-coverage.yml new file mode 100644 index 000000000..909d8a0be --- /dev/null +++ b/.github/workflows/changeset-coverage.yml @@ -0,0 +1,49 @@ +name: 'Changeset coverage' + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: changeset-coverage-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check: + name: Check changeset coverage + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Compute coverage report + id: coverage + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: node scripts/check-changeset-coverage.mjs >> "$GITHUB_OUTPUT" + + - name: Post sticky PR comment + if: steps.coverage.outputs.body != '' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + header: changeset-coverage + message: ${{ steps.coverage.outputs.body }} + + - name: Delete stale sticky PR comment + if: steps.coverage.outputs.body == '' + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + header: changeset-coverage + delete: true diff --git a/scripts/check-changeset-coverage.mjs b/scripts/check-changeset-coverage.mjs new file mode 100644 index 000000000..fe8ddace5 --- /dev/null +++ b/scripts/check-changeset-coverage.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// Compares packages modified in this PR against packages declared in any +// changeset added/modified in this PR. Writes a markdown comment body to +// stdout in GITHUB_OUTPUT format. Never exits non-zero — the workflow only +// uses this to surface a warning, not to gate the PR. + +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const baseRef = process.env.BASE_REF || 'main'; + +const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim(); + +// 1. Workspace package directories → package names. +const workspaceFile = readFileSync('pnpm-workspace.yaml', 'utf8'); +const pkgDirs = [...workspaceFile.matchAll(/^\s*-\s*"([^"]+)"\s*$/gm)].map((m) => m[1]); +const dirToName = {}; +for (const dir of pkgDirs) { + const pj = join(dir, 'package.json'); + if (!existsSync(pj)) continue; + const name = JSON.parse(readFileSync(pj, 'utf8')).name; + if (name) dirToName[dir] = 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 declaredList = [...declared] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([n, b]) => `- \`${n}\` — ${b}`) + .join('\n'); + +if (missing.length === 0 && extra.length === 0) { + // Coverage matches — no comment needed. Workflow will delete any stale one. + writeOutput(''); + process.exit(0); +} + +const summary = (() => { + if (missing.length && extra.length) { + return 'Possible changeset mismatch — modified and declared packages differ'; + } + if (missing.length === 1) { + return `\`${missing[0]}\` is modified but not declared in any changeset`; + } + if (missing.length > 1) { + return `${missing.length} packages modified but not declared in any changeset`; + } + if (extra.length === 1) { + return `Changeset declares \`${extra[0]}\` but no source files in that package changed`; + } + return 'Changesets declare packages with no source changes in this PR'; +})(); + +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 (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 (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(''); +} +inner.push('**Changesets in this PR:**'); +inner.push(declaredList || '_(none)_'); + +const body = `
\n⚠️ ${summary}\n\n${inner.join('\n')}\n\n
`; +writeOutput(body); From ec23baef274e2c73449dc4a3ef898fddf36960cd Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 11:05:43 -0400 Subject: [PATCH 2/5] ci: use `pnpm m ls` for workspace discovery (portable to posthog-js) --- .github/workflows/changeset-coverage.yml | 6 +++--- scripts/check-changeset-coverage.mjs | 20 +++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/changeset-coverage.yml b/.github/workflows/changeset-coverage.yml index 909d8a0be..1290dd49d 100644 --- a/.github/workflows/changeset-coverage.yml +++ b/.github/workflows/changeset-coverage.yml @@ -23,10 +23,10 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Setup Node - uses: actions/setup-node@v4 + - name: Setup environment + uses: ./.github/actions/setup with: - node-version: 22 + install: false - name: Compute coverage report id: coverage diff --git a/scripts/check-changeset-coverage.mjs b/scripts/check-changeset-coverage.mjs index fe8ddace5..a328133c2 100644 --- a/scripts/check-changeset-coverage.mjs +++ b/scripts/check-changeset-coverage.mjs @@ -6,21 +6,23 @@ import { execSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; const baseRef = process.env.BASE_REF || 'main'; const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim(); -// 1. Workspace package directories → package names. -const workspaceFile = readFileSync('pnpm-workspace.yaml', 'utf8'); -const pkgDirs = [...workspaceFile.matchAll(/^\s*-\s*"([^"]+)"\s*$/gm)].map((m) => m[1]); +// 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 dir of pkgDirs) { - const pj = join(dir, 'package.json'); - if (!existsSync(pj)) continue; - const name = JSON.parse(readFileSync(pj, 'utf8')).name; - if (name) dirToName[dir] = name; +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)); From 2808edb052515b45b7ba82166cd3fa1d398a92ef Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 12:15:34 -0400 Subject: [PATCH 3/5] ci: switch to actions/github-script + add no-changeset and typo warnings - Replaces marocchino/sticky-pull-request-comment with inline github-script, matching the convention in posthog-js's check-posthog-major-version.yml. - Adds 'no changeset at all' warning when source files in workspace packages are modified but the PR has no changeset entries. - Adds 'unknown package name' warning when a changeset declares a name not present in the workspace (likely typo). - Pins pull_request trigger types to [opened, synchronize, reopened]. --- .github/workflows/changeset-coverage.yml | 58 +++++++++--- scripts/check-changeset-coverage.mjs | 107 ++++++++++++++--------- 2 files changed, 114 insertions(+), 51 deletions(-) diff --git a/.github/workflows/changeset-coverage.yml b/.github/workflows/changeset-coverage.yml index 1290dd49d..676bdfbe3 100644 --- a/.github/workflows/changeset-coverage.yml +++ b/.github/workflows/changeset-coverage.yml @@ -3,6 +3,7 @@ name: 'Changeset coverage' on: pull_request: branches: [main] + types: [opened, synchronize, reopened] permissions: contents: read @@ -34,16 +35,51 @@ jobs: BASE_REF: ${{ github.event.pull_request.base.ref }} run: node scripts/check-changeset-coverage.mjs >> "$GITHUB_OUTPUT" - - name: Post sticky PR comment - if: steps.coverage.outputs.body != '' - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + - name: Upsert or delete sticky PR comment + uses: actions/github-script@v7 + env: + COMMENT_BODY: ${{ steps.coverage.outputs.body }} with: - header: changeset-coverage - message: ${{ steps.coverage.outputs.body }} + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const marker = ''; + const body = process.env.COMMENT_BODY || ''; - - name: Delete stale sticky PR comment - if: steps.coverage.outputs.body == '' - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 - with: - header: changeset-coverage - delete: true + 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-coverage 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-coverage 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-coverage comment'); + } diff --git a/scripts/check-changeset-coverage.mjs b/scripts/check-changeset-coverage.mjs index a328133c2..6c204e077 100644 --- a/scripts/check-changeset-coverage.mjs +++ b/scripts/check-changeset-coverage.mjs @@ -59,11 +59,6 @@ const writeOutput = (body) => { } }; -if (changesetFiles.length === 0) { - writeOutput(''); - process.exit(0); -} - // 5. Parse frontmatter from each changeset file. const declared = new Map(); for (const file of changesetFiles) { @@ -80,32 +75,46 @@ for (const file of changesetFiles) { // 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 declaredList = [...declared] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([n, b]) => `- \`${n}\` — ${b}`) - .join('\n'); +const hasIssue = + missing.length > 0 || + extra.length > 0 || + unknownDeclared.length > 0 || + noChangesetButPackagesModified; -if (missing.length === 0 && extra.length === 0) { - // Coverage matches — no comment needed. Workflow will delete any stale one. +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 (missing.length && extra.length) { - return 'Possible changeset mismatch — modified and declared packages differ'; + 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`; } - if (missing.length === 1) { - return `\`${missing[0]}\` is modified but not declared in any 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) { - return `${missing.length} packages modified but not declared in any changeset`; + if (missing.length === 1 && !extra.length && !unknownDeclared.length) { + return `\`${missing[0]}\` is modified but not declared in any changeset`; } - if (extra.length === 1) { + if (extra.length === 1 && !missing.length && !unknownDeclared.length) { return `Changeset declares \`${extra[0]}\` but no source files in that package changed`; } - return 'Changesets declare packages with no source changes in this PR'; + return `Possible changeset mismatch — ${issues.join(', ')}`; })(); const inner = []; @@ -113,30 +122,48 @@ 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 (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 (extra.length > 0) { - inner.push('**Declared in a changeset but no source files modified:**'); - for (const n of extra) inner.push(`- \`${n}\``); +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( - 'Double-check this is intentional — for example, releasing a previously-merged change.', + "If it isn't user-facing (refactor with no behavior change, internal tooling, generated files), no action needed.", ); - inner.push(''); +} 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(''); + } + inner.push('**Changesets in this PR:**'); + inner.push(declaredList || '_(none)_'); } -inner.push('**Changesets in this PR:**'); -inner.push(declaredList || '_(none)_'); -const body = `
\n⚠️ ${summary}\n\n${inner.join('\n')}\n\n
`; +const body = `\n
\n⚠️ ${summary}\n\n${inner.join('\n')}\n\n
`; writeOutput(body); From a096e26b2eece221cab9e985c9481df854d1274a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 12:57:52 -0400 Subject: [PATCH 4/5] ci: replace local workflow with caller for PostHog/.github shared workflow Removes the local 169-line script and 85-line workflow in favor of a tiny 22-line caller delegating to PostHog/.github's reusable changeset-hygiene workflow (PostHog/.github#39). Same behavior, single source of truth across SDKs. Pinned to feat/changeset-hygiene while PostHog/.github#39 is open; will update to @main once that merges. --- .github/workflows/changeset-coverage.yml | 85 ------------ .github/workflows/changeset-hygiene.yml | 22 +++ scripts/check-changeset-coverage.mjs | 169 ----------------------- 3 files changed, 22 insertions(+), 254 deletions(-) delete mode 100644 .github/workflows/changeset-coverage.yml create mode 100644 .github/workflows/changeset-hygiene.yml delete mode 100644 scripts/check-changeset-coverage.mjs diff --git a/.github/workflows/changeset-coverage.yml b/.github/workflows/changeset-coverage.yml deleted file mode 100644 index 676bdfbe3..000000000 --- a/.github/workflows/changeset-coverage.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: 'Changeset coverage' - -on: - pull_request: - branches: [main] - types: [opened, synchronize, reopened] - -permissions: - contents: read - pull-requests: write - -concurrency: - group: changeset-coverage-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - check: - name: Check changeset coverage - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - - name: Setup environment - uses: ./.github/actions/setup - with: - install: false - - - name: Compute coverage report - id: coverage - env: - BASE_REF: ${{ github.event.pull_request.base.ref }} - run: node scripts/check-changeset-coverage.mjs >> "$GITHUB_OUTPUT" - - - name: Upsert or delete sticky PR comment - uses: actions/github-script@v7 - env: - COMMENT_BODY: ${{ steps.coverage.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-coverage 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-coverage 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-coverage comment'); - } diff --git a/.github/workflows/changeset-hygiene.yml b/.github/workflows/changeset-hygiene.yml new file mode 100644 index 000000000..4002bd358 --- /dev/null +++ b/.github/workflows/changeset-hygiene.yml @@ -0,0 +1,22 @@ +name: 'Changeset hygiene' + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: changeset-hygiene-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check: + # NOTE: while PostHog/.github#39 is open, pin to feat/changeset-hygiene. + # Update both `uses:` and `script-ref` to `@main` once that PR merges. + uses: PostHog/.github/.github/workflows/changeset-hygiene.yml@feat/changeset-hygiene + with: + script-ref: feat/changeset-hygiene diff --git a/scripts/check-changeset-coverage.mjs b/scripts/check-changeset-coverage.mjs deleted file mode 100644 index 6c204e077..000000000 --- a/scripts/check-changeset-coverage.mjs +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env node -// Compares packages modified in this PR against packages declared in any -// changeset added/modified in this PR. Writes a markdown comment body to -// stdout in GITHUB_OUTPUT format. Never exits non-zero — the workflow only -// uses this to surface a warning, not to 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(''); - } - inner.push('**Changesets in this PR:**'); - inner.push(declaredList || '_(none)_'); -} - -const body = `\n
\n⚠️ ${summary}\n\n${inner.join('\n')}\n\n
`; -writeOutput(body); From d4000372b2753439cfc4eba2d77433de90eabe72 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 4 May 2026 13:32:16 -0400 Subject: [PATCH 5/5] ci: pin to PostHog/.github@main now that the shared workflow has merged --- .github/workflows/changeset-hygiene.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/changeset-hygiene.yml b/.github/workflows/changeset-hygiene.yml index 4002bd358..9f935a686 100644 --- a/.github/workflows/changeset-hygiene.yml +++ b/.github/workflows/changeset-hygiene.yml @@ -15,8 +15,4 @@ concurrency: jobs: check: - # NOTE: while PostHog/.github#39 is open, pin to feat/changeset-hygiene. - # Update both `uses:` and `script-ref` to `@main` once that PR merges. - uses: PostHog/.github/.github/workflows/changeset-hygiene.yml@feat/changeset-hygiene - with: - script-ref: feat/changeset-hygiene + uses: PostHog/.github/.github/workflows/changeset-hygiene.yml@main