Skip to content

Commit a534020

Browse files
committed
ci: enforce changesets for affected packages on PRs
1 parent 138acb8 commit a534020

2 files changed

Lines changed: 143 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Verify that every affected publishable package in this PR has at least
4+
* one new changeset entry.
5+
*
6+
* "Affected" = a release-worthy file under `packages/<pkg>/` was added,
7+
* modified, renamed, or deleted in the PR diff. Tests, build/test config,
8+
* and CHANGELOG.md are excluded since they don't ship to consumers.
9+
*
10+
* "Covered" = a `.changeset/*.md` file added in this PR has a frontmatter
11+
* entry naming the package (any bump level).
12+
*
13+
* Exits 0 on success, 1 if any affected package is missing a changeset,
14+
* 2 on usage/setup error.
15+
*/
16+
import { execSync } from "node:child_process";
17+
import { existsSync, readFileSync, readdirSync } from "node:fs";
18+
import { join } from "node:path";
19+
20+
const baseSha = process.env.BASE_SHA;
21+
const headSha = process.env.HEAD_SHA || "HEAD";
22+
if (!baseSha) {
23+
console.error("BASE_SHA env var is required");
24+
process.exit(2);
25+
}
26+
27+
// Make sure we have the base commit locally (shallow clones may lack it).
28+
try {
29+
execSync(`git cat-file -e ${baseSha}^{commit}`, { stdio: "ignore" });
30+
} catch {
31+
execSync(`git fetch --no-tags --depth=1 origin ${baseSha}`, { stdio: "inherit" });
32+
}
33+
34+
const diffRange = `${baseSha}...${headSha}`;
35+
const changedFiles = execSync(`git diff --name-only --diff-filter=ACMRTD ${diffRange}`, {
36+
encoding: "utf8",
37+
})
38+
.split("\n")
39+
.filter(Boolean);
40+
41+
// Discover publishable workspace packages.
42+
const packagesDir = "packages";
43+
const pkgNameByDir = new Map();
44+
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
45+
if (!entry.isDirectory()) continue;
46+
const pjPath = join(packagesDir, entry.name, "package.json");
47+
if (!existsSync(pjPath)) continue;
48+
const pj = JSON.parse(readFileSync(pjPath, "utf8"));
49+
if (pj.private) continue;
50+
pkgNameByDir.set(entry.name, pj.name);
51+
}
52+
53+
/** Return the package name if `file` is release-worthy, else null. */
54+
function packageForFile(file) {
55+
const m = file.match(/^packages\/([^/]+)\/(.+)$/);
56+
if (!m) return null;
57+
const [, dir, rest] = m;
58+
const name = pkgNameByDir.get(dir);
59+
if (!name) return null;
60+
// Excluded: tests, build/test config, generated CHANGELOG.
61+
if (/(^|\/)(test|tests|__tests__)\//.test(rest)) return null;
62+
if (/\.test\.[jt]sx?$/.test(rest)) return null;
63+
if (/^(tsup\.config|vitest\.config|tsconfig|biome)\b/.test(rest)) return null;
64+
if (rest === "CHANGELOG.md") return null;
65+
return name;
66+
}
67+
68+
const affected = new Set();
69+
for (const f of changedFiles) {
70+
const name = packageForFile(f);
71+
if (name) affected.add(name);
72+
}
73+
74+
// Collect packages covered by changeset files *added* in this PR.
75+
const addedChangesetFiles = execSync(
76+
`git diff --name-only --diff-filter=A ${diffRange} -- ".changeset/*.md"`,
77+
{ encoding: "utf8" },
78+
)
79+
.split("\n")
80+
.filter(Boolean)
81+
.filter((f) => !f.endsWith("README.md"));
82+
83+
const covered = new Set();
84+
for (const file of addedChangesetFiles) {
85+
// Read from the head ref so the script works even if the file has
86+
// already been consumed by a later `changeset version` run.
87+
const text = execSync(`git show ${headSha}:${file}`, { encoding: "utf8" });
88+
const fm = text.match(/^---\s*\n([\s\S]*?)\n---/);
89+
if (!fm) continue;
90+
for (const line of fm[1].split("\n")) {
91+
// Match `"@scope/name": patch` or `name: minor` etc.
92+
const m = line.match(/^\s*["']?([^"'\s:]+)["']?\s*:\s*(patch|minor|major)\s*$/);
93+
if (m) covered.add(m[1]);
94+
}
95+
}
96+
97+
const missing = [...affected].filter((p) => !covered.has(p)).sort();
98+
99+
if (affected.size === 0) {
100+
console.log("No publishable package files changed; no changeset required.");
101+
process.exit(0);
102+
}
103+
104+
console.log(`Affected packages (${affected.size}): ${[...affected].sort().join(", ")}`);
105+
console.log(`Covered by new changesets (${covered.size}): ${[...covered].sort().join(", ") || "(none)"}`);
106+
107+
if (missing.length === 0) {
108+
console.log("✓ All affected packages have a changeset.");
109+
process.exit(0);
110+
}
111+
112+
console.error("");
113+
console.error("✗ Missing changeset entries for:");
114+
for (const p of missing) console.error(` - ${p}`);
115+
console.error("");
116+
console.error("Run `yarn changeset` locally, pick the affected packages, and commit");
117+
console.error("the resulting file under .changeset/.");
118+
process.exit(1);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Changeset check
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
jobs:
8+
changeset:
9+
# Skip the auto-generated release PR opened by the changesets action.
10+
if: ${{ !startsWith(github.head_ref, 'changeset-release/') }}
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
with:
15+
fetch-depth: 0
16+
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 22
20+
21+
- name: Verify changesets cover affected packages
22+
env:
23+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
24+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
25+
run: node .github/scripts/check-changesets.mjs

0 commit comments

Comments
 (0)