Skip to content

Commit a67e0e6

Browse files
committed
ci: catch a changeset edited up to major, not only a new one
The gate listed added changeset files, so a pull request could raise an existing changeset from minor to major and pass unchallenged. List every changeset the pull request adds, edits, or renames into place, and compare each one against the base branch: a file counts only when it declares major now and did not already declare it there. Touching a major that a maintainer already approved no longer asks for the label twice. Pin the workflow's actions to commit SHAs so the gate cannot be changed by repointing a tag.
1 parent 00ed085 commit a67e0e6

2 files changed

Lines changed: 107 additions & 27 deletions

File tree

.github/workflows/changeset-policy.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ jobs:
2020
timeout-minutes: 5
2121

2222
steps:
23-
- uses: actions/checkout@v4
23+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
2424
with:
2525
# The gate diffs against the base commit, which a shallow clone of
2626
# the merge ref alone does not contain.
2727
fetch-depth: 0
2828

29-
- uses: actions/setup-node@v7
29+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
3030
with:
3131
node-version-file: .nvmrc
3232

scripts/release/check-major-changeset.mjs

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const APPROVAL_LABEL = 'breaking-change-approved';
3131
* @returns The declared levels, lowercased, in file order.
3232
*/
3333
export function parseBumpLevels(source) {
34-
const normalized = source.replaceAll(/\r\n/gu, '\n');
34+
const normalized = source.replaceAll('\r\n', '\n');
3535
if (!normalized.startsWith('---\n')) return [];
3636
const end = normalized.indexOf('\n---', 3);
3737
if (end === -1) return [];
@@ -53,15 +53,24 @@ export function isChangesetFile(path) {
5353
/**
5454
* Decide whether the gate passes.
5555
*
56-
* @param input.addedFiles - Paths added by the pull request.
56+
* A changeset counts against the pull request when it declares `major` now and
57+
* did not already declare it on the base branch. Editing an existing changeset
58+
* up to `major` therefore counts, while merely touching one that was already
59+
* approved does not ask for the label a second time.
60+
*
61+
* @param input.changedFiles - Changeset paths the pull request adds or edits.
5762
* @param input.labels - Label names on the pull request.
58-
* @param input.readFile - Reads one path; injected so this stays pure.
63+
* @param input.readFile - Reads one path at the pull request head.
64+
* @param input.readBaseFile - Reads one path on the base branch, or undefined
65+
* when the path does not exist there. Injected so this stays pure.
5966
* @returns The offending changesets and whether they are approved.
6067
*/
6168
export function evaluate(input) {
62-
const majors = input.addedFiles
63-
.filter(isChangesetFile)
64-
.filter((path) => parseBumpLevels(input.readFile(path)).includes('major'));
69+
const majors = input.changedFiles.filter(isChangesetFile).filter((path) => {
70+
if (!parseBumpLevels(input.readFile(path)).includes('major')) return false;
71+
const base = input.readBaseFile(path);
72+
return base === undefined || !parseBumpLevels(base).includes('major');
73+
});
6574
const approved = input.labels.includes(APPROVAL_LABEL);
6675
return { majors, approved, ok: majors.length === 0 || approved };
6776
}
@@ -87,22 +96,76 @@ function selfTest() {
8796
}
8897
}
8998

90-
const files = { '.changeset/a.md': '---\n"a": major\n---\n\nText.\n' };
99+
const MAJOR = '---\n"a": major\n---\n\nText.\n';
100+
const MINOR = '---\n"a": minor\n---\n\nText.\n';
101+
const files = { '.changeset/a.md': MAJOR };
91102
const readFile = (path) => files[path];
92-
const blocked = evaluate({ addedFiles: ['.changeset/a.md'], labels: [], readFile });
93-
if (blocked.ok || blocked.majors.length !== 1) {
94-
console.error('self-test FAILED: an unlabelled major must be blocked');
95-
failures += 1;
96-
}
97-
const allowed = evaluate({ addedFiles: ['.changeset/a.md'], labels: [APPROVAL_LABEL], readFile });
98-
if (!allowed.ok) {
99-
console.error('self-test FAILED: a labelled major must pass');
100-
failures += 1;
101-
}
102-
const readmeOnly = evaluate({ addedFiles: ['.changeset/README.md'], labels: [], readFile: () => '---\n"a": major\n---\n' });
103-
if (!readmeOnly.ok) {
104-
console.error('self-test FAILED: the changeset README is not a changeset');
105-
failures += 1;
103+
const absentFromBase = () => undefined;
104+
105+
const gateCases = [
106+
{
107+
name: 'an unlabelled new major is blocked',
108+
input: { changedFiles: ['.changeset/a.md'], labels: [], readFile, readBaseFile: absentFromBase },
109+
ok: false,
110+
majors: 1,
111+
},
112+
{
113+
name: 'a labelled new major passes',
114+
input: {
115+
changedFiles: ['.changeset/a.md'],
116+
labels: [APPROVAL_LABEL],
117+
readFile,
118+
readBaseFile: absentFromBase,
119+
},
120+
ok: true,
121+
majors: 1,
122+
},
123+
{
124+
name: 'the changeset README is not a changeset',
125+
input: {
126+
changedFiles: ['.changeset/README.md'],
127+
labels: [],
128+
readFile: () => MAJOR,
129+
readBaseFile: absentFromBase,
130+
},
131+
ok: true,
132+
majors: 0,
133+
},
134+
// The escape this gate exists to close: the file is not new, so a filter on
135+
// added paths alone would never see the bump rise from minor to major.
136+
{
137+
name: 'editing an existing changeset up to major is blocked',
138+
input: { changedFiles: ['.changeset/a.md'], labels: [], readFile, readBaseFile: () => MINOR },
139+
ok: false,
140+
majors: 1,
141+
},
142+
{
143+
name: 'touching an already-major changeset does not re-ask for the label',
144+
input: { changedFiles: ['.changeset/a.md'], labels: [], readFile, readBaseFile: () => MAJOR },
145+
ok: true,
146+
majors: 0,
147+
},
148+
{
149+
name: 'editing a changeset that stays below major passes',
150+
input: {
151+
changedFiles: ['.changeset/a.md'],
152+
labels: [],
153+
readFile: () => MINOR,
154+
readBaseFile: () => MINOR,
155+
},
156+
ok: true,
157+
majors: 0,
158+
},
159+
];
160+
161+
for (const { name, input, ok, majors } of gateCases) {
162+
const actual = evaluate(input);
163+
if (actual.ok !== ok || actual.majors.length !== majors) {
164+
console.error(
165+
`self-test FAILED: ${name} — expected ok=${ok} majors=${majors}, got ok=${actual.ok} majors=${actual.majors.length}`,
166+
);
167+
failures += 1;
168+
}
106169
}
107170

108171
const labelCases = [
@@ -122,7 +185,7 @@ function selfTest() {
122185
}
123186

124187
if (failures > 0) process.exit(1);
125-
console.log(`check-major-changeset: self-test OK (${cases.length + labelCases.length + 3} cases)`);
188+
console.log(`check-major-changeset: self-test OK (${cases.length + labelCases.length + gateCases.length} cases)`);
126189
}
127190

128191
/**
@@ -143,15 +206,31 @@ export function parseLabels(raw) {
143206
return Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string') : [];
144207
}
145208

146-
function addedFilesAgainst(baseSha) {
209+
/**
210+
* Changeset paths the pull request adds, edits, or renames into place.
211+
*
212+
* `--diff-filter=d` keeps every status except deletion, so an edit that raises
213+
* an existing changeset to `major` is reported alongside a brand new one. A
214+
* deleted changeset cannot introduce a bump, so it is the only status dropped.
215+
*/
216+
function changedFilesAgainst(baseSha) {
147217
const output = execFileSync(
148218
'git',
149-
['diff', '--name-only', '--diff-filter=A', `${baseSha}...HEAD`, '--', '.changeset'],
219+
['diff', '--name-only', '--diff-filter=d', `${baseSha}...HEAD`, '--', '.changeset'],
150220
{ encoding: 'utf8' },
151221
);
152222
return output.split('\n').filter((line) => line.length > 0);
153223
}
154224

225+
/** The same path on the base branch, or undefined when it is new there. */
226+
function readBaseFile(baseSha, path) {
227+
try {
228+
return execFileSync('git', ['show', `${baseSha}:${path}`], { encoding: 'utf8' });
229+
} catch {
230+
return undefined;
231+
}
232+
}
233+
155234
function main() {
156235
if (process.argv.includes('--self-test')) {
157236
selfTest();
@@ -165,9 +244,10 @@ function main() {
165244
}
166245

167246
const result = evaluate({
168-
addedFiles: addedFilesAgainst(baseSha),
247+
changedFiles: changedFilesAgainst(baseSha),
169248
labels: parseLabels(process.env['PR_LABELS_JSON']),
170249
readFile: (path) => readFileSync(path, 'utf8'),
250+
readBaseFile: (path) => readBaseFile(baseSha, path),
171251
});
172252

173253
if (result.ok) {

0 commit comments

Comments
 (0)