From bf6e7636ace7e4dd614856bf12354d3fb6f2fd70 Mon Sep 17 00:00:00 2001 From: yst Date: Thu, 23 Jul 2026 09:24:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(workflow):=20=E5=A2=9E=E5=8A=A0=20Changelog?= =?UTF-8?q?=20=E6=96=87=E4=BB=B6=E5=90=8D=E6=A3=80=E6=B5=8B=E5=99=A8=20(#2?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validate-changelog-filenames.yml | 35 ++ CONTRIBUTING.md | 53 +++ package.json | 12 + scripts/validate-changelog-filenames.mjs | 316 ++++++++++++++++++ tests/validate-changelog-filenames.test.mjs | 293 ++++++++++++++++ 5 files changed, 709 insertions(+) create mode 100644 .gitea/workflows/validate-changelog-filenames.yml create mode 100644 CONTRIBUTING.md create mode 100644 package.json create mode 100644 scripts/validate-changelog-filenames.mjs create mode 100644 tests/validate-changelog-filenames.test.mjs diff --git a/.gitea/workflows/validate-changelog-filenames.yml b/.gitea/workflows/validate-changelog-filenames.yml new file mode 100644 index 0000000..1002210 --- /dev/null +++ b/.gitea/workflows/validate-changelog-filenames.yml @@ -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" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2fa461f --- /dev/null +++ b/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 尚未激活。** diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8732ed --- /dev/null +++ b/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" + } +} diff --git a/scripts/validate-changelog-filenames.mjs b/scripts/validate-changelog-filenames.mjs new file mode 100644 index 0000000..1b9b701 --- /dev/null +++ b/scripts/validate-changelog-filenames.mjs @@ -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 or both --base --head .'); + } + 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(); +} diff --git a/tests/validate-changelog-filenames.test.mjs b/tests/validate-changelog-filenames.test.mjs new file mode 100644 index 0000000..b47a226 --- /dev/null +++ b/tests/validate-changelog-filenames.test.mjs @@ -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 }); + } +});