Files
nocobase/scripts/require-changesets.mjs

205 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 检查「改了可发布代码但没写 changeset」,缺失时以非零码退出,阻止合并。
//
// 这里判断的是「改动会不会进入发布产物」,不是「值不值得发版」。前者机器能看准:
// 一个文件要么随包发出去,要么不会。后者仍然留给人——patch 还是 minor 由写
// changeset 的人决定,而确实不需要发版的改动加 `release:skip` label 放行。
//
// 曾经这里只提醒不阻塞,理由是怕大家为了过检查随手补 patch。现实是漏发比误发
// 更贵:包发不出去,用户装到的还是旧的,而 CI 全绿,没有任何人会发现。
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
const root = process.cwd();
const { BASE_SHA, HEAD_SHA, SKIP_LABEL } = process.env;
if (!BASE_SHA || !HEAD_SHA) {
console.log('缺少 BASE_SHA / HEAD_SHA,跳过检查。');
process.exit(0);
}
function git(...args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8' });
}
// 从分叉点开始比,而不是直接 diff base..head。
//
// `pull_request.base.sha` 是目标分支当下的 HEAD,不是这个 PR 的分叉点。目标分支
// 在 PR 开着的期间前进过,两点 diff 就会把「目标分支有、这个分支没有」的提交也
// 算成本次改动——方向还是反的,别人新增的文件在这里表现为被删除。于是一个只改了
// CI 脚本的 PR,被要求为三个它从未碰过的模板包写 changeset。
//
// merge-base 之后 diff 只包含这个分支自己的提交。git 的 `A...B` 三点写法就是
// 这个语义,但 GitHub 给的是两个裸 SHA,所以显式求一次分叉点:真实场景下这两个
// 提交总是同一棵树里的,求不出来的话(浅克隆、force-push 之后的竞态)退回 base,
// 宁可多报也不要漏报。
function diffBase(base, head) {
try {
return git('merge-base', base, head).trim() || base;
} catch {
return base;
}
}
// 不进入发布产物的路径,相对包根目录。判断不准时按「影响产物」处理——漏掉一个
// changeset 的代价,比多要求一次大。
//
// 注意这里不含 AGENTS.md、CLAUDE.md 和 skills/。它们随包发布,注册插件时会同步
// 到用户应用的 `.agents/skills/`,AI 照着它们写代码——改了却发不出去,用户那边
// 的 AI 就一直按旧规则工作。它们是产物,不是文档。
const DOCS_AND_TESTS = [
// 阅读用文档。README.md 与 README.zh-CN.md、README.MD 等变体一起匹配。
/^README(\.[\w-]+)?\.mdx?$/i,
/^CHANGELOG\.md$/i,
/^docs\//,
// 测试。仓库约定测试放在包根的 tests/,但嵌套源码根下也有自己的 tests/
// (如 plugins/app-plugin-authentication/server/tests),所以两种都要匹配。
/^tests?\//,
/(^|\/)tests?\//,
/^e2e\//,
/^skill-evals\//,
/\.(test|spec)\.[jt]sx?$/,
];
// 只在本仓库开发时使用、不随包发布的配置。
const DEV_CONFIG = [
/^eslint\.config\.[jt]s$/,
/^\.prettierrc/,
/^\.prettierignore$/,
/^vitest\.config\.[jt]s$/,
/^vite\.config\.[jt]s$/,
/^playwright\.config\.[jt]s$/,
// tsconfig 只忽略确实不参与构建的那几个。根 tsconfig.json 决定 .d.ts
// 怎么生成,改了会直接改变发布出去的类型声明,所以它算产物。
/^tsconfig\.(node|test|vitest|eslint)\.json$/,
];
function isIgnoredInPackage(relative, pkg) {
if (DOCS_AND_TESTS.some((re) => re.test(relative))) return true;
// 模板包把源码本身作为产物发布,files 里列着 eslint.config.js、vitest.config.ts
// 这些在库包里属于开发配置的文件。对它们只忽略上面的文档和测试。
const filesField = Array.isArray(pkg.files) ? pkg.files : [];
const shipsConfig = filesField.some((entry) =>
/^(eslint\.config|vitest\.config|vite\.config|tsconfig)/.test(entry),
);
if (shipsConfig) return false;
return DEV_CONFIG.some((re) => re.test(relative));
}
// key 是相对 packages/ 的两段路径(如 libs/db),和下面从改动文件里截出来的前缀对齐。
function loadPackages() {
const dir = path.join(root, 'packages');
const map = new Map();
if (!fs.existsSync(dir)) return map;
for (const category of fs.readdirSync(dir, { withFileTypes: true })) {
if (!category.isDirectory()) continue;
const categoryDir = path.join(dir, category.name);
for (const entry of fs.readdirSync(categoryDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifest = path.join(categoryDir, entry.name, 'package.json');
if (!fs.existsSync(manifest)) continue;
const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8'));
map.set(`${category.name}/${entry.name}`, pkg);
}
}
return map;
}
const packages = loadPackages();
const changed = git(
'diff',
'--name-only',
'--diff-filter=ACMR',
diffBase(BASE_SHA, HEAD_SHA),
HEAD_SHA,
)
.split('\n')
.filter(Boolean);
// 每个包记下触发它的第一个文件,报错时能直接指出是哪个改动要求了 changeset。
const touched = new Map();
for (const file of changed) {
const m = file.match(/^packages\/([^/]+\/[^/]+)\//);
if (!m) continue;
const pkg = packages.get(m[1]);
if (!pkg || pkg.private) continue;
const relative = file.slice(`packages/${m[1]}/`.length);
if (isIgnoredInPackage(relative, pkg)) continue;
if (!touched.has(pkg.name)) touched.set(pkg.name, relative);
}
// 收集本 PR 新增的 changeset 覆盖了哪些包
const declared = new Set();
const changesetFiles = changed.filter(
(f) => f.startsWith('.changeset/') && f.endsWith('.md'),
);
for (const file of changesetFiles) {
const full = path.join(root, file);
if (!fs.existsSync(full)) continue;
const match = fs
.readFileSync(full, 'utf8')
.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) continue;
for (const line of match[1].split(/\r?\n/)) {
const entry = line.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^:]+?))\s*:/);
if (entry) declared.add((entry[1] ?? entry[2] ?? entry[3]).trim());
}
}
const uncovered = [...touched.keys()]
.filter((name) => !declared.has(name))
.sort();
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
const write = (text) => {
console.log(text);
if (summaryPath) fs.appendFileSync(summaryPath, `${text}\n`);
};
if (SKIP_LABEL === 'true') {
write('### Changeset check');
write('');
write('PR 带有 `release:skip` label,已记录「本次不发版」的决定。');
if (uncovered.length) {
write('');
write(`被跳过的包:${uncovered.map((n) => `\`${n}\``).join('、')}`);
}
process.exit(0);
}
write('### Changeset check');
write('');
if (!touched.size) {
write('本次没有改动可发布 package 的产物代码。');
process.exit(0);
}
if (!uncovered.length) {
write(
`改动的可发布 package 都已声明 changeset:${[...touched.keys()].map((n) => `\`${n}\``).join('、')}`,
);
process.exit(0);
}
write('以下 package 有产物改动但没有对应 changeset:');
write('');
for (const name of uncovered) {
write(`- \`${name}\`(如 \`${touched.get(name)}\`)`);
}
write('');
write('运行 `pnpm changeset` 补一份,勾选上面列出的包。');
write('');
write(
'如果这次改动确实不需要发版——比如纯重构、或回滚一个尚未发布的改动——给 PR 加 `release:skip` label,并在描述里说明原因。',
);
for (const name of uncovered) {
console.log(`::error::${name} 有产物改动但缺少 changeset`);
}
process.exit(1);