-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcheck-changeset-no-major.mjs
More file actions
127 lines (119 loc) · 5.16 KB
/
Copy pathcheck-changeset-no-major.mjs
File metadata and controls
127 lines (119 loc) · 5.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
#!/usr/bin/env node
/**
* Launch-window guard: rejects any changeset that declares a `major` bump.
*
* Run: node scripts/check-changeset-no-major.mjs
*
* WHY THIS EXISTS
* ---------------
* Every publishable package is enumerated in the Changesets `fixed` group
* (see `.changeset/config.json` + `check-changeset-fixed.mjs`), so the whole
* monorepo versions in LOCKSTEP. Changesets applies the HIGHEST bump found
* across the group to EVERY package in it. That means a single `major` on any
* one package — even a tiny spec helper — silently promotes the entire release
* (all ~70 packages) from e.g. `14.2.0` to `15.0.0`.
*
* During the launch window we ship breaking changes as `minor` (pre-1.0
* semantics: a breaking change does not burn a major version number while the
* stack is in lockstep). This guard makes that convention enforceable instead
* of tribal, so an over-strict `major` marker can never again turn an ordinary
* PR into a whole-stack major release by accident.
*
* Exits with code 1 (and a clear list of offenders) if any changeset frontmatter
* bumps a package `major`.
*
* RC EXEMPTION: when Changesets is in pre-release mode (`.changeset/pre.json`
* with `"mode": "pre"`, entered via `changeset pre enter <tag>`), a `major`
* bump only ever produces a `X.0.0-<tag>.N` PRE-RELEASE version — nothing final
* publishes until `changeset pre exit`. Accumulating the next major's breaking
* changes is precisely what an RC window is FOR, so this guard stands aside for
* the duration and re-arms automatically once pre-mode is exited. The pending
* majors are still printed (informationally) so the RC curator can eyeball them.
*
* ESCAPE HATCH: outside pre-mode, when a major release is genuinely intended,
* gate this check off in CI with the `allow-major` PR label (see
* `.github/workflows/pr-automation.yml`).
*
* The script intentionally has zero third-party dependencies so it can run in
* minimal CI environments before `pnpm install`.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..');
const changesetDir = resolve(repoRoot, '.changeset');
/**
* Extract the YAML frontmatter block (between the first two `---` fences) and
* return the list of `major`-bumped package names declared in it.
*
* A frontmatter line looks like: "@objectstack/spec": major
* (single or double quotes, any surrounding whitespace).
*
* @param {string} text
* @returns {string[]}
*/
function majorPackagesIn(text) {
const lines = text.split(/\r?\n/);
if (lines[0]?.trim() !== '---') return [];
const majors = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '---') break; // end of frontmatter
// "<name>": <bump> | '<name>': <bump> | <name>: <bump>
const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(line);
if (m && m[2].toLowerCase() === 'major') majors.push(m[1].trim());
}
return majors;
}
let entries;
try {
entries = readdirSync(changesetDir);
} catch {
console.log('No .changeset directory found — nothing to check.');
process.exit(0);
}
const offenders = [];
for (const name of entries) {
if (!name.endsWith('.md') || name === 'README.md') continue;
const file = join(changesetDir, name);
const majors = majorPackagesIn(readFileSync(file, 'utf8'));
if (majors.length) offenders.push({ file: `.changeset/${name}`, majors });
}
if (offenders.length === 0) {
console.log('✓ No `major` bumps in pending changesets.');
process.exit(0);
}
// RC exemption: in Changesets pre-release mode a `major` only yields a
// `X.0.0-<tag>.N` pre-release — the intended product of an RC window — and
// nothing final ships until `changeset pre exit`. Surface the pending majors
// for the RC curator, but do not fail. The guard re-arms once pre-mode exits.
try {
const pre = JSON.parse(readFileSync(join(changesetDir, 'pre.json'), 'utf8'));
if (pre?.mode === 'pre') {
console.log(
`✓ Changesets is in pre-release mode (tag: ${pre.tag ?? 'unknown'}) — ` +
'`major` bumps are the expected product of an RC window; skipping the no-major guard.',
);
for (const { file, majors } of offenders) {
console.log(`::notice file=${file}::pending major in ${file}: ${majors.join(', ')}`);
}
process.exit(0);
}
} catch {
// No readable `.changeset/pre.json` → not in pre-mode → fall through to the guard.
}
console.error('⛔ Changeset(s) declare a `major` bump.\n');
for (const { file, majors } of offenders) {
console.error(` ${file}`);
for (const pkg of majors) console.error(` - ${pkg}: major`);
}
console.error(
'\nEvery publishable package is in the Changesets `fixed` (lockstep) group, so a single\n' +
'`major` promotes the ENTIRE monorepo to a new major version. During the launch window\n' +
'ship breaking changes as `minor` instead (they do not burn a major version number).\n' +
'\n' +
'If a whole-stack major release is genuinely intended, add the `allow-major` label to\n' +
'the PR to skip this check.',
);
process.exit(1);