Skip to content

Commit d2e7c95

Browse files
Merge pull request #39 from PostHog/feat/changeset-hygiene
ci: reusable changeset-hygiene workflow
2 parents 1e83ebe + ad99d5e commit d2e7c95

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env node
2+
// Reusable changeset-hygiene check, called from PostHog/.github/.github/workflows/changeset-hygiene.yml.
3+
//
4+
// Compares packages modified in a PR against packages declared in any changeset
5+
// added/modified in that PR, and surfaces issues as a sticky comment. Always
6+
// exits 0 — output is purely informational, the caller workflow does not gate
7+
// the PR.
8+
9+
import { execSync } from 'node:child_process';
10+
import { existsSync, readFileSync } from 'node:fs';
11+
12+
const baseRef = process.env.BASE_REF || 'main';
13+
14+
const sh = (cmd) => execSync(cmd, { encoding: 'utf8' }).trim();
15+
16+
// 1. Workspace package directories → package names. Use pnpm itself as the
17+
// source of truth so we handle globs, exclusions, and the catalog correctly.
18+
const cwd = process.cwd();
19+
const workspaceListing = JSON.parse(sh('pnpm m ls --json --depth=-1'));
20+
const dirToName = {};
21+
for (const entry of workspaceListing) {
22+
if (!entry.name) continue; // workspace root has no name field
23+
if (entry.path === cwd) continue;
24+
const relPath = entry.path.startsWith(cwd + '/')
25+
? entry.path.slice(cwd.length + 1)
26+
: entry.path;
27+
dirToName[relPath] = entry.name;
28+
}
29+
const knownNames = new Set(Object.values(dirToName));
30+
31+
// 2. Diff vs base.
32+
const mergeBase = sh(`git merge-base origin/${baseRef} HEAD`);
33+
const changedFiles = sh(`git diff --name-only ${mergeBase}...HEAD`).split('\n').filter(Boolean);
34+
35+
// 3. Map changed files → affected packages.
36+
const ignoreSuffixes = ['/CHANGELOG.md', '/package.json'];
37+
const affected = new Set();
38+
for (const file of changedFiles) {
39+
if (file.startsWith('.changeset/')) continue;
40+
if (ignoreSuffixes.some((s) => file.endsWith(s))) continue;
41+
for (const [dir, name] of Object.entries(dirToName)) {
42+
if (file === dir || file.startsWith(dir + '/')) {
43+
affected.add(name);
44+
break;
45+
}
46+
}
47+
}
48+
49+
// 4. Find changeset files added or modified in this PR.
50+
const changesetFiles = sh(
51+
`git diff --name-only --diff-filter=AM ${mergeBase}...HEAD -- .changeset/`,
52+
)
53+
.split('\n')
54+
.filter((f) => f.endsWith('.md') && !f.endsWith('README.md'));
55+
56+
const writeOutput = (body) => {
57+
if (!body) {
58+
process.stdout.write('body=\n');
59+
} else {
60+
process.stdout.write(`body<<CHANGESET_HYGIENE_EOF\n${body}\nCHANGESET_HYGIENE_EOF\n`);
61+
}
62+
};
63+
64+
// 5. Parse frontmatter from each changeset file.
65+
const declared = new Map();
66+
for (const file of changesetFiles) {
67+
if (!existsSync(file)) continue;
68+
const content = readFileSync(file, 'utf8');
69+
const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
70+
if (!fm) continue;
71+
for (const line of fm[1].split(/\r?\n/)) {
72+
const m = line.match(/^\s*["']?([^"'\s:]+)["']?\s*:\s*(patch|minor|major)\s*$/);
73+
if (m) declared.set(m[1], m[2]);
74+
}
75+
}
76+
77+
// 6. Compare.
78+
const missing = [...affected].filter((n) => !declared.has(n)).sort();
79+
const extra = [...declared.keys()].filter((n) => !affected.has(n) && knownNames.has(n)).sort();
80+
const unknownDeclared = [...declared.keys()].filter((n) => !knownNames.has(n)).sort();
81+
const noChangesetButPackagesModified = changesetFiles.length === 0 && affected.size > 0;
82+
83+
const hasIssue =
84+
missing.length > 0 ||
85+
extra.length > 0 ||
86+
unknownDeclared.length > 0 ||
87+
noChangesetButPackagesModified;
88+
89+
if (!hasIssue) {
90+
writeOutput('');
91+
process.exit(0);
92+
}
93+
94+
const declaredList = [...declared]
95+
.sort(([a], [b]) => a.localeCompare(b))
96+
.map(([n, b]) => `- \`${n}\` — ${b}`)
97+
.join('\n');
98+
99+
const summary = (() => {
100+
if (noChangesetButPackagesModified) {
101+
const arr = [...affected].sort();
102+
return arr.length === 1
103+
? `\`${arr[0]}\` is modified but this PR has no changeset`
104+
: `${arr.length} packages modified but this PR has no changeset`;
105+
}
106+
const issues = [];
107+
if (missing.length) issues.push(`${missing.length} undeclared`);
108+
if (unknownDeclared.length) issues.push(`${unknownDeclared.length} unknown`);
109+
if (extra.length) issues.push(`${extra.length} extra`);
110+
if (unknownDeclared.length === 1 && !missing.length && !extra.length) {
111+
return `Changeset declares \`${unknownDeclared[0]}\` which isn't a known workspace package`;
112+
}
113+
if (missing.length === 1 && !extra.length && !unknownDeclared.length) {
114+
return `\`${missing[0]}\` is modified but not declared in any changeset`;
115+
}
116+
if (extra.length === 1 && !missing.length && !unknownDeclared.length) {
117+
return `Changeset declares \`${extra[0]}\` but no source files in that package changed`;
118+
}
119+
return `Possible changeset mismatch — ${issues.join(', ')}`;
120+
})();
121+
122+
const inner = [];
123+
inner.push(
124+
'This is informational — the PR is not blocked. Click the triangle above to collapse, or push a fix and this comment will auto-delete.',
125+
);
126+
inner.push('');
127+
128+
if (noChangesetButPackagesModified) {
129+
inner.push('**Modified in this PR but no changeset added:**');
130+
for (const n of [...affected].sort()) inner.push(`- \`${n}\``);
131+
inner.push('');
132+
inner.push('If this change should ship, run `pnpm changeset` and select a bump level.');
133+
inner.push(
134+
"If it isn't user-facing (refactor with no behavior change, internal tooling, generated files), no action needed.",
135+
);
136+
} else {
137+
if (missing.length > 0) {
138+
inner.push('**Modified in this PR but not in any changeset:**');
139+
for (const n of missing) inner.push(`- \`${n}\``);
140+
inner.push('');
141+
inner.push('If this package should ship the change, add it to the changeset frontmatter:');
142+
inner.push('');
143+
inner.push('```');
144+
inner.push('---');
145+
for (const n of missing) inner.push(`"${n}": patch`);
146+
inner.push('---');
147+
inner.push('```');
148+
inner.push('');
149+
}
150+
if (unknownDeclared.length > 0) {
151+
inner.push('**Declared in a changeset but not a known workspace package (typo?):**');
152+
for (const n of unknownDeclared) inner.push(`- \`${n}\``);
153+
inner.push('');
154+
const sample = [...knownNames].sort().slice(0, 5);
155+
inner.push(
156+
`Valid workspace package names: \`${sample.join('`, `')}\`${knownNames.size > 5 ? ', …' : ''}`,
157+
);
158+
inner.push('');
159+
}
160+
if (extra.length > 0) {
161+
inner.push('**Declared in a changeset but no source files modified:**');
162+
for (const n of extra) inner.push(`- \`${n}\``);
163+
inner.push('');
164+
inner.push(
165+
'Double-check this is intentional — for example, releasing a previously-merged change.',
166+
);
167+
inner.push('');
168+
}
169+
if (declared.size > 0) {
170+
inner.push('**Changesets in this PR:**');
171+
inner.push(declaredList || '_(none)_');
172+
}
173+
}
174+
175+
const body = `<!-- changeset-hygiene -->\n<details open>\n<summary>⚠️ ${summary}</summary>\n\n${inner.join('\n')}\n\n</details>`;
176+
writeOutput(body);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
name: 'Changeset hygiene'
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
script-ref:
7+
description: 'Ref of PostHog/.github to fetch the script from. Pin only for testing.'
8+
required: false
9+
type: string
10+
default: 'main'
11+
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
16+
jobs:
17+
check:
18+
name: Check changeset hygiene
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Checkout caller repository
22+
uses: actions/checkout@v6
23+
with:
24+
ref: ${{ github.event.pull_request.head.sha }}
25+
fetch-depth: 0
26+
27+
- name: Checkout PostHog/.github (for the script)
28+
uses: actions/checkout@v6
29+
with:
30+
repository: PostHog/.github
31+
ref: ${{ inputs.script-ref }}
32+
path: _shared
33+
34+
- name: Install pnpm
35+
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # pin v4.2.0
36+
37+
- name: Setup Node
38+
uses: actions/setup-node@v6
39+
with:
40+
node-version: '22'
41+
42+
- name: Compute hygiene report
43+
id: hygiene
44+
env:
45+
BASE_REF: ${{ github.event.pull_request.base.ref }}
46+
run: node _shared/.github/scripts/check-changeset-coverage.mjs >> "$GITHUB_OUTPUT"
47+
48+
- name: Upsert or delete sticky PR comment
49+
uses: actions/github-script@v7
50+
env:
51+
COMMENT_BODY: ${{ steps.hygiene.outputs.body }}
52+
with:
53+
github-token: ${{ secrets.GITHUB_TOKEN }}
54+
script: |
55+
const marker = '<!-- changeset-hygiene -->';
56+
const body = process.env.COMMENT_BODY || '';
57+
58+
const { data: comments } = await github.rest.issues.listComments({
59+
owner: context.repo.owner,
60+
repo: context.repo.repo,
61+
issue_number: context.issue.number,
62+
});
63+
const existing = comments.find(
64+
(c) => c.user.type === 'Bot' && c.body.includes(marker),
65+
);
66+
67+
if (!body) {
68+
if (existing) {
69+
await github.rest.issues.deleteComment({
70+
owner: context.repo.owner,
71+
repo: context.repo.repo,
72+
comment_id: existing.id,
73+
});
74+
console.log('Deleted stale changeset-hygiene comment');
75+
}
76+
return;
77+
}
78+
79+
if (existing) {
80+
await github.rest.issues.updateComment({
81+
owner: context.repo.owner,
82+
repo: context.repo.repo,
83+
comment_id: existing.id,
84+
body,
85+
});
86+
console.log('Updated existing changeset-hygiene comment');
87+
} else {
88+
await github.rest.issues.createComment({
89+
owner: context.repo.owner,
90+
repo: context.repo.repo,
91+
issue_number: context.issue.number,
92+
body,
93+
});
94+
console.log('Created changeset-hygiene comment');
95+
}

0 commit comments

Comments
 (0)