Skip to content
Open
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
41 changes: 29 additions & 12 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
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]
types: [closed, synchronize]
# Inert until the main ruleset enables a merge queue; queue runs are full.
merge_group:
workflow_dispatch:
Expand Down Expand Up @@ -43,8 +43,8 @@ env:
LOOPX_USAGE_PING: "0"

jobs:
cancel-closed-pr:
if: github.event_name == 'pull_request_target' && github.event.action == 'closed'
cancel-obsolete-pr:
if: github.event_name == 'pull_request_target' && (github.event.action == 'closed' || github.event.action == 'synchronize')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
Expand All @@ -59,36 +59,46 @@ jobs:
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 closed = context.payload.action === 'closed';
if (!closed && context.payload.action !== 'synchronize') return core.info('No obsolete-run cleanup event.');
if (pr.state !== (closed ? 'closed' : 'open')) return core.info('PR state changed; retain qualification.');
if (!closed && context.payload.pull_request?.head.sha !== pr.head.sha) {
return core.info('Synchronize head changed; stop cleanup.');
}
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.');
if (!pr.head.repo?.id || !pr.head.ref || !pr.head.sha || (closed && !Number.isFinite(cutoff))) {
throw new Error('PR has no verifiable source repository, branch, head or close time.');
}
const unchanged = current => current.state === pr.state && current.head.sha === pr.head.sha &&
current.head.ref === pr.head.ref && current.head.repo?.id === pr.head.repo.id &&
(!closed || current.closed_at === pr.closed_at);
// 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.');
if (open.some(p => closed || p.number !== pull_number)) {
return core.info('Source branch has another 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;
run.head_branch !== pr.head.ref || !run.head_sha || !Number.isFinite(created) ||
(closed ? created > cutoff : run.head_sha === pr.head.sha)) 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.');
: !closed || run.head_sha !== pr.head.sha) continue;
if (!unchanged(await readPR())) 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') {
if (!unchanged(await readPR())) return core.info('PR changed; stop force cancellation.');
await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/force-cancel', params);
let terminal = false;
for (let attempt = 0; attempt < 10; attempt++) {
Expand Down Expand Up @@ -499,6 +509,13 @@ jobs:
needs: [changes, chat-bundle]
if: needs.changes.outputs.python_tests == 'true'
runs-on: ubuntu-latest
# Share four long-test lanes across current PRs and other workflow runs.
# Do not replace another candidate's pending shard or cancel its active head.
# Workflow-level concurrency still retires obsolete heads of the same PR.
concurrency:
group: loopx-python-test-shard-${{ matrix.shard }}
queue: max
cancel-in-progress: false
# All four shards were still progressing at 83-94% when the 45m job
# deadline cancelled run 37916979857. Reserve setup/reporting headroom
# without changing test-level deadlines, collection or worker count.
Expand Down
47 changes: 44 additions & 3 deletions docs/development/testing-and-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ 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
A trusted `pull_request_target` cleanup handles closed and synchronize events.
Closed events cancel 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
Expand All @@ -186,8 +187,8 @@ 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 的必需检查及取消失败规则不变。来自目标分支的可信 `pull_request_target`
清理任务处理 closed 与 synchronize 事件(更新规则见下方共享分片说明),仅自身持有 `actions: write`,不检出 PR 代码。它核验当前关闭
状态、源仓库/分支与关闭时间,保留重开的 PR、同源分支上的其他开放 PR、更新
运行及其他仓库;缺少 PR 关联时只处理确切关闭 head。普通取消后仍未结束的
`always()` 汇总使用强制取消 API。清理需要一个短 runner 任务;这会释放过期
Expand Down Expand Up @@ -619,6 +620,46 @@ Sonar 只复用同一次 run 的 XML,不重复测试、不跨 run 取产物。
Sonar,测试 job 不接收 Sonar secret。触发范围取原有两套 workflow 的并集;纯前端
PR 使用前述豁免,Sonar 配置变更仍全量运行,包括没有 token 的 fork。

Full Python shards share four repository-wide job concurrency groups, one per
`matrix.shard`, across PR, main, merge-queue and manual runs carrying this
workflow version. Each group admits one running job and uses `queue: max`
to retain up to 100 pending jobs instead of replacing another current
candidate. Workflow-level cancellation requests retirement of the same PR's
obsolete head. Trusted base-branch cleanup on `synchronize` also retires its
remaining obsolete runs, including `always()` aggregates that survive ordinary
cancellation. It requires the event's current head, exact PR association, source
repository and branch, and rechecks the current head before ordinary and force
cancellation. It preserves another open PR sharing that branch. Current-head
runs, unassociated open-PR runs and ordinary required gate predicates remain
unchanged: cancelled, skipped or incomplete dependencies cannot become a green
aggregate. This adds one short trusted cleanup job per update without checkout,
PR-code execution or new permissions on qualification jobs. Older workflow
versions on the base branch retain only closed-event cleanup. Queue overflow, cancellation and missing coverage remain failures of
the required gate; collection, four partitions, two workers, reports and the
coverage floor stay unchanged. Contributors can wait longer for a shard during
overlapping full runs. This limits long-shard occupancy to four runners; it
does not reserve release capacity or prioritize releases over other jobs or
repositories in the organization's shared hosted pool. Older workflow versions
do not participate. Roll back by removing this job-level concurrency block,
retaining workflow-level cancellation and the required gate. See GitHub's
[concurrency queue contract](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency).

全量 Python 分片按 `matrix.shard` 共用四个仓库级 job concurrency 组,适用于
携带本版 workflow 的 PR、main、merge queue 和手动运行。每组允许一个运行中 job,
用 `queue: max` 保留至多 100 个等待 job,不替换其他有效候选。workflow 级取消
请求清退同 PR 的旧 head;可信 base 分支的 `synchronize` 清理还会取消遗留
旧 run,包括普通取消后仍继续的 `always()` 汇总。清理要求事件中的当前 head、
精确 PR 关联、源仓库和分支,在普通及强制取消前重新读取 head;同分支另一 open
PR、当前 head 和无关联 run 均被保护。必过汇总条件不变,取消、跳过或缺失依赖
不能变绿。每次更新增加一个短清理 job,不 checkout 或执行 PR 代码,不增加普通
验证 job 的权限;base 分支旧版 workflow 仍只清理 closed 事件。队列溢出、取消
和缺失 coverage 仍不能通过必需检查。
完整 collection、四分片、双 worker、报告和覆盖率门槛保持不变;重叠的全量运行
会增加贡献者等待分片的时间。此限制只把长分片占用约束到四台 runner,不预留发布
容量,也不保证发布优先于组织共享 hosted 池中的其他 job 或仓库。旧版 workflow
不参与该限制。回退时移除此 job 的 concurrency 块,保留 workflow 级取消和必需
检查;队列行为遵循上述 GitHub 官方契约。

Reproduce one shard locally with `uv run --extra test python -m pytest -v -n 2 --splits 4 --group 1
--splitting-algorithm least_duration --cov=loopx`. Omit the split arguments to
run the complete suite locally. 全量本地测试仍省略分片参数即可。
Expand Down
Loading
Loading