fix(workflow): 增加 Changelog 文件名检测器 (#24)
一些检查失败了
changelog-filename-gate / validate (push) Has been cancelled
一些检查失败了
changelog-filename-gate / validate (push) Has been cancelled
这个提交包含在:
父节点
e1bc127abc
当前提交
bf6e7636ac
@ -0,0 +1,35 @@
|
|||||||
|
name: changelog-filename-gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- edited
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Run regression tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Validate new changelog filenames
|
||||||
|
run: npm run check:filenames -- --event "$GITHUB_EVENT_PATH"
|
||||||
53
CONTRIBUTING.md
普通文件
53
CONTRIBUTING.md
普通文件
@ -0,0 +1,53 @@
|
|||||||
|
# Changelog 贡献规则
|
||||||
|
|
||||||
|
## 二期文件名
|
||||||
|
|
||||||
|
`/v3/admin/*` 接口写入 `changelogs-v2/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
changelogs-v2/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-管理后台.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`/v3/mp/*` 接口写入 `changelogs-v2-mp/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
changelogs-v2-mp/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-小程序端.md
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `YYYY-MM` 和 `DD` 必须是校验运行时 `Asia/Shanghai` 的真实年月日,且均须补齐两位。跨越上海零点后仍未合并的 PR,需要把新文件重命名为当天日期。
|
||||||
|
- `issue` 必须是不带 `#` 的十进制正整数,不允许 `0`、负数或前缀符号。
|
||||||
|
- 业务标题不能为空。
|
||||||
|
- 变更类型只能是 `新增接口`、`修改接口` 或 `删除接口`。
|
||||||
|
- 端类型由目录唯一决定:`changelogs-v2/` 固定为 `管理后台`,`changelogs-v2-mp/` 固定为 `小程序端`。
|
||||||
|
- 同一改动同时影响 `/v3/admin/*` 和 `/v3/mp/*` 时,应按目录拆成两份。
|
||||||
|
|
||||||
|
一期 `changelogs/` 沿用现行格式,不套用上述强制模板。
|
||||||
|
|
||||||
|
## 校验范围
|
||||||
|
|
||||||
|
检测器读取 `git diff --name-status -z --find-renames` 的结果,只校验本次 diff 新出现的目标路径:
|
||||||
|
|
||||||
|
- `A`(新增)、`C`(复制)和 `R`(重命名)的目标路径必须通过规则。
|
||||||
|
- `M`(修改历史文件)和 `D`(删除)豁免,不会因存量错误命名阻断。
|
||||||
|
- 重命名到受控目录时,新目标路径必须使用校验当天的上海日期。
|
||||||
|
|
||||||
|
本地校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run check:filenames -- --base origin/main --head HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
生产 CLI 故意不提供 `--date` 或日期环境变量;测试只通过导出的纯函数注入 `Date`。规则失败返回退出码 `1`,Git/事件/参数等基础设施错误返回 `2`。
|
||||||
|
|
||||||
|
## CI 与服务端阻断边界
|
||||||
|
|
||||||
|
Gitea Actions 会在指向 `main` 的 PR 和 `main` 的 push 上运行回归测试与文件名检测。该 workflow 是检测器:
|
||||||
|
|
||||||
|
- 在 `main` 未开启分支保护和 required status 时,失败状态不能硬性阻止合并。
|
||||||
|
- `push` 事件发生在写入之后,只能检测/报警,不能撤销直推。
|
||||||
|
- 只有管理员另行保护 `main`、关闭直推,并在 workflow 首次成功运行后,从 Gitea 最近上报的 status context 列表中选择实际值作为 required status,才能宣称服务端 hard gate 已激活。激活记录必须保存首次运行链接和 status API/分支保护回读证据;不得预设 job id/name `validate` 就是 Gitea 实际上报的 context。
|
||||||
|
|
||||||
|
因此,本仓库文件交付的准确表述是:**detector 已安装;在 `main` 未保护时,服务端 hard gate 尚未激活。**
|
||||||
12
package.json
普通文件
12
package.json
普通文件
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "hl-api-changelog-quality-gates",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/validate-changelog-filenames.test.mjs",
|
||||||
|
"check:filenames": "node scripts/validate-changelog-filenames.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const CONTROLLED_ROOTS = new Map([
|
||||||
|
['changelogs-v2', '管理后台'],
|
||||||
|
['changelogs-v2-mp', '小程序端'],
|
||||||
|
]);
|
||||||
|
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口']);
|
||||||
|
|
||||||
|
function ruleError(code, path, message, expected) {
|
||||||
|
return { code, path, message, expected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShanghaiDate(now = new Date()) {
|
||||||
|
if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
|
||||||
|
throw new TypeError('The validation clock must be a valid Date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(now);
|
||||||
|
const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
|
||||||
|
if (!/^\d{4}$/.test(values.year) || !/^\d{2}$/.test(values.month) || !/^\d{2}$/.test(values.day)) {
|
||||||
|
throw new Error('Unable to derive the current Asia/Shanghai calendar date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
yearMonth: `${values.year}-${values.month}`,
|
||||||
|
day: values.day,
|
||||||
|
isoDate: `${values.year}-${values.month}-${values.day}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNameStatusZ(raw) {
|
||||||
|
const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw ?? '');
|
||||||
|
const tokens = text.split('\0');
|
||||||
|
if (tokens.at(-1) === '') {
|
||||||
|
tokens.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = [];
|
||||||
|
for (let index = 0; index < tokens.length;) {
|
||||||
|
const status = tokens[index++];
|
||||||
|
if (!status) {
|
||||||
|
throw new Error('Malformed git diff: an empty status token was found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[RC]\d{1,3}$/.test(status)) {
|
||||||
|
const sourcePath = tokens[index++];
|
||||||
|
const targetPath = tokens[index++];
|
||||||
|
if (sourcePath === undefined || targetPath === undefined) {
|
||||||
|
throw new Error(`Malformed git diff: ${status} must contain source and target paths.`);
|
||||||
|
}
|
||||||
|
records.push({ status, sourcePath, targetPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = tokens[index++];
|
||||||
|
if (targetPath === undefined) {
|
||||||
|
throw new Error(`Malformed git diff: ${status} is missing its path.`);
|
||||||
|
}
|
||||||
|
records.push({ status, targetPath });
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectNewTargetPaths(records) {
|
||||||
|
return records
|
||||||
|
.filter(({ status }) => status === 'A' || /^C\d{1,3}$/.test(status) || /^R\d{1,3}$/.test(status))
|
||||||
|
.map(({ targetPath }) => targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlledRootForPath(inputPath) {
|
||||||
|
const candidate = String(inputPath);
|
||||||
|
for (const root of CONTROLLED_ROOTS.keys()) {
|
||||||
|
if (candidate === root || candidate.startsWith(`${root}/`) || candidate.startsWith(`${root}\\`)) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateChangelogPath(inputPath, now = new Date()) {
|
||||||
|
const changelogPath = String(inputPath);
|
||||||
|
const root = controlledRootForPath(changelogPath);
|
||||||
|
if (!root) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const expectedClient = CONTROLLED_ROOTS.get(root);
|
||||||
|
if (changelogPath.includes('\\')) {
|
||||||
|
return [ruleError(
|
||||||
|
'E_STRUCTURE',
|
||||||
|
changelogPath,
|
||||||
|
'受控 Git 路径包含反斜杠;反斜杠是文件名字节,不是路径分隔符',
|
||||||
|
`${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedDate = getShanghaiDate(now);
|
||||||
|
const errors = [];
|
||||||
|
const segments = changelogPath.split('/');
|
||||||
|
if (segments.length !== 3 || !segments[2].endsWith('.md')) {
|
||||||
|
return [ruleError(
|
||||||
|
'E_STRUCTURE',
|
||||||
|
changelogPath,
|
||||||
|
'路径层级或扩展名不符合规则',
|
||||||
|
`${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, yearMonth, filename] = segments;
|
||||||
|
if (yearMonth !== expectedDate.yearMonth) {
|
||||||
|
errors.push(ruleError(
|
||||||
|
'E_MONTH',
|
||||||
|
changelogPath,
|
||||||
|
`月目录为 ${yearMonth}`,
|
||||||
|
`Asia/Shanghai 当天的 ${expectedDate.yearMonth}`,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stem = filename.slice(0, -3);
|
||||||
|
const clientSeparator = stem.lastIndexOf('-');
|
||||||
|
const actualClient = clientSeparator >= 0 ? stem.slice(clientSeparator + 1) : '';
|
||||||
|
const beforeClient = clientSeparator >= 0 ? stem.slice(0, clientSeparator) : stem;
|
||||||
|
if (actualClient !== expectedClient) {
|
||||||
|
errors.push(ruleError(
|
||||||
|
'E_CLIENT',
|
||||||
|
changelogPath,
|
||||||
|
`端类型为 ${actualClient || '(缺失)'}`,
|
||||||
|
expectedClient,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeSeparator = beforeClient.lastIndexOf('-');
|
||||||
|
const actualType = typeSeparator >= 0 ? beforeClient.slice(typeSeparator + 1) : '';
|
||||||
|
const prefix = typeSeparator >= 0 ? beforeClient.slice(0, typeSeparator) : beforeClient;
|
||||||
|
if (!CHANGE_TYPES.has(actualType)) {
|
||||||
|
errors.push(ruleError(
|
||||||
|
'E_CHANGE_TYPE',
|
||||||
|
changelogPath,
|
||||||
|
`变更类型为 ${actualType || '(缺失)'}`,
|
||||||
|
'新增接口 / 修改接口 / 删除接口',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixMatch = /^(\d{2})_([^_]*)_(.*)$/.exec(prefix);
|
||||||
|
if (!prefixMatch) {
|
||||||
|
const dayMatch = /^(\d{2})_/.exec(prefix);
|
||||||
|
if (dayMatch && dayMatch[1] !== expectedDate.day) {
|
||||||
|
errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${dayMatch[1]}`, expectedDate.day));
|
||||||
|
}
|
||||||
|
errors.push(ruleError(
|
||||||
|
'E_ISSUE',
|
||||||
|
changelogPath,
|
||||||
|
'文件名缺少可识别的正整数 Issue 号',
|
||||||
|
'DD_[1-9][0-9]*_标题',
|
||||||
|
));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, day, issue, title] = prefixMatch;
|
||||||
|
if (day !== expectedDate.day) {
|
||||||
|
errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${day}`, expectedDate.day));
|
||||||
|
}
|
||||||
|
if (!/^[1-9]\d*$/.test(issue)) {
|
||||||
|
errors.push(ruleError('E_ISSUE', changelogPath, `Issue 号为 ${issue || '(缺失)'}`, '不带 # 的十进制正整数'));
|
||||||
|
}
|
||||||
|
if (title.trim() === '') {
|
||||||
|
errors.push(ruleError('E_TITLE', changelogPath, '业务标题为空', '非空业务标题'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runValidation(records, now = new Date()) {
|
||||||
|
const targetPaths = collectNewTargetPaths(records);
|
||||||
|
const controlledPaths = targetPaths.filter((targetPath) => controlledRootForPath(targetPath));
|
||||||
|
const errors = controlledPaths.flatMap((targetPath) => validateChangelogPath(targetPath, now));
|
||||||
|
return {
|
||||||
|
targetCount: targetPaths.length,
|
||||||
|
checkedCount: controlledPaths.length,
|
||||||
|
errors,
|
||||||
|
expectedDate: getShanghaiDate(now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(argv) {
|
||||||
|
const options = {};
|
||||||
|
for (let index = 0; index < argv.length; index += 2) {
|
||||||
|
const flag = argv[index];
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (!['--event', '--base', '--head'].includes(flag) || value === undefined) {
|
||||||
|
throw new Error(`Unsupported or incomplete argument: ${flag ?? '(missing)'}`);
|
||||||
|
}
|
||||||
|
options[flag.slice(2)] = value;
|
||||||
|
}
|
||||||
|
if (options.event && (options.base || options.head)) {
|
||||||
|
throw new Error('--event cannot be combined with --base/--head.');
|
||||||
|
}
|
||||||
|
if (!options.event && (!options.base || !options.head)) {
|
||||||
|
throw new Error('Use --event <path> or both --base <ref> --head <ref>.');
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(args, options = {}) {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
...options,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCommitRef(ref, label) {
|
||||||
|
if (typeof ref !== 'string' || ref.length === 0 || /[\0\r\n]/.test(ref)) {
|
||||||
|
throw new Error(`${label} must be a non-empty Git ref without control characters.`);
|
||||||
|
}
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = runGit(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], { encoding: 'utf8' }).trim();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} does not resolve to an existing commit.`);
|
||||||
|
}
|
||||||
|
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(resolved)) {
|
||||||
|
throw new Error(`${label} resolved to an unexpected object ID.`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEventCommitSha(sha, label) {
|
||||||
|
if (typeof sha !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(sha)) {
|
||||||
|
throw new Error(`${label} must be a full 40- or 64-character hexadecimal commit SHA.`);
|
||||||
|
}
|
||||||
|
return resolveCommitRef(sha.toLowerCase(), label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTreeObjectId() {
|
||||||
|
const objectId = runGit(['hash-object', '-t', 'tree', '--stdin'], {
|
||||||
|
input: Buffer.alloc(0),
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).trim();
|
||||||
|
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId)) {
|
||||||
|
throw new Error('Git returned an unexpected empty-tree object ID.');
|
||||||
|
}
|
||||||
|
return objectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitDiff(revisions) {
|
||||||
|
return runGit(['diff', '--name-status', '-z', '--find-renames', ...revisions, '--'], {
|
||||||
|
encoding: 'buffer',
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffFromOptions(options) {
|
||||||
|
if (!options.event) {
|
||||||
|
const base = resolveCommitRef(options.base, 'base ref');
|
||||||
|
const head = resolveCommitRef(options.head, 'head ref');
|
||||||
|
return gitDiff([`${base}...${head}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = JSON.parse(readFileSync(options.event, 'utf8'));
|
||||||
|
if (event.pull_request) {
|
||||||
|
const base = event.pull_request.base?.sha;
|
||||||
|
const head = event.pull_request.head?.sha;
|
||||||
|
if (!base || !head) {
|
||||||
|
throw new Error('The pull_request event is missing base/head SHA values.');
|
||||||
|
}
|
||||||
|
const resolvedBase = resolveEventCommitSha(base, 'pull_request base SHA');
|
||||||
|
const resolvedHead = resolveEventCommitSha(head, 'pull_request head SHA');
|
||||||
|
return gitDiff([`${resolvedBase}...${resolvedHead}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.before && event.after) {
|
||||||
|
if (typeof event.before !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(event.before)) {
|
||||||
|
throw new Error('push before SHA must be a full 40- or 64-character hexadecimal value.');
|
||||||
|
}
|
||||||
|
const before = /^0+$/.test(event.before)
|
||||||
|
? emptyTreeObjectId()
|
||||||
|
: resolveEventCommitSha(event.before, 'push before SHA');
|
||||||
|
const after = resolveEventCommitSha(event.after, 'push after SHA');
|
||||||
|
return gitDiff([before, after]);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported event payload: expected pull_request or push before/after SHA values.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function main(argv = process.argv.slice(2)) {
|
||||||
|
try {
|
||||||
|
const options = parseArguments(argv);
|
||||||
|
const records = parseNameStatusZ(diffFromOptions(options));
|
||||||
|
const result = runValidation(records, new Date());
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
for (const error of result.errors) {
|
||||||
|
console.error(`[${error.code}] ${error.path}: ${error.message}; expected ${error.expected}`);
|
||||||
|
}
|
||||||
|
console.error(`FAIL: found ${new Set(result.errors.map(({ path }) => path)).size} invalid changelog path(s) among ${result.checkedCount} controlled changelog path(s).`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
console.log(`PASS: validated ${result.checkedCount} controlled changelog path(s) from ${result.targetCount} new target path(s); expected Shanghai date ${result.expectedDate.isoDate}.`);
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCli = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
||||||
|
if (isCli) {
|
||||||
|
process.exitCode = main();
|
||||||
|
}
|
||||||
@ -0,0 +1,293 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectNewTargetPaths,
|
||||||
|
getShanghaiDate,
|
||||||
|
parseNameStatusZ,
|
||||||
|
runValidation,
|
||||||
|
validateChangelogPath,
|
||||||
|
} from '../scripts/validate-changelog-filenames.mjs';
|
||||||
|
|
||||||
|
const SHANGHAI_NOW = new Date('2026-07-22T04:00:00.000Z');
|
||||||
|
const CLI_PATH = fileURLToPath(new URL('../scripts/validate-changelog-filenames.mjs', import.meta.url));
|
||||||
|
|
||||||
|
function validAdmin({ day = '22', month = '2026-07', issue = '5161', title = '文件名校验', type = '新增接口', client = '管理后台' } = {}) {
|
||||||
|
return `changelogs-v2/${month}/${day}_${issue}_${title}-${type}-${client}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validMp({ day = '22', month = '2026-07', issue = '5161', title = '文件名校验', type = '修改接口', client = '小程序端' } = {}) {
|
||||||
|
return `changelogs-v2-mp/${month}/${day}_${issue}_${title}-${type}-${client}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codes(file, now = SHANGHAI_NOW) {
|
||||||
|
return validateChangelogPath(file, now).map((error) => error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('accepts a valid v2 admin path', () => {
|
||||||
|
assert.deepEqual(validateChangelogPath(validAdmin(), SHANGHAI_NOW), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a valid v2-mp path', () => {
|
||||||
|
assert.deepEqual(validateChangelogPath(validMp(), SHANGHAI_NOW), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses Asia/Shanghai when UTC is still the previous day', () => {
|
||||||
|
const now = new Date('2026-07-21T16:05:00.000Z');
|
||||||
|
assert.equal(getShanghaiDate(now).isoDate, '2026-07-22');
|
||||||
|
assert.deepEqual(validateChangelogPath(validAdmin(), now), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts the real leap day', () => {
|
||||||
|
const now = new Date('2024-02-29T04:00:00.000Z');
|
||||||
|
assert.deepEqual(validateChangelogPath(validAdmin({ month: '2024-02', day: '29' }), now), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a wrong day', () => {
|
||||||
|
assert.ok(codes(validAdmin({ day: '21' })).includes('E_DAY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a wrong month directory', () => {
|
||||||
|
assert.ok(codes(validAdmin({ month: '2026-06' })).includes('E_MONTH'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a day greater than 31', () => {
|
||||||
|
assert.ok(codes(validAdmin({ day: '63' })).includes('E_DAY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a missing issue number', () => {
|
||||||
|
assert.ok(codes('changelogs-v2/2026-07/22_标题-新增接口-管理后台.md').includes('E_ISSUE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects issue zero', () => {
|
||||||
|
assert.ok(codes(validAdmin({ issue: '0' })).includes('E_ISSUE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a hash-prefixed issue', () => {
|
||||||
|
assert.ok(codes(validAdmin({ issue: '#5161' })).includes('E_ISSUE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a non-numeric issue', () => {
|
||||||
|
assert.ok(codes(validAdmin({ issue: 'ABC' })).includes('E_ISSUE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an unsupported change type', () => {
|
||||||
|
assert.ok(codes(validAdmin({ type: '新增字段' })).includes('E_CHANGE_TYPE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects the mini-program client in v2', () => {
|
||||||
|
assert.ok(codes(validAdmin({ client: '小程序端' })).includes('E_CLIENT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects the admin client in v2-mp', () => {
|
||||||
|
assert.ok(codes(validMp({ client: '管理后台' })).includes('E_CLIENT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an empty title', () => {
|
||||||
|
assert.ok(codes(validAdmin({ title: '' })).includes('E_TITLE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a backslash inside a controlled physical path', () => {
|
||||||
|
const physicalPath = 'changelogs-v2/2026-07\\22_5161_绕过-新增接口-管理后台.md';
|
||||||
|
assert.ok(codes(physicalPath).includes('E_STRUCTURE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a root-level backslash pseudo path', () => {
|
||||||
|
const physicalPath = 'changelogs-v2\\2026-07\\22_5161_绕过-新增接口-管理后台.md';
|
||||||
|
assert.ok(codes(physicalPath).includes('E_STRUCTURE'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exempts phase-one changelogs', () => {
|
||||||
|
assert.deepEqual(validateChangelogPath('changelogs/2026-07/99_no_issue_anything.md', SHANGHAI_NOW), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses NUL-delimited Chinese and space-containing paths', () => {
|
||||||
|
const raw = Buffer.from(`A\0${validAdmin({ title: '中文 空格' })}\0M\0old file.md\0`, 'utf8');
|
||||||
|
assert.deepEqual(parseNameStatusZ(raw), [
|
||||||
|
{ status: 'A', targetPath: validAdmin({ title: '中文 空格' }) },
|
||||||
|
{ status: 'M', targetPath: 'old file.md' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exempts modified and deleted historical bad paths', () => {
|
||||||
|
const records = parseNameStatusZ(Buffer.from(
|
||||||
|
'M\0changelogs-v2/2026-07/63_历史坏文件.md\0D\0changelogs-v2/2026-07/99_无issue.md\0',
|
||||||
|
'utf8',
|
||||||
|
));
|
||||||
|
assert.deepEqual(collectNewTargetPaths(records), []);
|
||||||
|
assert.equal(runValidation(records, SHANGHAI_NOW).errors.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates only a copy target path', () => {
|
||||||
|
const records = parseNameStatusZ(Buffer.from(`C100\0old.md\0${validAdmin()}\0`, 'utf8'));
|
||||||
|
assert.deepEqual(collectNewTargetPaths(records), [validAdmin()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a rename from an old bad name to a valid target', () => {
|
||||||
|
const records = parseNameStatusZ(Buffer.from(`R100\0changelogs-v2/2026-07/63_old.md\0${validAdmin()}\0`, 'utf8'));
|
||||||
|
assert.equal(runValidation(records, SHANGHAI_NOW).errors.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a rename target with an old day', () => {
|
||||||
|
const target = validAdmin({ day: '21' });
|
||||||
|
const records = parseNameStatusZ(Buffer.from(`R100\0changelogs-v2/2026-07/63_old.md\0${target}\0`, 'utf8'));
|
||||||
|
assert.ok(runValidation(records, SHANGHAI_NOW).errors.some((error) => error.code === 'E_DAY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates all invalid paths instead of stopping at the first', () => {
|
||||||
|
const records = [
|
||||||
|
{ status: 'A', targetPath: validAdmin() },
|
||||||
|
{ status: 'A', targetPath: validMp() },
|
||||||
|
{ status: 'A', targetPath: validAdmin({ day: '21' }) },
|
||||||
|
{ status: 'A', targetPath: validMp({ issue: '0' }) },
|
||||||
|
];
|
||||||
|
const result = runValidation(records, SHANGHAI_NOW);
|
||||||
|
assert.equal(new Set(result.errors.map((error) => error.path)).size, 2);
|
||||||
|
assert.equal(result.checkedCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real git diff ignores historical modifications and validates an added path', () => {
|
||||||
|
const repo = mkdtempSync(path.join(tmpdir(), 'hl-changelog-gate-'));
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['init', '-q'], { cwd: repo });
|
||||||
|
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo });
|
||||||
|
execFileSync('git', ['config', 'user.name', 'Gate Test'], { cwd: repo });
|
||||||
|
execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: repo });
|
||||||
|
const oldPath = path.join(repo, 'changelogs-v2', '2026-07', '63_old.md');
|
||||||
|
mkdirSync(path.dirname(oldPath), { recursive: true });
|
||||||
|
writeFileSync(oldPath, 'old\n');
|
||||||
|
execFileSync('git', ['add', '.'], { cwd: repo });
|
||||||
|
execFileSync('git', ['commit', '-qm', 'base'], { cwd: repo });
|
||||||
|
const base = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
|
writeFileSync(oldPath, 'changed\n');
|
||||||
|
const addedPath = path.join(repo, ...validAdmin().split('/'));
|
||||||
|
writeFileSync(addedPath, 'new\n');
|
||||||
|
execFileSync('git', ['add', '.'], { cwd: repo });
|
||||||
|
execFileSync('git', ['commit', '-qm', 'head'], { cwd: repo });
|
||||||
|
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
||||||
|
const raw = execFileSync('git', ['diff', '--name-status', '-z', '--find-renames', base, head], { cwd: repo });
|
||||||
|
const result = runValidation(parseNameStatusZ(raw), SHANGHAI_NOW);
|
||||||
|
|
||||||
|
assert.equal(result.checkedCount, 1);
|
||||||
|
assert.equal(result.errors.length, 0);
|
||||||
|
} finally {
|
||||||
|
rmSync(repo, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production CLI selects PR/push revisions safely and preserves exit-code semantics', async (t) => {
|
||||||
|
const repo = mkdtempSync(path.join(tmpdir(), 'hl-changelog-cli-'));
|
||||||
|
const eventPath = path.join(repo, 'event.json');
|
||||||
|
|
||||||
|
function git(...args) {
|
||||||
|
return execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(args) {
|
||||||
|
return execFileSync(process.execPath, [CLI_PATH, ...args], {
|
||||||
|
cwd: repo,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCliResult(args) {
|
||||||
|
try {
|
||||||
|
return { status: 0, stdout: runCli(args), stderr: '' };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: error.status,
|
||||||
|
stdout: String(error.stdout ?? ''),
|
||||||
|
stderr: String(error.stderr ?? ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvent(value) {
|
||||||
|
writeFileSync(eventPath, typeof value === 'string' ? value : JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
git('init', '-q');
|
||||||
|
git('config', 'user.email', 'test@example.com');
|
||||||
|
git('config', 'user.name', 'Gate Test');
|
||||||
|
git('config', 'core.autocrlf', 'false');
|
||||||
|
writeFileSync(path.join(repo, 'README.md'), 'base\n');
|
||||||
|
git('add', '.');
|
||||||
|
git('commit', '-qm', 'base');
|
||||||
|
const base = git('rev-parse', 'HEAD');
|
||||||
|
|
||||||
|
const today = getShanghaiDate(new Date());
|
||||||
|
const validPath = validAdmin({ month: today.yearMonth, day: today.day });
|
||||||
|
const addedPath = path.join(repo, ...validPath.split('/'));
|
||||||
|
mkdirSync(path.dirname(addedPath), { recursive: true });
|
||||||
|
writeFileSync(addedPath, 'valid\n');
|
||||||
|
git('add', '.');
|
||||||
|
git('commit', '-qm', 'valid');
|
||||||
|
const validHead = git('rev-parse', 'HEAD');
|
||||||
|
|
||||||
|
const wrongDay = today.day === '01' ? '02' : '01';
|
||||||
|
const invalidPath = validAdmin({ month: today.yearMonth, day: wrongDay, issue: '5162' });
|
||||||
|
writeFileSync(path.join(repo, ...invalidPath.split('/')), 'invalid\n');
|
||||||
|
git('add', '.');
|
||||||
|
git('commit', '-qm', 'invalid');
|
||||||
|
const invalidHead = git('rev-parse', 'HEAD');
|
||||||
|
|
||||||
|
await t.test('PR event uses base...head and returns 0 for a legal addition', () => {
|
||||||
|
writeEvent({ pull_request: { base: { sha: base }, head: { sha: validHead } } });
|
||||||
|
const result = runCliResult(['--event', eventPath]);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /validated 1 controlled changelog path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('push event uses before/after and returns 0 for a legal addition', () => {
|
||||||
|
writeEvent({ before: base, after: validHead });
|
||||||
|
const result = runCliResult(['--event', eventPath]);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('new-branch push uses the empty tree', () => {
|
||||||
|
writeEvent({ before: '0'.repeat(base.length), after: validHead });
|
||||||
|
const result = runCliResult(['--event', eventPath]);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /validated 1 controlled changelog path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('rule failure returns 1', () => {
|
||||||
|
writeEvent({ pull_request: { base: { sha: validHead }, head: { sha: invalidHead } } });
|
||||||
|
const result = runCliResult(['--event', eventPath]);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, /\[E_DAY\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('missing event fields return 2', () => {
|
||||||
|
writeEvent({ pull_request: { base: { sha: base }, head: {} } });
|
||||||
|
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('invalid JSON returns 2', () => {
|
||||||
|
writeEvent('{not-json');
|
||||||
|
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('nonexistent event commit returns 2', () => {
|
||||||
|
writeEvent({ before: base, after: 'f'.repeat(base.length) });
|
||||||
|
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('a Git option cannot be injected as a local ref', () => {
|
||||||
|
const result = runCliResult(['--base', '--output=gate-review', '--head', 'HEAD']);
|
||||||
|
assert.equal(result.status, 2);
|
||||||
|
assert.equal(existsSync(path.join(repo, 'gate-review')), false);
|
||||||
|
assert.equal(readdirSync(repo).some((name) => name.startsWith('gate-review')), false);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(repo, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
正在加载...
x
在新工单中引用
屏蔽一个用户