mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 00:23:37 +08:00
ci: check changed-file formatting (#557)
This commit is contained in:
25
.github/workflows/test.yml
vendored
25
.github/workflows/test.yml
vendored
@@ -13,6 +13,31 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: yarn
|
||||
cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Check changed-file formatting
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -105,7 +105,7 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
|
||||
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`。
|
||||
4. 文件集合为空时正常通过。
|
||||
|
||||
本地直接运行 `yarn format` 或 `yarn format:check` 时,脚本以可用的 `upstream/v2`、`origin/v2` 或本地 `v2` 为基线,并合并当前工作区与未跟踪文件。CI 使用显式引用保证输入可复现:
|
||||
本地直接运行 `yarn format` 或 `yarn format:check` 时,脚本按 `refs/remotes/upstream/v2`、`refs/remotes/origin/v2`、`refs/heads/v2` 的顺序选择与当前提交具有共同祖先的基线,并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义;CI 使用显式提交 SHA 保证输入可复现:
|
||||
|
||||
```sh
|
||||
yarn format:check --base <base-sha> --head <head-sha>
|
||||
@@ -120,7 +120,7 @@ yarn format:check --base <base-sha> --head <head-sha>
|
||||
具体交付拆分为:
|
||||
|
||||
1. Prettier 基础设施 PR:安装项目 CLI,定义配置、命令、变更文件选择脚本和回归测试,不修改 workflow。
|
||||
2. 变更文件门禁 PR:复用已合并脚本接入 Pull Request workflow;验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名后,将该稳定 check 配置为 required。
|
||||
2. 变更文件门禁 PR:复用已合并脚本接入 Pull Request workflow;验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名。初期作为普通 check 观察,稳定后再由维护者通过仓库规则独立配置为 required。
|
||||
3. 全仓门禁 PR:仅在存量收敛条件满足后择机实施,不与变更文件门禁绑定。
|
||||
|
||||
## 全仓格式门禁的启用条件
|
||||
@@ -129,6 +129,8 @@ yarn format:check --base <base-sha> --head <head-sha>
|
||||
|
||||
Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。
|
||||
|
||||
变更文件 CI 接入前在 `v2` 再次测量为 206 个文件,较基础设施合并后的 201 个文件出现回升。这说明仅提供本地命令不足以阻止新改动扩大存量;变更文件 check 用于守住新增 diff,不因此要求在本阶段批量格式化已有文件。
|
||||
|
||||
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check:
|
||||
|
||||
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"format": "node scripts/format-changed.mjs --write",
|
||||
"format:check": "node scripts/format-changed.mjs --check",
|
||||
"format:all:check": "prettier . --check",
|
||||
"test:format-config": "vitest run tests/config/format-changed.spec.ts",
|
||||
"test:format-config": "vitest run tests/config/format-changed.spec.ts tests/config/frontend-workflow.spec.ts",
|
||||
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
|
||||
"postinstall": "npm run build:icons",
|
||||
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
|
||||
|
||||
@@ -5,7 +5,8 @@ import process from 'node:process'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import * as prettier from 'prettier'
|
||||
|
||||
const DEFAULT_BASE_REFS = ['upstream/v2', 'origin/v2', 'v2']
|
||||
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v2', 'refs/remotes/origin/v2', 'refs/heads/v2']
|
||||
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
|
||||
const PRETTIER_ARGUMENT_BUDGET = 24_000
|
||||
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
|
||||
|
||||
@@ -16,39 +17,44 @@ function parseNullDelimited(output) {
|
||||
|
||||
/** 运行只返回仓库相对路径的 Git 查询。 */
|
||||
function gitPaths(cwd, args) {
|
||||
return parseNullDelimited(execFileSync('git', args, { cwd, encoding: 'buffer' }))
|
||||
return parseNullDelimited(
|
||||
execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** 判断本地仓库是否存在可用于三点 diff 的提交引用。 */
|
||||
function hasCommitRef(cwd, ref) {
|
||||
return (
|
||||
spawnSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
}).status === 0
|
||||
)
|
||||
/** 尝试把引用解析为提交 SHA;完整引用可避免分支与标签同名时产生歧义。 */
|
||||
function tryResolveCommit(cwd, ref) {
|
||||
const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
return result.status === 0 ? result.stdout.trim() : undefined
|
||||
}
|
||||
|
||||
/** 把外部引用解析为提交 SHA,后续 Git diff 不再解释用户提供的选项样式字符串。 */
|
||||
function resolveCommit(cwd, ref) {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
} catch {
|
||||
throw new Error(`无法解析 Git 提交引用:${ref}`)
|
||||
}
|
||||
const commit = tryResolveCommit(cwd, ref)
|
||||
if (commit) return commit
|
||||
throw new Error(`无法解析 Git 提交引用:${ref}`)
|
||||
}
|
||||
|
||||
/** 为本地命令选择现有 v2 基线;CI 应显式传入事件中的 base/head SHA。 */
|
||||
function resolveDefaultBase(cwd) {
|
||||
const base = DEFAULT_BASE_REFS.find(ref => hasCommitRef(cwd, ref))
|
||||
if (!base) {
|
||||
throw new Error('无法定位 v2 基线,请通过 --base <ref> 显式指定比较起点。')
|
||||
/** 判断两个提交能否进行三点比较。 */
|
||||
function hasMergeBase(cwd, baseCommit, headCommit) {
|
||||
return spawnSync('git', ['merge-base', baseCommit, headCommit], { cwd, stdio: 'ignore' }).status === 0
|
||||
}
|
||||
|
||||
/** 为本地命令选择与当前提交具有共同祖先的 v2 基线。 */
|
||||
function resolveDefaultBase(cwd, headCommit) {
|
||||
for (const ref of DEFAULT_BASE_REFS) {
|
||||
const commit = tryResolveCommit(cwd, ref)
|
||||
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
|
||||
}
|
||||
|
||||
return base
|
||||
throw new Error('无法定位可比较的 v2 基线,请通过 --base <ref> 显式指定比较起点。')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,16 +67,17 @@ function collectChangedPaths({ cwd, base, head }) {
|
||||
}
|
||||
|
||||
const explicitRange = Boolean(base)
|
||||
const resolvedBase = base ?? resolveDefaultBase(cwd)
|
||||
const resolvedHead = head ?? 'HEAD'
|
||||
const baseCommit = resolveCommit(cwd, resolvedBase)
|
||||
const headCommit = resolveCommit(cwd, resolvedHead)
|
||||
const headCommit = resolveCommit(cwd, head ?? 'HEAD')
|
||||
const baseCommit = base ? resolveCommit(cwd, base) : resolveDefaultBase(cwd, headCommit)
|
||||
if (!hasMergeBase(cwd, baseCommit, headCommit)) {
|
||||
throw new Error('Git 比较起点与终点没有共同祖先,无法执行三点比较。')
|
||||
}
|
||||
const paths = new Set(
|
||||
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMR', '-z', `${baseCommit}...${headCommit}`]),
|
||||
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', `${baseCommit}...${headCommit}`]),
|
||||
)
|
||||
|
||||
if (!explicitRange) {
|
||||
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMR', '-z', 'HEAD'])) {
|
||||
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', 'HEAD'])) {
|
||||
paths.add(filePath)
|
||||
}
|
||||
for (const filePath of gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])) {
|
||||
@@ -178,7 +185,7 @@ function parseArguments(args) {
|
||||
return options
|
||||
}
|
||||
|
||||
/** 执行变更文件选择与格式化;显式引用模式为后续 PR workflow 提供确定输入。 */
|
||||
/** 执行变更文件选择与格式化;显式引用模式为 CI 提供确定输入。 */
|
||||
async function main() {
|
||||
const cwd = process.cwd()
|
||||
const { action, base, head } = parseArguments(process.argv.slice(2))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { delimiter, dirname, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const selectorScript = resolve(process.cwd(), 'scripts/format-changed.mjs')
|
||||
@@ -36,15 +36,21 @@ function createRepository() {
|
||||
return repository
|
||||
}
|
||||
|
||||
function listSelected(repository: string, ...args: string[]) {
|
||||
function runSelector(repository: string, args: string[], env = process.env) {
|
||||
const output = execFileSync(process.execPath, [selectorScript, '--list', ...args], {
|
||||
cwd: repository,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
})
|
||||
|
||||
return JSON.parse(output) as string[]
|
||||
}
|
||||
|
||||
function listSelected(repository: string, ...args: string[]) {
|
||||
return runSelector(repository, args)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const repository of temporaryRepositories.splice(0)) {
|
||||
rmSync(repository, { recursive: true, force: true })
|
||||
@@ -124,4 +130,85 @@ describe('Prettier 变更文件选择器', () => {
|
||||
|
||||
expect(readFileSync(resolve(repository, '--special [file].ts'), 'utf8')).toBe('export const special = true;\n')
|
||||
})
|
||||
|
||||
it('本地基线使用完整分支引用,不受同名标签影响', () => {
|
||||
const repository = createRepository()
|
||||
git(repository, 'switch', '-c', 'feature')
|
||||
write(repository, 'src/modified.ts', "export const value='feature'\n")
|
||||
git(repository, 'add', 'src/modified.ts')
|
||||
git(repository, 'commit', '-m', 'feature')
|
||||
git(repository, 'tag', 'v2', 'HEAD')
|
||||
|
||||
expect(listSelected(repository)).toEqual(['src/modified.ts'])
|
||||
})
|
||||
|
||||
it('首个候选没有 merge base 时回退到后续可比较分支', () => {
|
||||
const repository = createRepository()
|
||||
const baseCommit = git(repository, 'rev-parse', 'HEAD')
|
||||
git(repository, 'switch', '-c', 'feature')
|
||||
write(repository, 'src/modified.ts', "export const value='feature'\n")
|
||||
git(repository, 'add', 'src/modified.ts')
|
||||
git(repository, 'commit', '-m', 'feature')
|
||||
const tree = git(repository, 'rev-parse', `${baseCommit}^{tree}`)
|
||||
const unrelatedCommit = execFileSync('git', ['commit-tree', tree, '-m', 'unrelated'], {
|
||||
cwd: repository,
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
git(repository, 'update-ref', 'refs/remotes/upstream/v2', unrelatedCommit)
|
||||
git(repository, 'update-ref', 'refs/remotes/origin/v2', baseCommit)
|
||||
|
||||
expect(listSelected(repository)).toEqual(['src/modified.ts'])
|
||||
})
|
||||
|
||||
it('纳入转为普通文件的类型变更,并继续排除最终为符号链接的路径', () => {
|
||||
const repository = createRepository()
|
||||
symlinkSync('modified.ts', resolve(repository, 'src/to-regular.ts'))
|
||||
write(repository, 'src/to-link.ts', 'export const link = false\n')
|
||||
git(repository, 'add', 'src/to-regular.ts', 'src/to-link.ts')
|
||||
git(repository, 'commit', '-m', 'type-change base')
|
||||
git(repository, 'switch', '-c', 'feature')
|
||||
rmSync(resolve(repository, 'src/to-regular.ts'))
|
||||
write(repository, 'src/to-regular.ts', 'export const regular=true\n')
|
||||
rmSync(resolve(repository, 'src/to-link.ts'))
|
||||
symlinkSync('modified.ts', resolve(repository, 'src/to-link.ts'))
|
||||
git(repository, 'add', '-A')
|
||||
git(repository, 'commit', '-m', 'type changes')
|
||||
|
||||
expect(listSelected(repository, '--base', 'v2', '--head', 'HEAD')).toEqual(['src/to-regular.ts'])
|
||||
})
|
||||
|
||||
it('Git 路径输出超过默认缓冲区时仍能完成选择', () => {
|
||||
const repository = createRepository()
|
||||
const wrapperDirectory = resolve(repository, '.test-bin')
|
||||
const wrapperPath = resolve(wrapperDirectory, 'git')
|
||||
write(
|
||||
repository,
|
||||
'.test-bin/git',
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const { writeSync } = require('node:fs')
|
||||
const args = process.argv.slice(2)
|
||||
if (args[0] === 'ls-files') {
|
||||
const output = Buffer.from('src/modified.ts\\0'.repeat(300_000))
|
||||
let offset = 0
|
||||
while (offset < output.length) offset += writeSync(1, output, offset)
|
||||
process.exit(0)
|
||||
}
|
||||
const result = spawnSync('git', args, {
|
||||
env: { ...process.env, PATH: process.env.REAL_GIT_PATH },
|
||||
stdio: 'inherit',
|
||||
})
|
||||
process.exit(result.status ?? 1)
|
||||
`,
|
||||
)
|
||||
chmodSync(wrapperPath, 0o755)
|
||||
|
||||
expect(
|
||||
runSelector(repository, [], {
|
||||
...process.env,
|
||||
PATH: `${wrapperDirectory}${delimiter}${process.env.PATH ?? ''}`,
|
||||
REAL_GIT_PATH: process.env.PATH ?? '',
|
||||
}),
|
||||
).toEqual(['src/modified.ts'])
|
||||
})
|
||||
})
|
||||
|
||||
22
tests/config/frontend-workflow.spec.ts
Normal file
22
tests/config/frontend-workflow.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml')
|
||||
|
||||
describe('前端 Pull Request workflow', () => {
|
||||
it('使用只读的变更文件格式检查,并显式比较事件 base/head SHA', () => {
|
||||
const workflow = readFileSync(workflowPath, 'utf8')
|
||||
const formatJob = workflow.match(/\n {2}format:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
|
||||
|
||||
expect(workflow).toContain('permissions:\n contents: read')
|
||||
expect(formatJob).toBeDefined()
|
||||
expect(formatJob).toContain('fetch-depth: 0')
|
||||
expect(formatJob).toContain("node-version: '24'")
|
||||
expect(formatJob).toContain('run: yarn --frozen-lockfile')
|
||||
expect(formatJob).toContain('BASE_SHA: ${{ github.event.pull_request.base.sha }}')
|
||||
expect(formatJob).toContain('HEAD_SHA: ${{ github.event.pull_request.head.sha }}')
|
||||
expect(formatJob).toContain('run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"')
|
||||
expect(formatJob).not.toContain('--write')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user