-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathcheck-changesets.js
More file actions
168 lines (151 loc) · 5.03 KB
/
Copy pathcheck-changesets.js
File metadata and controls
168 lines (151 loc) · 5.03 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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
/**
* Exits with error if a package changed without a changeset
*
* Usage: node scripts/check-changesets.js [base-branch] (default main)
*/
const { execFileSync } = require('node:child_process');
const {
readFileSync,
existsSync,
unlinkSync,
appendFileSync,
} = require('node:fs');
const path = require('node:path');
// Top-level dirs whose packages must carry a changeset when changed
// Add 'integration-tests' here to also gate the private test-harness packages
const SCOPE_DIRS = ['packages'];
const GH_LABEL = 'No changeset needed';
// Branch to compare against - the workflow passes the PR's target branch
const branch = (process.argv[2] || 'main').replace(/^origin\//, '');
const baseRef = `origin/${branch}`;
const git = (args) =>
execFileSync('git', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const tryGit = (args) => {
try {
return git(args);
} catch {
return null;
}
};
// Where this branch diverged from the base (the merge-base), so we compare only
// what the PR changed, not what the base has since moved on to. A full checkout
// (fetch-depth: 0) already has the history; otherwise fetch the base to get it
let mergeBase = tryGit(['merge-base', baseRef, 'HEAD']);
if (!mergeBase) {
tryGit([
'fetch',
'--no-tags',
'origin',
`${branch}:refs/remotes/origin/${branch}`,
]);
mergeBase = tryGit(['merge-base', baseRef, 'HEAD']);
}
if (!mergeBase) {
console.error(
`Could not find where HEAD diverged from "${baseRef}" - use a full checkout (fetch-depth: 0)`
);
process.exit(2);
}
// Every file that differs from the merge-base: committed + staged + unstaged,
// plus anything untracked - matches what a PR contains and what you have locally
const diffed = git(['diff', '--name-only', mergeBase]).split('\n');
const untracked = git(['ls-files', '--others', '--exclude-standard']).split(
'\n'
);
const scopePattern = new RegExp(`^(${SCOPE_DIRS.join('|')})/([^/]+)/`);
const changedDirs = new Set();
for (const file of diffed.concat(untracked)) {
const match = file.match(scopePattern);
if (match) {
changedDirs.add(`${match[1]}/${match[2]}`);
}
}
// Map each changed dir to its package name, skipping unpublishable packages
const changed = new Map();
for (const dir of changedDirs) {
let pkg;
try {
pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
} catch {
continue; // no manifest (e.g. a removed package)
}
// Mirror changesets: a package with no version is never released
if (pkg.name && pkg.version) {
changed.set(pkg.name, dir);
}
}
// Ask changesets which packages are marked for release (the "covered" set).
// Going through the CLI means we never parse the changeset file format ourselves
const covered = new Set();
const planFile = path.join(process.cwd(), '.changeset-status.json');
try {
const stdout = execFileSync(
'pnpm',
['exec', 'changeset', 'status', '--output', planFile],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}
);
console.log('[changeset status] exit 0');
console.log('[changeset status] stdout:\n', stdout);
} catch (e) {
// status exits non-zero and writes nothing when packages changed but no
// changesets exist yet - nothing is marked for release, covered stays empty
console.log('[changeset status] exit', e.status);
console.log('[changeset status] stdout:\n', e.stdout?.toString() ?? '');
console.log('[changeset status] stderr:\n', e.stderr?.toString() ?? '');
}
if (existsSync(planFile)) {
const plan = JSON.parse(readFileSync(planFile, 'utf8'));
console.log({ plan });
// changesets[] = packages a changeset explicitly names. Not releases[], which
// also includes transitive dependency bumps we don't want to treat as covered
for (const changeset of plan.changesets) {
for (const release of changeset.releases) {
covered.add(release.name);
}
}
unlinkSync(planFile);
}
// Any changed package that no changeset accounts for
const missing = Array.from(changed.keys())
.filter((name) => !covered.has(name))
.sort();
if (missing.length === 0) {
console.log('✔ Every changed package has a changeset');
process.exit(0);
}
// Inline annotations on the PR checks tab
for (const name of missing) {
console.log(`::error::Missing a changeset for ${name}`);
}
const lines = ['', 'These changed packages have no changeset:'];
for (const name of missing) {
lines.push(` - ${name}`);
}
console.error(lines.join('\n'));
console.error('\nAdd one and commit it:\n pnpm changeset');
console.error(
`\nIf this PR does not need a release, add the "${GH_LABEL}" label.\n`
);
if (process.env.GITHUB_STEP_SUMMARY) {
const summary = [
'### ❌ Changeset check failed',
'',
'These changed packages have no changeset:',
];
for (const name of missing) {
summary.push(`- \`${name}\``);
}
summary.push(
'',
'Run `pnpm changeset` and commit the result, or add the',
`**${GH_LABEL}** label if no release is needed.`
);
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.join('\n')}\n`);
}
process.exit(1);