mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 07:09:09 +08:00
ci: reuse verified PR checks and balance coverage shards
This commit is contained in:
80
.github/scripts/reuse.mjs
vendored
Normal file
80
.github/scripts/reuse.mjs
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
/** 只复用相同工作流对相同合并代码的完整成功检查;证据不足时执行全量门禁。 */
|
||||
export async function reuse({ github, context, core }) {
|
||||
core.setOutput('reuse', 'false')
|
||||
if (
|
||||
context.eventName !== 'push' ||
|
||||
context.payload.forced ||
|
||||
!context.payload.before?.match(/^[a-f0-9]{40}$/) ||
|
||||
/^0+$/.test(context.payload.before)
|
||||
)
|
||||
return
|
||||
|
||||
try {
|
||||
const repo = context.repo
|
||||
const { data: current } = await github.rest.actions.getWorkflowRun({ ...repo, run_id: context.runId })
|
||||
const { data: pushed } = await github.rest.git.getCommit({ ...repo, commit_sha: context.sha })
|
||||
const pulls = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
|
||||
...repo,
|
||||
commit_sha: context.sha,
|
||||
per_page: 100,
|
||||
})
|
||||
for (const pull of pulls) {
|
||||
if (
|
||||
!pull.merged_at ||
|
||||
pull.merge_commit_sha !== context.sha ||
|
||||
pull.base?.ref !== context.ref.replace('refs/heads/', '') ||
|
||||
pull.base?.repo?.full_name !== `${repo.owner}/${repo.repo}`
|
||||
)
|
||||
continue
|
||||
|
||||
// 不过滤 success:同一 PR 最新执行失败或仍在运行时,不能退回旧的绿灯。
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
...repo,
|
||||
workflow_id: current.workflow_id,
|
||||
event: 'pull_request',
|
||||
head_sha: pull.head.sha,
|
||||
per_page: 100,
|
||||
})
|
||||
const run = data.workflow_runs
|
||||
.filter(
|
||||
candidate => candidate.head_sha === pull.head.sha && candidate.head_repository?.id === pull.head.repo?.id,
|
||||
)
|
||||
.sort((left, right) => right.id - left.id)[0]
|
||||
if (!run || run.status !== 'completed' || run.conclusion !== 'success') continue
|
||||
|
||||
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
...repo,
|
||||
run_id: run.id,
|
||||
filter: 'latest',
|
||||
per_page: 100,
|
||||
})
|
||||
const proofs = jobs.filter(
|
||||
job =>
|
||||
job.status === 'completed' && job.conclusion === 'success' && /^CI proof \([a-f0-9]{40}\)$/.test(job.name),
|
||||
)
|
||||
if (proofs.length !== 1) continue
|
||||
const testedSha = proofs[0].name.slice(10, -1)
|
||||
const { data: tested } = await github.rest.git.getCommit({ ...repo, commit_sha: testedSha })
|
||||
// run.head_sha 是 PR 分支头;只有 proof 记录的 github.sha 才是实际测试的模拟合并提交。
|
||||
if (
|
||||
tested.parents.length !== 2 ||
|
||||
tested.parents[0].sha !== context.payload.before ||
|
||||
tested.parents[1].sha !== pull.head.sha ||
|
||||
tested.tree.sha !== pushed.tree.sha
|
||||
)
|
||||
continue
|
||||
|
||||
// 查询证明期间可能有人重新运行 CI,不能复用已经失效的运行快照。
|
||||
const { data: latest } = await github.rest.actions.getWorkflowRun({ ...repo, run_id: run.id })
|
||||
if (latest.status !== 'completed' || latest.conclusion !== 'success' || latest.run_attempt !== run.run_attempt)
|
||||
continue
|
||||
|
||||
core.setOutput('reuse', 'true')
|
||||
core.notice(`复用 PR #${pull.number} 的完整检查:${run.html_url};代码树 ${tested.tree.sha}`)
|
||||
return
|
||||
}
|
||||
core.info('没有匹配的完整 PR 检查,执行全量门禁。')
|
||||
} catch (error) {
|
||||
core.warning(`无法确认 PR 检查,执行全量门禁:${error.message}`)
|
||||
}
|
||||
}
|
||||
171
.github/scripts/reuse.test.mjs
vendored
Normal file
171
.github/scripts/reuse.test.mjs
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { reuse } from './reuse.mjs'
|
||||
|
||||
/** 构造带真实 GitHub 字段形状的离线 API 替身。 */
|
||||
function fixture() {
|
||||
const head = 'a'.repeat(40),
|
||||
base = 'b'.repeat(40),
|
||||
sha = 'c'.repeat(40),
|
||||
merge = 'd'.repeat(40)
|
||||
const state = {
|
||||
context: {
|
||||
eventName: 'push',
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
ref: 'refs/heads/v3',
|
||||
sha,
|
||||
runId: 20,
|
||||
payload: { before: base },
|
||||
},
|
||||
pull: {
|
||||
number: 7,
|
||||
merged_at: '2026-09-09T00:00:00Z',
|
||||
merge_commit_sha: sha,
|
||||
head: { sha: head, repo: { id: 2 } },
|
||||
base: { ref: 'v3', repo: { full_name: 'owner/repo' } },
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
id: 10,
|
||||
run_attempt: 1,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
head_sha: head,
|
||||
head_repository: { id: 2 },
|
||||
html_url: 'https://github.com/owner/repo/actions/runs/10',
|
||||
},
|
||||
],
|
||||
jobs: [{ name: `CI proof (${merge})`, status: 'completed', conclusion: 'success' }],
|
||||
tested: { parents: [{ sha: base }, { sha: head }], tree: { sha: 'tree' } },
|
||||
outputs: {},
|
||||
calls: [],
|
||||
warnings: [],
|
||||
}
|
||||
/** 保留 API 参数供测试验证,避免错误地过滤历史失败记录。 */
|
||||
const api = name => async args => {
|
||||
state.calls.push({ name, args })
|
||||
if (state.error) throw new Error('API unavailable')
|
||||
if (name === 'getWorkflowRun')
|
||||
return { data: args.run_id === 20 ? { workflow_id: 100 } : { ...state.runs[0], ...state.refreshed } }
|
||||
if (name === 'getCommit') return { data: args.commit_sha === sha ? { tree: { sha: 'tree' } } : state.tested }
|
||||
if (name === 'listPullRequestsAssociatedWithCommit') return [state.pull]
|
||||
if (name === 'listWorkflowRuns') return { data: { workflow_runs: state.runs } }
|
||||
if (name === 'listJobsForWorkflowRun') return state.jobs
|
||||
throw new Error(name)
|
||||
}
|
||||
state.github = {
|
||||
rest: {
|
||||
actions: {
|
||||
getWorkflowRun: api('getWorkflowRun'),
|
||||
listWorkflowRuns: api('listWorkflowRuns'),
|
||||
listJobsForWorkflowRun: api('listJobsForWorkflowRun'),
|
||||
},
|
||||
git: { getCommit: api('getCommit') },
|
||||
repos: { listPullRequestsAssociatedWithCommit: api('listPullRequestsAssociatedWithCommit') },
|
||||
},
|
||||
paginate: (method, args) => method(args),
|
||||
}
|
||||
state.core = {
|
||||
setOutput: (key, value) => {
|
||||
state.outputs[key] = value
|
||||
},
|
||||
notice: () => {},
|
||||
info: () => {},
|
||||
warning: message => state.warnings.push(message),
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
test('相同合并代码且完整成功时复用,即使 fork run 没有 pull_requests 字段', async () => {
|
||||
const state = fixture()
|
||||
await reuse(state)
|
||||
assert.equal(state.outputs.reuse, 'true')
|
||||
const query = state.calls.find(call => call.name === 'listWorkflowRuns').args
|
||||
assert.equal(query.workflow_id, 100)
|
||||
assert.equal(query.event, 'pull_request')
|
||||
assert.equal(query.status, undefined)
|
||||
assert.equal(state.calls.find(call => call.name === 'listJobsForWorkflowRun').args.filter, 'latest')
|
||||
})
|
||||
|
||||
const cases = {
|
||||
'PR 必须完整执行': state => {
|
||||
state.context.eventName = 'pull_request'
|
||||
},
|
||||
'手工触发必须完整执行': state => {
|
||||
state.context.eventName = 'workflow_dispatch'
|
||||
},
|
||||
'force push 必须完整执行': state => {
|
||||
state.context.payload.forced = true
|
||||
},
|
||||
'新建分支必须完整执行': state => {
|
||||
state.context.payload.before = '0'.repeat(40)
|
||||
},
|
||||
'直接 push 没有对应合并': state => {
|
||||
state.pull.merge_commit_sha = 'other'
|
||||
},
|
||||
'未合并 PR 不可复用': state => {
|
||||
state.pull.merged_at = null
|
||||
},
|
||||
'目标分支不同': state => {
|
||||
state.pull.base.ref = 'other'
|
||||
},
|
||||
'目标仓库不同': state => {
|
||||
state.pull.base.repo.full_name = 'other/repo'
|
||||
},
|
||||
'PR head 已改变': state => {
|
||||
state.pull.head.sha = 'e'.repeat(40)
|
||||
},
|
||||
'PR 来源仓库不同': state => {
|
||||
state.pull.head.repo.id = 9
|
||||
},
|
||||
'旧工作流没有 proof': state => {
|
||||
state.jobs = []
|
||||
},
|
||||
'proof 被跳过': state => {
|
||||
state.jobs[0].conclusion = 'skipped'
|
||||
},
|
||||
'proof 尚未完成': state => {
|
||||
state.jobs[0].status = 'in_progress'
|
||||
},
|
||||
'proof 重复': state => {
|
||||
state.jobs.push({ ...state.jobs[0] })
|
||||
},
|
||||
'工作流有失败': state => {
|
||||
state.runs[0].conclusion = 'failure'
|
||||
},
|
||||
'工作流尚未完成': state => {
|
||||
state.runs[0].status = 'in_progress'
|
||||
},
|
||||
'最新执行失败不可退回旧成功': state => {
|
||||
state.runs.push({ ...state.runs[0], id: 11, conclusion: 'failure' })
|
||||
},
|
||||
'查询期间开始重跑': state => {
|
||||
state.refreshed = { status: 'in_progress', conclusion: null }
|
||||
},
|
||||
'查询期间运行轮次变化': state => {
|
||||
state.refreshed = { run_attempt: 2 }
|
||||
},
|
||||
'目标分支已经前进': state => {
|
||||
state.tested.parents[0].sha = 'e'.repeat(40)
|
||||
},
|
||||
'证明不是 PR 模拟合并': state => {
|
||||
state.tested.parents.pop()
|
||||
},
|
||||
'模拟合并来自其他 PR head': state => {
|
||||
state.tested.parents[1].sha = 'e'.repeat(40)
|
||||
},
|
||||
'合入代码树有变化': state => {
|
||||
state.tested.tree.sha = 'different'
|
||||
},
|
||||
'API 异常回退全量': state => {
|
||||
state.error = true
|
||||
},
|
||||
}
|
||||
for (const [name, mutate] of Object.entries(cases)) {
|
||||
test(name, async () => {
|
||||
const state = fixture()
|
||||
mutate(state)
|
||||
await reuse(state)
|
||||
assert.equal(state.outputs.reuse, 'false')
|
||||
})
|
||||
}
|
||||
35
.github/workflows/pylint.yml
vendored
35
.github/workflows/pylint.yml
vendored
@@ -17,7 +17,32 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reuse:
|
||||
name: Check reusable PR verification
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
reused: ${{ steps.check.outputs.reuse }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Test reuse decision
|
||||
run: node --test .github/scripts/reuse.test.mjs
|
||||
- name: Check successful PR merge tree
|
||||
id: check
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { reuse } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/reuse.mjs`);
|
||||
await reuse({ github, context, core });
|
||||
|
||||
pylint:
|
||||
needs: reuse
|
||||
if: needs.reuse.outputs.reused != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Pylint Code Quality Check
|
||||
|
||||
@@ -105,3 +130,13 @@ jobs:
|
||||
echo "🎉 Pylint 检查完成!"
|
||||
echo "✅ 改动 Python 文件没有新增语法错误或严重问题"
|
||||
echo "📊 全仓建议性报告已保存为构建工件"
|
||||
|
||||
proof:
|
||||
name: CI proof (${{ github.sha }})
|
||||
needs: [pylint]
|
||||
if: github.event_name == 'pull_request' && needs.pylint.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
- name: Record fully verified merge commit
|
||||
run: echo 'All required checks passed for this PR merge commit.'
|
||||
|
||||
74
.github/workflows/test.yml
vendored
74
.github/workflows/test.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
# 指向 v3 的 PR 与推送都跑全量单测,作为合并门禁
|
||||
# PR 完整验证;合并推送仅在代码树和成功证明匹配时复用
|
||||
pull_request:
|
||||
branches:
|
||||
- v3
|
||||
@@ -19,7 +19,32 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reuse:
|
||||
name: Check reusable PR verification
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
reused: ${{ steps.check.outputs.reuse }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Test reuse decision
|
||||
run: node --test .github/scripts/reuse.test.mjs
|
||||
- name: Check successful PR merge tree
|
||||
id: check
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { reuse } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/reuse.mjs`);
|
||||
await reuse({ github, context, core });
|
||||
|
||||
architecture:
|
||||
needs: reuse
|
||||
if: needs.reuse.outputs.reused != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Architecture Contract Gate
|
||||
timeout-minutes: 10
|
||||
@@ -90,42 +115,11 @@ jobs:
|
||||
uv run --locked --no-sync python
|
||||
scripts/startup/performance.py --check --repeat 3
|
||||
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit Tests (${{ matrix.shard }})
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/4'
|
||||
- shard: '2/4'
|
||||
- shard: '3/4'
|
||||
- shard: '4/4'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v10.0.1
|
||||
with:
|
||||
python-version: '3.14'
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Run tests
|
||||
timeout-minutes: 10
|
||||
run: uv run --locked --no-sync python tests/run.py --shard "${{ matrix.shard }}"
|
||||
|
||||
coverage-shard:
|
||||
needs: reuse
|
||||
if: needs.reuse.outputs.reused != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Coverage Shard (${{ matrix.shard }})
|
||||
name: Unit Tests with Coverage (${{ matrix.shard }})
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -222,3 +216,13 @@ jobs:
|
||||
coverage.xml
|
||||
coverage.json
|
||||
retention-days: 7
|
||||
|
||||
proof:
|
||||
name: CI proof (${{ github.sha }})
|
||||
needs: [architecture, coverage-shard, coverage-report]
|
||||
if: github.event_name == 'pull_request' && needs.architecture.result == 'success' && needs.coverage-shard.result == 'success' && needs.coverage-report.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
- name: Record fully verified merge commit
|
||||
run: echo 'All required checks passed for this PR merge commit.'
|
||||
|
||||
Reference in New Issue
Block a user