Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 81 additions & 5 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ name: Python Tests

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled, closed]
# Trusted base-branch code only; never checks out or executes PR content.
pull_request_target:
types: [closed]
# Inert until the main ruleset enables a merge queue; queue runs are full.
merge_group:
workflow_dispatch:
Expand Down Expand Up @@ -32,14 +35,82 @@ permissions:
contents: read

concurrency:
group: python-tests-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
# A merged PR's closed event uses main as github.ref; keep its original PR lane.
group: python-tests-${{ github.event_name }}-${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('pr-{0}', github.event.number) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'push' }}

env:
LOOPX_USAGE_PING: "0"

jobs:
cancel-closed-pr:
if: github.event_name == 'pull_request_target' && github.event.action == 'closed'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
contents: read
pull-requests: read
steps:
- uses: actions/github-script@v8
with:
script: |
const repo = context.repo;
const pull_number = context.payload.number;
const readPR = async () => (await github.rest.pulls.get({...repo, pull_number})).data;
const pr = await readPR();
if (pr.state !== 'closed') return core.info('PR is open; retain its qualification.');
const cutoff = Date.parse(pr.closed_at);
if (!pr.head.repo?.id || !pr.head.ref || !Number.isFinite(cutoff)) {
throw new Error('Closed PR has no verifiable source repository, branch or close time.');
}
// Another open PR may use the same source branch against a different base.
const open = await github.paginate(github.rest.pulls.list,
{...repo, state: 'open', head: pr.head.label, per_page: 100});
if (open.length) return core.info('Source branch has an open PR; retain its qualification.');
const runs = await github.paginate(github.rest.actions.listWorkflowRuns,
{...repo, workflow_id: 'python-tests.yml', event: 'pull_request',
branch: pr.head.ref, per_page: 100});
for (const run of runs) {
const created = Date.parse(run.created_at);
if (run.status === 'completed' || run.head_repository?.id !== pr.head.repo.id ||
run.head_branch !== pr.head.ref || !Number.isFinite(created) || created > cutoff) continue;
// GitHub can omit PR associations after merge. Only the exact closed head
// is safe in that case; superseded heads remain in their concurrency lane.
if (run.pull_requests?.length
? !run.pull_requests.some(p => p.number === pull_number)
: run.head_sha !== pr.head.sha) continue;
const current = await readPR();
if (current.state !== 'closed' || current.head.sha !== pr.head.sha ||
current.closed_at !== pr.closed_at) return core.info('PR changed; stop cleanup.');
const params = {...repo, run_id: run.id};
try {
await github.rest.actions.cancelWorkflowRun(params);
await new Promise(resolve => setTimeout(resolve, 1500));
const {data: remaining} = await github.rest.actions.getWorkflowRun(params);
if (remaining.status !== 'completed') {
await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel', params);
let terminal = false;
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000));
if ((await github.rest.actions.getWorkflowRun(params)).data.status === 'completed') {
terminal = true;
break;
}
}
if (!terminal) throw new Error(`Cancellation ${run.id} has not completed; inspect its run.`);
}
core.info(`Retired obsolete Python qualification ${run.id}.`);
} catch (error) {
if (error.status !== 409 ||
(await github.rest.actions.getWorkflowRun(params)).data.status !== 'completed') throw error;
core.info(`Qualification ${run.id} completed before cancellation.`);
}
}

changes:
# Closed events cancel the old lane without starting another qualification.
if: github.event_name != 'pull_request_target' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
Expand All @@ -56,6 +127,11 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Install workflow contract parser
run: python -m pip install --disable-pip-version-check 'pyyaml>=6,<7'
- name: Validate merge gate semantics
run: python -m unittest discover -s scripts/ci -p 'test_*.py'
- name: Classify the exact pull-request change
Expand Down Expand Up @@ -779,8 +855,8 @@ jobs:
run: python scripts/chat_bundle.py verify --source

merge-gate:
# Always publish one stable outcome, including documentation-only PRs.
if: always()
# Open PRs always receive an outcome; closed PRs only cancel their old lane.
if: always() && github.event_name != 'pull_request_target' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [changes, checks, pytest, node-minimum-compatibility, stage2c-correctness-e2e, windows-powershell, presentation]
runs-on: ubuntu-latest
timeout-minutes: 3
Expand Down
27 changes: 25 additions & 2 deletions docs/development/testing-and-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ shell;不能因为过滤了 `CI` 就恢复采集。不能按系统、安装渠

### Required Merge Check / 必需合并检查

`python-tests.yml` publishes `merge-gate` for every pull request. Code,
`python-tests.yml` publishes `merge-gate` for every open pull request. Code,
workflow, policy and unknown paths require the existing `pytest` aggregate
(including TypeScript checks and all four Python shards), Stage 2C correctness
aggregate, and Windows tests to succeed. Failed, cancelled, missing or
Expand Down Expand Up @@ -149,7 +149,7 @@ merge method `merge`. The trigger is inert until the live ruleset enables a
merge queue. Enabling the queue, and then relaxing the up-to-date-branch
requirement, is a ruleset decision for the lead maintainer.

每个 PR 都会收到 `merge-gate` 结果。代码、工作流、治理规则和未知路径必须通过
每个开放 PR 都会收到 `merge-gate` 结果。代码、工作流、治理规则和未知路径必须通过
原有核心测试;失败、取消、缺失或意外跳过均不能通过。仅白名单根目录 Markdown
或 `docs/**/*.md` 的修改可显式跳过昂贵测试;运行时 prompt、可执行文档、代码删除
及代码移入文档均不享受豁免。实际启用状态以在线规则为准,使用 owner bypass
Expand All @@ -170,6 +170,29 @@ Changes to the classifier or workflow need both code-path and documentation-only
qualification. Keep required check names stable and never require a
workflow-level path-filtered check that cannot report on every PR.

Closing or merging a PR retires its outstanding Python qualification. A PR
number keeps the same concurrency lane when the merged close event changes
`github.ref` to the base branch. Closed events skip classification and the final
gate; open PRs retain the same required jobs and cancellation failures.

A trusted `pull_request_target: closed` job additionally cancels remaining runs
of this workflow, using job-local `actions: write` without checking out PR code.
It verifies the current closed PR, source repository/branch and close time;
it preserves reopened PRs, another open PR on that branch, newer runs and
foreign repositories. A missing run-to-PR association requires the exact closed
head. After ordinary cancellation, it uses GitHub's force-cancel endpoint for
nonterminal `always()` aggregates. The cleanup needs one short runner job;
it releases obsolete capacity rather than reserving release capacity or
providing a GitHub shared-pool priority guarantee.

PR 关闭或合并后,以 PR 编号维持原并发 lane,关闭事件跳过分类与最终门;开放
PR 的必需检查及取消失败规则不变。来自目标分支的可信 `pull_request_target:
closed` 清理任务仅自身持有 `actions: write`,不检出 PR 代码。它核验当前关闭
状态、源仓库/分支与关闭时间,保留重开的 PR、同源分支上的其他开放 PR、更新
运行及其他仓库;缺少 PR 关联时只处理确切关闭 head。普通取消后仍未结束的
`always()` 汇总使用强制取消 API。清理需要一个短 runner 任务;这会释放过期
容量,不等于为发布预留容量或设置 GitHub 共享池优先级。

The [job exemption policy](ci-impact-selection.md) additionally permits pure
Dashboard-client changes to skip backend Python/Windows and Stage2c, while
requiring frontend type-check/build, scoped coverage and real packaged Dashboard
Expand Down
116 changes: 116 additions & 0 deletions scripts/ci/test_review_gate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""An intentionally exempt job must be skipped; all required jobs must pass."""
from __future__ import annotations
import copy
import json
from pathlib import Path
import re
import subprocess
from types import SimpleNamespace
import unittest
import yaml
from review_gate import CHECK_OUTPUT, JOB_OUTPUT, requires_core_tests, verify, verify_checks

# Independent acceptance: client-only work retains compiled frontend/browser
Expand Down Expand Up @@ -29,6 +35,116 @@ def needs(kind="full", *, presentation=False, jobs=JOB_OUTPUT):


class GateTests(unittest.TestCase):
def test_closed_pr_cancels_same_lane_and_skips_qualification(self):
workflow = yaml.load(
(Path(__file__).resolve().parents[2] / ".github/workflows/python-tests.yml").read_text(),
Loader=yaml.BaseLoader,
)
self.assertIn("closed", workflow["on"]["pull_request"]["types"])
concurrency = workflow["concurrency"]
jobs = workflow["jobs"]

# These workflow clauses use only string comparisons and Boolean logic.
# Exercise the configured clauses against GitHub's documented ref change.
def expression(value, action, *, number=17, event="pull_request"):
ref = "refs/heads/main" if action == "closed" or event != "pull_request" else f"refs/pull/{number}/merge"
github = SimpleNamespace(event_name=event, ref=ref, event=SimpleNamespace(
action=action, number=number, pull_request=None))
return eval(value.strip().removeprefix("${{").removesuffix("}}").strip()
.replace("&&", "and").replace("||", "or"),
{"__builtins__": {}}, {"github": github, "always": lambda: True,
"format": lambda template, number: template.format(number)})

def lane(action, **kwargs):
return re.sub(r"\$\{\{(.*?)\}\}", lambda match: str(expression(match[0], action, **kwargs)),
concurrency["group"])

self.assertEqual(lane("synchronize"), lane("closed"))
self.assertNotEqual(lane("closed"), lane("closed", number=18))
self.assertNotEqual(lane("closed"), lane("closed", event="push"))
for action in ("opened", "synchronize", "reopened", "closed"):
self.assertTrue(expression(concurrency["cancel-in-progress"], action))
for name in ("changes", "merge-gate"):
self.assertEqual(bool(expression(jobs[name]["if"], action)), action != "closed")
for event in ("push", "schedule", "workflow_dispatch", "merge_group"):
for name in ("changes", "merge-gate"):
self.assertTrue(expression(jobs[name]["if"], "", event=event))
self.assertEqual([name for name, job in jobs.items() if not job.get("needs")],
["cancel-closed-pr", "changes"])
for name in ("changes", "merge-gate"):
self.assertFalse(expression(jobs[name]["if"], "closed", event="pull_request_target"))
cleanup = jobs["cancel-closed-pr"]
self.assertTrue(expression(cleanup["if"], "closed", event="pull_request_target"))
self.assertFalse(expression(cleanup["if"], "closed"))
self.assertFalse(expression(cleanup["if"], "reopened", event="pull_request_target"))
self.assertNotEqual(lane("closed", event="pull_request_target"),
lane("closed", number=18, event="pull_request_target"))
self.assertEqual(workflow["permissions"], {"contents": "read"})
self.assertEqual(cleanup["permissions"],
{"actions": "write", "contents": "read", "pull-requests": "read"})
self.assertEqual(len(cleanup["steps"]), 1)
self.assertEqual(cleanup["steps"][0]["uses"], "actions/github-script@v8")
for name, job in jobs.items():
if "always()" in job.get("if", "") and name != "merge-gate":
self.assertIn("needs.changes.outputs.", job["if"])
self.assertIn("== 'true'", job["if"])

def test_trusted_cleanup_executes_only_obsolete_closed_pr_runs(self):
workflow = yaml.load(
(Path(__file__).resolve().parents[2] / ".github/workflows/python-tests.yml").read_text(),
Loader=yaml.BaseLoader,
)
script = workflow["jobs"]["cancel-closed-pr"]["steps"][0]["with"]["script"]
# Execute the shipped transport script, independently supplying legal and
# illegal lifecycle fixtures. No token or untrusted PR code is involved.
harness = r"""
import assert from 'node:assert/strict';
const script = JSON.parse(process.argv[1]);
const execute = new (Object.getPrototypeOf(async function(){}).constructor)(
'github', 'context', 'core', 'setTimeout', script);
const pr = {state:'closed', closed_at:'2026-01-02T00:00:00Z',
head:{repo:{id:7}, ref:'fix', label:'contributor:fix', sha:'head'}};
const old = {id:1, status:'queued', head_repository:{id:7}, head_branch:'fix',
head_sha:'head', created_at:'2026-01-01T00:00:00Z', pull_requests:[]};
async function run({first=pr, current=first, open=[], runs=[old], ordinaryDone=false,
conflict=false, stuck=false, failure=false}={}) {
const cancelled=[], forced=[], queries=[]; let reads=0;
const github = {rest:{pulls:{get:async()=>({data:reads++ ? current : first}), list:'pulls'},
actions:{listWorkflowRuns:'runs',
cancelWorkflowRun:async p=>{cancelled.push(p.run_id); if(conflict || failure)
throw Object.assign(new Error('provider failure'), {status:conflict ? 409 : 403});},
getWorkflowRun:async p=>({data:{status:(conflict || ordinaryDone ||
(forced.includes(p.run_id) && !stuck)) ? 'completed' : 'queued'}})}},
paginate:async (method,params)=>{queries.push([method,params]); return method==='pulls' ? open : runs;},
request:async (route,p)=>{assert.equal(route,
'POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel'); forced.push(p.run_id);}};
await execute(github, {repo:{owner:'owner',repo:'repo'},payload:{number:17}},
{info:()=>{}}, fn=>fn());
return {cancelled,forced,queries};
}
const selected = await run({runs:[old,
{...old,id:2,pull_requests:[{number:17}],head_sha:'previous'},
{...old,id:3,status:'completed'}, {...old,id:4,head_repository:{id:8}},
{...old,id:5,created_at:'2026-01-03T00:00:00Z'},
{...old,id:6,pull_requests:[{number:18}]}, {...old,id:7,head_sha:'unknown'},
{...old,id:8,head_branch:'another'}, {...old,id:9,created_at:'invalid'}]});
assert.deepEqual(selected.cancelled,[1,2]); assert.deepEqual(selected.forced,[1,2]);
assert.equal(selected.queries[1][1].workflow_id,'python-tests.yml');
assert.equal(selected.queries[1][1].event,'pull_request');
assert.equal(selected.queries[1][1].branch,'fix');
for (const fixture of [{first:{...pr,state:'open'}}, {current:{...pr,state:'open'}},
{current:{...pr,closed_at:'2026-01-04T00:00:00Z'}},
{current:{...pr,head:{...pr.head,sha:'new'}}}, {open:[{number:18}]}]) {
assert.deepEqual((await run(fixture)).cancelled,[]);
}
assert.deepEqual((await run({ordinaryDone:true})).forced,[]);
assert.deepEqual((await run({conflict:true})).forced,[]);
await assert.rejects(run({stuck:true}),/has not completed/);
await assert.rejects(run({failure:true}),/provider failure/);
await assert.rejects(run({first:{...pr,head:{...pr.head,repo:null}}}),/no verifiable source/);
"""
subprocess.run(["node", "--input-type=module", "-e", harness, json.dumps(script)], check=True)

def test_legal_profiles_and_policy_rehearsal(self):
for kind in ("docs", "presentation", "full"):
verify(needs(kind))
Expand Down
Loading