Skip to content

Commit 1fb05c6

Browse files
authored
fix: map bundled sources to their release package in changeset hygiene (#74)
1 parent 5ba5d24 commit 1fb05c6

4 files changed

Lines changed: 273 additions & 29 deletions

File tree

‎.github/scripts/check-changeset-coverage.mjs‎

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,55 @@ for (const entry of workspaceListing) {
2828
}
2929
const knownNames = new Set(Object.values(dirToName));
3030

31+
// Optional per-repo hygiene config (.changeset/hygiene.json, all keys optional):
32+
// {
33+
// "releasePackagePaths": { "packages/rrweb/": "posthog-js" },
34+
// "transitiveReExports": { "<source-pkg>": ["<re-exporter>"] }
35+
// }
36+
//
37+
// releasePackagePaths replaces workspace ownership for files under a repo-relative
38+
// directory with the package that ships them. Targets must be known workspace
39+
// packages. Trailing slashes are optional; the most specific directory wins.
40+
// This makes bundled sources require only the shipping package's changeset,
41+
// without incorrectly reporting that changeset as extra.
42+
//
43+
// transitiveReExports allows a re-exporter's changeset when its source package
44+
// has both source changes and a changeset (e.g. Gradle api(project(":x"))).
45+
let transitiveReExports = {};
46+
let releasePackagePaths = [];
47+
const hygieneConfigPath = '.changeset/hygiene.json';
48+
if (existsSync(hygieneConfigPath)) {
49+
try {
50+
const cfg = JSON.parse(readFileSync(hygieneConfigPath, 'utf8'));
51+
if (cfg.transitiveReExports && typeof cfg.transitiveReExports === 'object') {
52+
transitiveReExports = cfg.transitiveReExports;
53+
}
54+
if (
55+
cfg.releasePackagePaths &&
56+
typeof cfg.releasePackagePaths === 'object' &&
57+
!Array.isArray(cfg.releasePackagePaths)
58+
) {
59+
releasePackagePaths = Object.entries(cfg.releasePackagePaths)
60+
.map(([dir, name]) => [dir.replace(/\/$/, ''), name])
61+
.filter(([dir, name]) => {
62+
if (
63+
dir.split('/').some((part) => !part || part === '.' || part === '..') ||
64+
!knownNames.has(name)
65+
) {
66+
process.stderr.write(
67+
`Ignoring invalid releasePackagePaths entry in ${hygieneConfigPath}: ${dir} -> ${name}\n`,
68+
);
69+
return false;
70+
}
71+
return true;
72+
})
73+
.sort(([a], [b]) => b.length - a.length);
74+
}
75+
} catch (e) {
76+
process.stderr.write(`Could not parse ${hygieneConfigPath}: ${e.message}\n`);
77+
}
78+
}
79+
3180
// 2. Diff vs base.
3281
const mergeBase = sh(`git merge-base origin/${baseRef} HEAD`);
3382
const changedFiles = sh(`git diff --name-only ${mergeBase}...HEAD`).split('\n').filter(Boolean);
@@ -38,6 +87,13 @@ const affected = new Set();
3887
for (const file of changedFiles) {
3988
if (file.startsWith('.changeset/')) continue;
4089
if (ignoreSuffixes.some((s) => file.endsWith(s))) continue;
90+
const releasePackage = releasePackagePaths.find(
91+
([dir]) => file === dir || file.startsWith(dir + '/'),
92+
);
93+
if (releasePackage) {
94+
affected.add(releasePackage[1]);
95+
continue;
96+
}
4197
for (const [dir, name] of Object.entries(dirToName)) {
4298
if (file === dir || file.startsWith(dir + '/')) {
4399
affected.add(name);
@@ -53,34 +109,6 @@ const changesetFiles = sh(
53109
.split('\n')
54110
.filter((f) => f.endsWith('.md') && !f.endsWith('README.md'));
55111

56-
// 4.5. Optional per-repo hygiene config for transitive re-exports.
57-
//
58-
// Schema (.changeset/hygiene.json, all keys optional):
59-
// {
60-
// "transitiveReExports": {
61-
// "<source-pkg>": ["<re-exporter-1>", "<re-exporter-2>"]
62-
// }
63-
// }
64-
//
65-
// When <source-pkg> has source changes AND is declared in a changeset on this
66-
// PR, declaring any of its re-exporters is treated as legitimate even if no
67-
// source files in that re-exporter changed. Use for cases the workspace graph
68-
// can't see — e.g. Gradle `api(project(":x"))` re-exports where a downstream
69-
// artifact must be republished to deliver an upstream core change to its own
70-
// consumers.
71-
let transitiveReExports = {};
72-
const hygieneConfigPath = '.changeset/hygiene.json';
73-
if (existsSync(hygieneConfigPath)) {
74-
try {
75-
const cfg = JSON.parse(readFileSync(hygieneConfigPath, 'utf8'));
76-
if (cfg.transitiveReExports && typeof cfg.transitiveReExports === 'object') {
77-
transitiveReExports = cfg.transitiveReExports;
78-
}
79-
} catch (e) {
80-
process.stderr.write(`Could not parse ${hygieneConfigPath}: ${e.message}\n`);
81-
}
82-
}
83-
84112
const writeOutput = (body) => {
85113
if (!body) {
86114
process.stdout.write('body=\n');
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import assert from 'node:assert/strict';
2+
import { execFileSync } from 'node:child_process';
3+
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { dirname, join } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import test from 'node:test';
8+
9+
const script = fileURLToPath(new URL('./check-changeset-coverage.mjs', import.meta.url));
10+
const packages = {
11+
'packages/browser': 'posthog-js',
12+
'packages/rrweb/rrweb': '@posthog/rrweb',
13+
'packages/rrweb/types': '@posthog/rrweb-types',
14+
'packages/rrweb-extra': 'rrweb-extra',
15+
'packages/node': 'posthog-node',
16+
};
17+
const mapping = { releasePackagePaths: { 'packages/rrweb/': 'posthog-js' } };
18+
19+
function report(t, { files = [], declared = [], config } = {}) {
20+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'changeset-hygiene-')));
21+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
22+
function write(path, content) {
23+
mkdirSync(dirname(join(cwd, path)), { recursive: true });
24+
writeFileSync(join(cwd, path), content);
25+
}
26+
const git = (...args) => execFileSync('git', args, { cwd, stdio: 'pipe' });
27+
git('init', '-b', 'main');
28+
git('config', 'user.email', 'test@example.com');
29+
git('config', 'user.name', 'Test');
30+
git('config', 'commit.gpgsign', 'false');
31+
git('config', 'core.hooksPath', '/dev/null');
32+
// Stub only workspace discovery. Exercise the real script, git diff and frontmatter parser.
33+
write(
34+
'bin/pnpm',
35+
`#!/bin/sh\nprintf '%s\\n' '${JSON.stringify(
36+
Object.entries(packages).map(([path, name]) => ({ path: join(cwd, path), name })),
37+
)}'\n`,
38+
);
39+
execFileSync('chmod', ['+x', join(cwd, 'bin/pnpm')]);
40+
if (config) write('.changeset/hygiene.json', JSON.stringify(config));
41+
git('add', '.');
42+
git('commit', '-m', 'base');
43+
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
44+
for (const file of files) write(file, 'changed\n');
45+
if (declared.length) {
46+
write(
47+
'.changeset/change.md',
48+
`---\n${declared.map((n) => `'${n}': patch`).join('\n')}\n---\n\nChange\n`,
49+
);
50+
}
51+
git('add', '.');
52+
git('commit', '--allow-empty', '-m', 'change');
53+
return execFileSync(process.execPath, [script], {
54+
cwd,
55+
encoding: 'utf8',
56+
env: { ...process.env, BASE_REF: 'main', PATH: `${cwd}/bin:${process.env.PATH}` },
57+
});
58+
}
59+
60+
test('rrweb-only changes are covered by a browser changeset', (t) => {
61+
assert.equal(
62+
report(t, {
63+
files: ['packages/rrweb/rrweb/src/index.ts', 'packages/rrweb/types/src/index.ts'],
64+
declared: ['posthog-js'],
65+
config: mapping,
66+
}),
67+
'body=\n',
68+
);
69+
});
70+
71+
test('rrweb changes without a changeset request only the browser package', (t) => {
72+
const body = report(t, { files: ['packages/rrweb/rrweb/src/index.ts'], config: mapping });
73+
assert.match(body, /`posthog-js` is modified but this PR has no changeset/);
74+
assert.doesNotMatch(body, /@posthog\/rrweb/);
75+
});
76+
77+
test('an rrweb changeset does not satisfy the browser release requirement', (t) => {
78+
const body = report(t, {
79+
files: ['packages/rrweb/rrweb/src/index.ts'],
80+
declared: ['@posthog/rrweb'],
81+
config: mapping,
82+
});
83+
assert.match(body, /"posthog-js": patch/);
84+
assert.match(
85+
body,
86+
/\*\*Declared in a changeset but no source files modified:\*\*\n- `@posthog\/rrweb`/,
87+
);
88+
});
89+
90+
test('unmapped packages still need their own changesets', (t) => {
91+
const body = report(t, {
92+
files: ['packages/rrweb/rrweb/src/index.ts', 'packages/node/src/index.ts'],
93+
declared: ['posthog-js'],
94+
config: mapping,
95+
});
96+
assert.match(body, /`posthog-node` is modified but not declared/);
97+
assert.doesNotMatch(body, /@posthog\/rrweb/);
98+
});
99+
100+
test('path matching respects directory boundaries', (t) => {
101+
const body = report(t, { files: ['packages/rrweb-extra/src/index.ts'], config: mapping });
102+
assert.match(body, /`rrweb-extra` is modified/);
103+
assert.doesNotMatch(body, /posthog-js/);
104+
});
105+
106+
test('the most specific mapping wins, with or without trailing slashes', (t) => {
107+
assert.equal(
108+
report(t, {
109+
files: ['packages/rrweb/rrweb/src/index.ts', 'packages/rrweb/types/src/index.ts'],
110+
declared: ['posthog-js', 'posthog-node'],
111+
config: {
112+
releasePackagePaths: {
113+
'packages/rrweb': 'posthog-js',
114+
'packages/rrweb/types/': 'posthog-node',
115+
},
116+
},
117+
}),
118+
'body=\n',
119+
);
120+
});
121+
122+
test('changelog and manifest changes remain ignored under mapped paths', (t) => {
123+
assert.equal(
124+
report(t, {
125+
files: ['packages/rrweb/rrweb/CHANGELOG.md', 'packages/rrweb/types/package.json'],
126+
config: mapping,
127+
}),
128+
'body=\n',
129+
);
130+
});
131+
132+
test('no config preserves normal workspace coverage', (t) => {
133+
assert.equal(
134+
report(t, {
135+
files: ['packages/rrweb/rrweb/src/index.ts'],
136+
declared: ['@posthog/rrweb'],
137+
}),
138+
'body=\n',
139+
);
140+
});
141+
142+
test('transitive re-export config still works', (t) => {
143+
assert.equal(
144+
report(t, {
145+
files: ['packages/node/src/index.ts'],
146+
declared: ['posthog-node', 'posthog-js'],
147+
config: { transitiveReExports: { 'posthog-node': ['posthog-js'] } },
148+
}),
149+
'body=\n',
150+
);
151+
});
152+
153+
test('browser and bundled source changes share one changeset', (t) => {
154+
assert.equal(
155+
report(t, {
156+
files: ['packages/browser/src/index.ts', 'packages/rrweb/rrweb/src/index.ts'],
157+
declared: ['posthog-js'],
158+
config: mapping,
159+
}),
160+
'body=\n',
161+
);
162+
});
163+
164+
for (const [path, target] of [
165+
['packages/rrweb/', 'typo'],
166+
['', 'posthog-js'],
167+
['../packages/rrweb', 'posthog-js'],
168+
]) {
169+
test(`invalid mapping ${path} -> ${target} keeps workspace coverage`, (t) => {
170+
assert.equal(
171+
report(t, {
172+
files: ['packages/rrweb/rrweb/src/index.ts'],
173+
declared: ['@posthog/rrweb'],
174+
config: { releasePackagePaths: { [path]: target } },
175+
}),
176+
'body=\n',
177+
);
178+
});
179+
}
180+
181+
test('browser changesets without source changes are still reported as extra', (t) => {
182+
assert.match(
183+
report(t, { declared: ['posthog-js'], config: mapping }),
184+
/Changeset declares `posthog-js` but no source files in that package changed/,
185+
);
186+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Changeset hygiene tests
2+
3+
on:
4+
pull_request:
5+
merge_group:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 5
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17+
18+
- name: Setup Node
19+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
20+
with:
21+
node-version: '22'
22+
23+
- name: Test changeset coverage
24+
run: node --test .github/scripts/check-changeset-coverage.test.mjs

‎AGENTS.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ There is no build step and no app. Changes are config (YAML workflows, semgrep r
1818

1919
## Testing
2020

21-
No general test suite. The one locally runnable thing is the semgrep rule tests:
21+
No general test suite. Run the changeset hygiene script tests with:
22+
23+
```bash
24+
node --test .github/scripts/check-changeset-coverage.test.mjs
25+
```
26+
27+
The semgrep rule tests run with:
2228

2329
```bash
2430
semgrep --test .semgrep/

0 commit comments

Comments
 (0)