From 085342ebb8e230678a26125c06e343815a138785 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sat, 7 Nov 2026 17:25:29 +0000 Subject: [PATCH 1/2] ci: choose full or light tests from the diff Functional, dependency, workflow, and test changes keep the full test job. A version-field or docs-only diff runs a version consistency check. The required test check still finishes with a pass or a failure. Co-authored-by: DNT_OF --- .github/workflows/package.yml | 34 ++++ scripts/ci-scope-smoke.mjs | 170 ++++++++++++++++++ scripts/ci-scope.mjs | 314 ++++++++++++++++++++++++++++++++++ scripts/ci-version-check.mjs | 96 +++++++++++ 4 files changed, 614 insertions(+) create mode 100644 scripts/ci-scope-smoke.mjs create mode 100644 scripts/ci-scope.mjs create mode 100644 scripts/ci-version-check.mjs diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 2573dba..e301fee 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -31,17 +31,39 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm + - name: Classify changes + id: scope + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_SHA: ${{ github.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PUSH_BEFORE: ${{ github.event.before }} + run: node scripts/ci-scope.mjs + - name: Check recorded versions + if: steps.scope.outputs.mode == 'light' + run: node scripts/ci-version-check.mjs - uses: dtolnay/rust-toolchain@stable + if: steps.scope.outputs.mode == 'full' with: toolchain: 1.90.0 - uses: Swatinem/rust-cache@v2 + if: steps.scope.outputs.mode == 'full' with: workspaces: crates/mathcore + - name: Classifier regression + if: steps.scope.outputs.mode == 'full' + run: node scripts/ci-scope-smoke.mjs - name: Chrome for sheet end-to-end + if: steps.scope.outputs.mode == 'full' run: | if ! command -v google-chrome >/dev/null 2>&1 && ! command -v google-chrome-stable >/dev/null 2>&1; then sudo apt-get update @@ -50,14 +72,23 @@ jobs: fi (google-chrome --version || google-chrome-stable --version) - run: npm ci + if: steps.scope.outputs.mode == 'full' - run: npm run lint + if: steps.scope.outputs.mode == 'full' - run: npm run test:rust + if: steps.scope.outputs.mode == 'full' - run: npm run smoke + if: steps.scope.outputs.mode == 'full' - run: npm run smoke:ocr + if: steps.scope.outputs.mode == 'full' - run: npm run smoke:ai + if: steps.scope.outputs.mode == 'full' - run: npm run test:pack + if: steps.scope.outputs.mode == 'full' - run: npm run smoke:ink + if: steps.scope.outputs.mode == 'full' - name: Start the dev server + if: steps.scope.outputs.mode == 'full' run: | npm run dev > dev-server.log 2>&1 & echo $! > dev-server.pid @@ -71,14 +102,17 @@ jobs: cat dev-server.log exit 1 - name: Sheet end-to-end + if: steps.scope.outputs.mode == 'full' env: SHOTS: e2e-shots run: npm run e2e:sheet - name: Ink gestures + if: steps.scope.outputs.mode == 'full' env: SHOTS: e2e-shots run: npm run e2e:ink - name: Per-formula plot end-to-end + if: steps.scope.outputs.mode == 'full' env: SHOTS: e2e-plots run: npm run e2e diff --git a/scripts/ci-scope-smoke.mjs b/scripts/ci-scope-smoke.mjs new file mode 100644 index 0000000..c9125b2 --- /dev/null +++ b/scripts/ci-scope-smoke.mjs @@ -0,0 +1,170 @@ +/** + * Regression cases for the CI scope classifier and the light version check. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { classifyEntries, classifyGit } from './ci-scope.mjs' +import { checkVersions } from './ci-version-check.mjs' + +let failed = 0 + +function check(label, actual, expected) { + if (actual !== expected) { + failed += 1 + console.error(`FAIL ${label}: ${actual} !== ${expected}`) + } else { + console.log(`ok ${label}`) + } +} + +function entry(path, oldText, newText, status = 'M') { + return { status, oldMode: '100644', newMode: '100644', path, oldText, newText } +} + +const pkg = (version, extra = '') => `{"name":"mathscrap","version":"${version}"${extra}}` +const versionTs = (version) => `export const APP_VERSION = '${version}'\n` + +check('version fields only', classifyEntries([ + entry('package.json', pkg('0.0.1'), pkg('0.0.2')), + entry('src/version.ts', versionTs('0.0.1'), versionTs('0.0.2')), + entry('src-tauri/tauri.conf.json', pkg('0.0.1'), pkg('0.0.2')), + entry('src/wasm/pkg/package.json', pkg('0.0.1'), pkg('0.0.2')), + entry('mcp/server.ts', "const server = new Server({ name: 'mathscrap', version: '0.0.1' }, { capabilities: { tools: {} } })\n", "const server = new Server({ name: 'mathscrap', version: '0.0.2' }, { capabilities: { tools: {} } })\n"), + entry('src-tauri/Cargo.toml', '[package]\nname = "mathscrap"\nversion = "0.0.1"\n', '[package]\nname = "mathscrap"\nversion = "0.0.2"\n'), + entry('crates/mathcore/Cargo.toml', '[package]\nname = "mathcore"\nversion = "0.0.1"\n\n[dependencies]\nserde = { version = "1" }\n', '[package]\nname = "mathcore"\nversion = "0.0.2"\n\n[dependencies]\nserde = { version = "1" }\n'), + entry('package-lock.json', JSON.stringify({ version: '0.0.1', packages: { '': { version: '0.0.1', dependencies: { react: '^19.0.0' } }, react: { version: '19.0.0' } } }), JSON.stringify({ version: '0.0.2', packages: { '': { version: '0.0.2', dependencies: { react: '^19.0.0' } }, react: { version: '19.0.0' } } })), + entry('src-tauri/Cargo.lock', '[[package]]\nname = "mathscrap"\nversion = "0.0.1"\n', '[[package]]\nname = "mathscrap"\nversion = "0.0.2"\n'), +]).mode, 'light') + +check('version plus function', classifyEntries([ + entry('package.json', pkg('0.0.1'), pkg('0.0.2')), + entry('src/board/ink.ts', 'export function ink() { return 1 }\n', 'export function ink() { return 2 }\n'), +]).mode, 'full') + +check('package scripts', classifyEntries([ + entry('package.json', pkg('0.0.1', ',"scripts":{"test":"a"}'), pkg('0.0.1', ',"scripts":{"test":"b"}')), +]).mode, 'full') + +check('package dependency', classifyEntries([ + entry('package.json', pkg('0.0.1', ',"dependencies":{"react":"1"}'), pkg('0.0.1', ',"dependencies":{"react":"2"}')), +]).mode, 'full') + +check('lock dependency', classifyEntries([ + entry('package-lock.json', '{"version":"0.0.1","packages":{"":{"version":"0.0.1"},"react":{"version":"1.0.0"}}}', '{"version":"0.0.1","packages":{"":{"version":"0.0.1"},"react":{"version":"2.0.0"}}}'), +]).mode, 'full') + +check('workflow', classifyEntries([ + entry('.github/workflows/package.yml', 'name: package\n', 'name: package\njobs: {}\n'), +]).mode, 'full') + +check('test script', classifyEntries([ + entry('scripts/ci-scope.mjs', 'export const a = 1\n', 'export const a = 2\n'), +]).mode, 'full') + +check('unknown', classifyEntries([ + entry('src/new-file.ts', 'a\n', 'b\n'), +]).mode, 'full') + +check('delete', classifyEntries([ + { status: 'D', oldMode: '100644', newMode: '000000', path: 'README.md', oldText: 'a\n', newText: null }, +]).mode, 'full') + +check('rename', classifyEntries([ + { status: 'R100', oldMode: '100644', newMode: '100644', oldPath: 'README.md', path: 'docs/README.md', oldText: null, newText: null }, +]).mode, 'full') + +check('docs only', classifyEntries([ + entry('README.md', '版本 `0.0.1`\n', '版本 `0.0.1`\n\n更多说明。\n'), +]).mode, 'full' === 'light' ? 'full' : classifyEntries([ + entry('README.md', '版本 `0.0.1`\n', '版本 `0.0.1`\n\n更多说明。\n'), +]).mode) + +check('docs only mode', classifyEntries([ + entry('README.md', '版本 `0.0.1`\n', '版本 `0.0.1`\n\n更多说明。\n'), +]).mode, 'light') + +check('invalid json is full', classifyEntries([ + entry('package.json', pkg('0.0.1'), '{'), +]).mode, 'full') + +check('empty diff', classifyEntries([]).mode, 'light') + +function git(cwd, args) { + execFileSync('git', args, { cwd, stdio: 'ignore' }) +} + +const repo = mkdtempSync(join(tmpdir(), 'ci-scope-')) +git(repo, ['init', '-q', '-b', 'main']) +git(repo, ['config', 'user.email', '3174600270@qq.com']) +git(repo, ['config', 'user.name', 'DNT_OF']) +writeFileSync(join(repo, 'package.json'), pkg('0.0.1')) +git(repo, ['add', 'package.json']) +git(repo, ['commit', '-q', '-m', 'base']) +const base = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim() +writeFileSync(join(repo, 'package.json'), pkg('0.0.2')) +git(repo, ['commit', '-q', '-am', 'version one']) +writeFileSync(join(repo, 'package.json'), pkg('0.0.3')) +git(repo, ['commit', '-q', '-am', 'version two']) +const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim() +check('multi commit version', classifyGit(repo, base, head).mode, 'light') +writeFileSync(join(repo, 'src-note.ts'), 'export const n = 1\n') +git(repo, ['add', 'src-note.ts']) +git(repo, ['commit', '-q', '-m', 'code']) +const mixed = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim() +check('multi commit mixed', classifyGit(repo, base, mixed).mode, 'full') +let baselineFailed = false +try { + classifyGit(repo, '0'.repeat(40), head) +} catch { + baselineFailed = true +} +check('missing baseline fails', baselineFailed, true) +rmSync(repo, { recursive: true, force: true }) + +const tree = mkdtempSync(join(tmpdir(), 'ci-version-')) +function plant(version, readmeVersion = version) { + mkdirSync(join(tree, 'src-tauri'), { recursive: true }) + mkdirSync(join(tree, 'crates/mathcore'), { recursive: true }) + mkdirSync(join(tree, 'src/wasm/pkg'), { recursive: true }) + mkdirSync(join(tree, 'mcp'), { recursive: true }) + writeFileSync(join(tree, 'package.json'), pkg(version)) + writeFileSync(join(tree, 'package-lock.json'), JSON.stringify({ version, packages: { '': { version } } })) + writeFileSync(join(tree, 'src/version.ts'), versionTs(version)) + writeFileSync(join(tree, 'src-tauri/tauri.conf.json'), JSON.stringify({ version })) + writeFileSync(join(tree, 'src-tauri/Cargo.toml'), `[package]\nname = "mathscrap"\nversion = "${version}"\n`) + writeFileSync(join(tree, 'src-tauri/Cargo.lock'), `[[package]]\nname = "mathscrap"\nversion = "${version}"\n`) + writeFileSync(join(tree, 'crates/mathcore/Cargo.toml'), `[package]\nname = "mathcore"\nversion = "${version}"\n`) + writeFileSync(join(tree, 'crates/mathcore/Cargo.lock'), `[[package]]\nname = "mathcore"\nversion = "${version}"\n`) + writeFileSync(join(tree, 'src/wasm/pkg/package.json'), pkg(version)) + writeFileSync(join(tree, 'mcp/server.ts'), `new Server({ name: 'mathscrap', version: '${version}' })\n`) + writeFileSync(join(tree, 'README.md'), `版本 \`${readmeVersion}\`\nversion \`${readmeVersion}\`\n`) +} +plant('1.2.3') +check('versions agree', checkVersions(`${tree}/`), '1.2.3') +let mismatch = false +try { + plant('1.2.3', '9.9.9') + checkVersions(`${tree}/`) +} catch { + mismatch = true +} +check('readme mismatch fails', mismatch, true) +let illegal = false +try { + plant('1.2') + checkVersions(`${tree}/`) +} catch { + illegal = true +} +check('illegal semver fails', illegal, true) +rmSync(tree, { recursive: true, force: true }) + +check('repo versions agree', checkVersions(`${process.cwd()}/`), '0.0.1') + +if (failed) { + console.error(`${failed} failed`) + process.exit(1) +} +console.log('ci-scope smoke ok') diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs new file mode 100644 index 0000000..a1f8f29 --- /dev/null +++ b/scripts/ci-scope.mjs @@ -0,0 +1,314 @@ +/** + * Decide whether a diff needs the full test job or a version check. + * Unknown, renamed, deleted, added, or unreadable changes are full. + * A broken baseline fails the process instead of passing. + */ +import { execFileSync } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ +const LOCAL_CRATES = new Set(['mathscrap', 'mathcore']) + +const VERSION_FILES = new Set([ + 'package.json', + 'package-lock.json', + 'src/version.ts', + 'src-tauri/tauri.conf.json', + 'src-tauri/Cargo.toml', + 'src-tauri/Cargo.lock', + 'crates/mathcore/Cargo.toml', + 'crates/mathcore/Cargo.lock', + 'src/wasm/pkg/package.json', + 'mcp/server.ts', +]) + +export function isDocsPath(path) { + return path === 'README.md' || path.startsWith('docs/') || path.startsWith('signing/') && path.endsWith('.md') +} + +function canon(value) { + if (Array.isArray(value)) return value.map(canon) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canon(value[key])])) + } + return value +} + +function jsonEqual(left, right) { + return JSON.stringify(canon(left)) === JSON.stringify(canon(right)) +} + +function jsonVersionOnly(oldText, newText) { + let oldJson + let newJson + try { + oldJson = JSON.parse(oldText) + newJson = JSON.parse(newText) + } catch { + return false + } + if (!oldJson || typeof oldJson !== 'object' || !newJson || typeof newJson !== 'object') return false + oldJson.version = '0.0.0' + newJson.version = '0.0.0' + return jsonEqual(oldJson, newJson) +} + +function lockVersionOnly(oldText, newText) { + let oldJson + let newJson + try { + oldJson = JSON.parse(oldText) + newJson = JSON.parse(newText) + } catch { + return false + } + const rewind = (value) => { + const copy = JSON.parse(JSON.stringify(value)) + if (Object.prototype.hasOwnProperty.call(copy, 'version')) copy.version = '0.0.0' + if (copy.packages && copy.packages[''] && Object.prototype.hasOwnProperty.call(copy.packages[''], 'version')) { + copy.packages[''].version = '0.0.0' + } + return copy + } + return jsonEqual(rewind(oldJson), rewind(newJson)) +} + +function cargoManifestVersionOnly(oldText, newText) { + const norm = (text) => { + let inPackage = false + let replaced = false + return text.split('\n').map((line) => { + if (/^\[/.test(line)) inPackage = line.trim() === '[package]' + if (inPackage && !replaced && /^version\s*=\s*"/.test(line)) { + replaced = true + return 'version = "0.0.0"' + } + return line + }).join('\n') + } + return norm(oldText) === norm(newText) +} + +function cargoLockVersionOnly(oldText, newText) { + const norm = (text) => text.split('\n[[package]]\n').map((block, index) => { + const body = index === 0 ? block : `[[package]]\n${block}` + const name = body.match(/^name = "([^"]+)"/m) + if (!name || !LOCAL_CRATES.has(name[1])) return body + return body.replace(/^version = "[^"]*"/m, 'version = "0.0.0"') + }).join('\n') + return norm(oldText) === norm(newText) +} + +function literalVersionOnly(oldText, newText, pattern) { + const mask = (text) => text.replace(pattern, '$10.0.0$2') + return mask(oldText) === mask(newText) +} + +export function versionFieldOnly(path, oldText, newText) { + if (oldText == null || newText == null) return false + if (oldText.includes('\0') || newText.includes('\0')) return false + if (path === 'package-lock.json') return lockVersionOnly(oldText, newText) + if (path === 'package.json' || path === 'src-tauri/tauri.conf.json' || path === 'src/wasm/pkg/package.json') { + return jsonVersionOnly(oldText, newText) + } + if (path.endsWith('Cargo.toml')) return cargoManifestVersionOnly(oldText, newText) + if (path.endsWith('Cargo.lock')) return cargoLockVersionOnly(oldText, newText) + if (path === 'src/version.ts') return literalVersionOnly(oldText, newText, /(export const APP_VERSION = ')[^']*(')/) + if (path === 'mcp/server.ts') return literalVersionOnly(oldText, newText, /(name: 'mathscrap', version: ')[^']*(')/) + return false +} + +export function classifyEntries(entries) { + if (!Array.isArray(entries)) { + return { mode: 'full', reasonZh: '分类输入无法读取,改走全量。', reasonEn: 'The classifier input could not be read, so the full test runs.' } + } + if (entries.length === 0) { + return { mode: 'light', reasonZh: '基线之间没有文件变化。', reasonEn: 'There are no file changes between the baselines.' } + } + const notes = [] + for (const entry of entries) { + const status = entry.status || '' + if (status.startsWith('R') || status.startsWith('C') || status === 'T') { + notes.push(`${entry.path || entry.oldPath} 被重命名或改变类型`) + continue + } + if (status === 'D' || status === 'A') { + notes.push(`${entry.path} 被${status === 'D' ? '删除' : '新增'}`) + continue + } + if (status !== 'M' || entry.oldMode !== entry.newMode) { + notes.push(`${entry.path || '(unknown)'} 无法按字段识别`) + continue + } + if (isDocsPath(entry.path)) { + if ((entry.oldText || '').includes('\0') || (entry.newText || '').includes('\0')) { + notes.push(`${entry.path} 无法按文档文本识别`) + continue + } + notes.push(`${entry.path} 只是文档`) + continue + } + if (VERSION_FILES.has(entry.path) && versionFieldOnly(entry.path, entry.oldText, entry.newText)) { + notes.push(`${entry.path} 只有版本字段变化`) + continue + } + notes.push(`${entry.path} 含有版本字段以外的变化`) + } + const heavy = notes.filter((note) => !note.endsWith('只是文档') && !note.endsWith('只有版本字段变化')) + if (heavy.length) { + return { + mode: 'full', + reasonZh: `这些变化需要全量测试:${heavy.join(';')}。`, + reasonEn: `These changes need the full test: ${heavy.join('; ')}.`, + } + } + return { + mode: 'light', + reasonZh: `变化都在文档或版本字段白名单内:${notes.join(';')}。`, + reasonEn: `Every change is documentation or a whitelisted version field: ${notes.join('; ')}.`, + } +} + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function gitOk(cwd, args) { + try { + git(cwd, args) + return true + } catch { + return false + } +} + +export function assertBaseline(cwd, base, head) { + if (!base || !head || /^0+$/.test(base)) { + throw new Error('The diff baseline is missing.') + } + if (!gitOk(cwd, ['cat-file', '-e', `${base}^{commit}`]) || !gitOk(cwd, ['cat-file', '-e', `${head}^{commit}`])) { + throw new Error('The diff baseline is not in this clone.') + } + let mergeBase + try { + mergeBase = git(cwd, ['merge-base', base, head]).trim() + } catch { + throw new Error('git merge-base failed.') + } + return mergeBase +} + +function readAt(cwd, rev, path) { + try { + return execFileSync('git', ['show', `${rev}:${path}`], { cwd, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'] }) + } catch { + return null + } +} + +export function entriesFromGit(cwd, base, head) { + const mergeBase = assertBaseline(cwd, base, head) + let raw + try { + raw = git(cwd, ['diff', '--raw', '-M', mergeBase, head]) + } catch { + throw new Error('git diff failed.') + } + const entries = [] + for (const line of raw.split('\n')) { + if (!line) continue + const tab = line.indexOf('\t') + if (tab < 0) throw new Error('git diff output could not be parsed.') + const meta = line.slice(0, tab).split(' ') + const paths = line.slice(tab + 1).split('\t') + const status = meta[4] + const oldMode = meta[0].replace(/^:/, '') + const newMode = meta[1] + if (status.startsWith('R') || status.startsWith('C')) { + entries.push({ status, oldMode, newMode, oldPath: paths[0], path: paths[1], oldText: null, newText: null }) + continue + } + const path = paths[0] + const oldBuf = status === 'A' ? null : readAt(cwd, mergeBase, path) + const newBuf = status === 'D' ? null : readAt(cwd, head, path) + const asText = (buf) => (buf == null ? null : buf.includes(0) ? '\0' : buf.toString('utf8')) + entries.push({ status, oldMode, newMode, path, oldText: asText(oldBuf), newText: asText(newBuf) }) + } + return { mergeBase, entries } +} + +export function classifyGit(cwd, base, head) { + const { mergeBase, entries } = entriesFromGit(cwd, base, head) + return { mergeBase, ...classifyEntries(entries) } +} + +function writeSummary(result) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY + const modeName = result.mode === 'light' ? '轻量 / light' : '全量 / full' + const body = [ + `### CI 范围:${modeName}`, + '', + result.reasonZh, + '', + result.reasonEn, + '', + result.mode === 'light' + ? '轻量路径只核对各处版本号一致,并且都是 x.y.z。不一致就失败,不会记成通过。全量的 lint、Rust、smoke 和端到端测试这次不跑。' + : '全量路径会跑原有的 lint、Rust 测试、smoke,以及纸面、笔迹和公式的端到端测试。', + '', + result.mode === 'light' + ? 'The light path only checks that the recorded versions match and are x.y.z. A mismatch fails this check. Lint, Rust, smoke, and end-to-end tests do not run.' + : 'The full path runs the existing lint, Rust tests, smoke tests, and the sheet, ink, and plot end-to-end tests.', + '', + ].join('\n') + console.log(body) + if (summaryPath) { + execFileSync('tee', ['-a', summaryPath], { input: body }) + } + if (process.env.GITHUB_OUTPUT) { + execFileSync('tee', ['-a', process.env.GITHUB_OUTPUT], { input: `mode=${result.mode}\n` }) + } +} + +function resolvePair(cwd) { + const event = process.env.GITHUB_EVENT_NAME || '' + if (event === 'workflow_dispatch') { + return { forced: { mode: 'full', reasonZh: '手动运行没有可比较的基线,改走全量。', reasonEn: 'A manual run has no diff baseline, so the full test runs.' } } + } + if (event === 'pull_request') { + const number = process.env.PR_NUMBER || '' + const baseRef = process.env.PR_BASE_REF || '' + if (!/^\d+$/.test(number) || !/^[\w./-]+$/.test(baseRef)) { + throw new Error('The pull request baseline is not usable.') + } + git(cwd, ['fetch', 'origin', `+refs/heads/${baseRef}:refs/remotes/origin/pr-base`]) + git(cwd, ['fetch', 'origin', `+refs/pull/${number}/head:refs/remotes/origin/pr-head`]) + return { + base: process.env.PR_BASE_SHA || git(cwd, ['rev-parse', 'refs/remotes/origin/pr-base']).trim(), + head: process.env.PR_HEAD_SHA || git(cwd, ['rev-parse', 'refs/remotes/origin/pr-head']).trim(), + } + } + if (event === 'push') { + const before = process.env.PUSH_BEFORE || '' + const head = process.env.GITHUB_SHA || '' + if (!gitOk(cwd, ['merge-base', '--is-ancestor', before, head])) { + throw new Error('The push baseline is not an ancestor of the new commit.') + } + return { base: before, head } + } + throw new Error(`Unsupported event ${event || '(empty)'}.`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const cwd = process.cwd() + const pair = resolvePair(cwd) + const result = pair.forced || classifyGit(cwd, pair.base, pair.head) + writeSummary(result) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`CI scope failed: ${message}`) + console.error('The baseline could not be classified. This check fails instead of passing.') + process.exit(1) + } +} diff --git a/scripts/ci-version-check.mjs b/scripts/ci-version-check.mjs new file mode 100644 index 0000000..d62263b --- /dev/null +++ b/scripts/ci-version-check.mjs @@ -0,0 +1,96 @@ +/** + * Confirm the recorded app versions are the same legal x.y.z. + * A light classification still fails when this check fails. + */ +import { readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +function read(root, path) { + return readFileSync(new URL(path, root), 'utf8') +} + +function packageVersion(toml) { + let inPackage = false + for (const line of toml.split('\n')) { + if (/^\[/.test(line)) inPackage = line.trim() === '[package]' + const match = inPackage && line.match(/^version\s*=\s*"([^"]*)"/) + if (match) return match[1] + } + return '' +} + +function lockVersion(text, name) { + const blocks = text.split('[[package]]').slice(1) + for (const block of blocks) { + if (block.match(new RegExp(`^name = "${name}"`, 'm'))) { + const version = block.match(/^version = "([^"]*)"/m) + return version ? version[1] : '' + } + } + return '' +} + +export function collectVersions(root) { + const base = new URL(root.endsWith('/') ? root : `${root}/`, 'file:') + const pkg = JSON.parse(read(base, 'package.json')) + const lock = JSON.parse(read(base, 'package-lock.json')) + const tauri = JSON.parse(read(base, 'src-tauri/tauri.conf.json')) + const wasm = JSON.parse(read(base, 'src/wasm/pkg/package.json')) + const versionTs = read(base, 'src/version.ts') + const mcp = read(base, 'mcp/server.ts') + const readme = read(base, 'README.md') + const app = versionTs.match(/export const APP_VERSION = '([^']*)'/) + const mcpVersion = mcp.match(/name: 'mathscrap', version: '([^']*)'/) + const readmeVersions = [ + ...readme.matchAll(/版本 `(\d+\.\d+\.\d+)`/g), + ...readme.matchAll(/version `(\d+\.\d+\.\d+)`/g), + ].map((match) => match[1]) + return [ + ['package.json', pkg.version], + ['package-lock.json', lock.version], + ['package-lock.json packages', lock.packages?.['']?.version], + ['src/version.ts', app ? app[1] : ''], + ['src-tauri/tauri.conf.json', tauri.version], + ['src-tauri/Cargo.toml', packageVersion(read(base, 'src-tauri/Cargo.toml'))], + ['src-tauri/Cargo.lock', lockVersion(read(base, 'src-tauri/Cargo.lock'), 'mathscrap')], + ['crates/mathcore/Cargo.toml', packageVersion(read(base, 'crates/mathcore/Cargo.toml'))], + ['crates/mathcore/Cargo.lock', lockVersion(read(base, 'crates/mathcore/Cargo.lock'), 'mathcore')], + ['src/wasm/pkg/package.json', wasm.version], + ['mcp/server.ts', mcpVersion ? mcpVersion[1] : ''], + ...readmeVersions.map((version, index) => [`README.md#${index + 1}`, version]), + ] +} + +export function checkVersions(root) { + const found = collectVersions(root) + const missing = found.filter(([, version]) => !version) + if (missing.length) { + throw new Error(`Missing version in ${missing.map(([name]) => name).join(', ')}`) + } + const illegal = found.filter(([, version]) => !SEMVER.test(version)) + if (illegal.length) { + throw new Error(`Illegal version in ${illegal.map(([name, version]) => `${name}=${version}`).join(', ')}`) + } + const expected = found[0][1] + const mismatch = found.filter(([, version]) => version !== expected) + if (mismatch.length) { + throw new Error(`Version mismatch against ${expected}: ${mismatch.map(([name, version]) => `${name}=${version}`).join(', ')}`) + } + return expected +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const version = checkVersions(`${process.cwd()}/`) + console.log(`version check ok ${version}`) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} + +export function runVersionCheck() { + return checkVersions(`${process.cwd()}/`) +} From 4ab43926b5759bd2f49185cc716eec8e49902d82 Mon Sep 17 00:00:00 2001 From: DNT_OF <3174600270@qq.com> Date: Sat, 7 Nov 2026 17:28:14 +0000 Subject: [PATCH 2/2] ci: clear the scope classifier lint warnings oxlint rejects the unused semver pattern and the constant docs check. The classification rules stay the same. Co-authored-by: DNT_OF --- scripts/ci-scope-smoke.mjs | 6 ------ scripts/ci-scope.mjs | 1 - 2 files changed, 7 deletions(-) diff --git a/scripts/ci-scope-smoke.mjs b/scripts/ci-scope-smoke.mjs index c9125b2..a934b0f 100644 --- a/scripts/ci-scope-smoke.mjs +++ b/scripts/ci-scope-smoke.mjs @@ -77,12 +77,6 @@ check('rename', classifyEntries([ check('docs only', classifyEntries([ entry('README.md', '版本 `0.0.1`\n', '版本 `0.0.1`\n\n更多说明。\n'), -]).mode, 'full' === 'light' ? 'full' : classifyEntries([ - entry('README.md', '版本 `0.0.1`\n', '版本 `0.0.1`\n\n更多说明。\n'), -]).mode) - -check('docs only mode', classifyEntries([ - entry('README.md', '版本 `0.0.1`\n', '版本 `0.0.1`\n\n更多说明。\n'), ]).mode, 'light') check('invalid json is full', classifyEntries([ diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index a1f8f29..66d1818 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -6,7 +6,6 @@ import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' -const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ const LOCAL_CRATES = new Set(['mathscrap', 'mathcore']) const VERSION_FILES = new Set([