diff --git a/.github/workflows/ci-maintenance.yml b/.github/workflows/ci-maintenance.yml new file mode 100644 index 0000000000..8d12d13a01 --- /dev/null +++ b/.github/workflows/ci-maintenance.yml @@ -0,0 +1,103 @@ +name: CI Maintenance + +# Trusted base-branch code only; never checks out or executes PR content. +on: + pull_request_target: + types: [closed, synchronize] + +permissions: + contents: read + +concurrency: + group: ci-maintenance-pr-${{ github.event.number }} + cancel-in-progress: true + +jobs: + 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: + 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(); + 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 || !pr.head.sha || (closed && !Number.isFinite(cutoff))) { + throw new Error('PR has no verifiable source repository, branch, head or close time.'); + } + // Synchronize supplies the exact replaced head, including for fork + // runs whose Actions response omits pull_requests. Bind both ends + // and the source identity before using this fallback. + const previousHead = !closed && context.payload.after === pr.head.sha && + context.payload.pull_request?.head.ref === pr.head.ref && + context.payload.pull_request?.head.repo?.id === pr.head.repo.id + ? context.payload.before : null; + 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 hasOtherOpenPR = async (head = pr.head.label) => (await github.paginate(github.rest.pulls.list, + {...repo, state: 'open', head, per_page: 100})) + .some(p => closed || p.number !== pull_number); + const cancellationScopeUnchanged = async () => { + const current = await readPR(); + return unchanged(current) && !(await hasOtherOpenPR(current.head.label)); + }; + if (await hasOtherOpenPR()) { + 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); + // A queued PR event may materialize after close. Only the exact + // closed head can cross that cutoff; recheck its lifecycle below. + if (run.status === 'completed' || run.head_repository?.id !== pr.head.repo.id || + run.head_branch !== pr.head.ref || !run.head_sha || !Number.isFinite(created) || + (closed ? created > cutoff && run.head_sha !== pr.head.sha : run.head_sha === pr.head.sha)) continue; + // Missing associations require either the exact closed head or + // the replaced head bound to this verified synchronize event. + if (run.pull_requests?.length + ? !run.pull_requests.some(p => p.number === pull_number) + : run.head_sha !== (closed ? pr.head.sha : previousHead)) continue; + if (!(await cancellationScopeUnchanged())) return core.info('PR or source branch 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 (!(await cancellationScopeUnchanged())) return core.info('PR or source branch 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++) { + 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.`); + } + } diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 46dff876fb..d02aba8da8 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -3,9 +3,6 @@ name: Python Tests on: pull_request: 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, synchronize] # Inert until the main ruleset enables a merge queue; queue runs are full. merge_group: workflow_dispatch: @@ -43,86 +40,6 @@ env: LOOPX_USAGE_PING: "0" jobs: - 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: - 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(); - 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 || !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 hasOtherOpenPR = async (head = pr.head.label) => (await github.paginate(github.rest.pulls.list, - {...repo, state: 'open', head, per_page: 100})) - .some(p => closed || p.number !== pull_number); - const cancellationScopeUnchanged = async () => { - const current = await readPR(); - return unchanged(current) && !(await hasOtherOpenPR(current.head.label)); - }; - if (await hasOtherOpenPR()) { - 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 || !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) - : !closed || run.head_sha !== pr.head.sha) continue; - if (!(await cancellationScopeUnchanged())) return core.info('PR or source branch 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 (!(await cancellationScopeUnchanged())) return core.info('PR or source branch 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++) { - 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') @@ -148,7 +65,9 @@ jobs: - 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' + run: | + python -m unittest discover -s scripts/ci -p 'test_*.py' + node --test scripts/ci/qualification_summary.test.ts - name: Classify the exact pull-request change id: classify env: @@ -453,14 +372,18 @@ jobs: - name: Reject undefined CSS custom properties run: node scripts/check-css-custom-properties.mjs dashboard - - name: Measure Dashboard unit and browser interactions + # The coverage merger consumes the unit report from shard 1 only. + - name: Measure Dashboard unit coverage once + if: matrix.shard == 1 + run: npm run test:dashboard:coverage + + - name: Measure Dashboard browser interactions env: LOOPX_PERSONAL_WORKSPACE_SHARD: "${{ matrix.shard }}/3" LOOPX_DASHBOARD_COVERAGE: "1" LOOPX_PLAYWRIGHT_PACKAGE: ${{ github.workspace }}/apps/presentation/dashboard/node_modules/playwright run: | node --test examples/personal-workspace-browser/shard.test.mjs - npm run test:dashboard:coverage cd apps/presentation/dashboard npx playwright install --with-deps chromium --only-shell node ../../../examples/personal-workspace-browser-smoke.mjs @@ -568,8 +491,14 @@ jobs: npm run test:control-plane test-shard: - needs: [changes, chat-bundle] - if: needs.changes.outputs.python_tests == 'true' + # Keep known-red PRs out of the long-test queue. Non-PR runs still collect + # complete Python diagnostics after checks finish, even when checks fail. + needs: [changes, chat-bundle, checks] + if: >- + ${{ !cancelled() && needs.changes.result == 'success' && + needs.changes.outputs.python_tests == 'true' && + needs.chat-bundle.result == 'success' && + (github.event_name != 'pull_request' || needs.checks.result == 'success') }} runs-on: ubuntu-latest # Share six long-test lanes across current PRs and other workflow runs. # Do not replace another candidate's pending shard or cancel its active head. @@ -943,6 +872,9 @@ jobs: run: python scripts/chat_bundle.py verify --source merge-gate: + permissions: + contents: read + actions: read # 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') && (github.event_name != 'push' || !cancelled()) needs: [changes, checks, pytest, node-minimum-compatibility, stage2c-correctness-e2e, windows-powershell, presentation] @@ -954,6 +886,41 @@ jobs: with: python-version: "3.11" - name: Reject incomplete or unsuccessful qualification + id: verdict env: NEEDS_JSON: ${{ toJSON(needs) }} run: python scripts/ci/review_gate.py verify + - name: Explain qualification with current-attempt job evidence + if: always() + # Reporting failure cannot change the preceding gate's verdict. + continue-on-error: true + timeout-minutes: 1 + uses: actions/github-script@v8 + env: + NEEDS_JSON: ${{ toJSON(needs) }} + GATE_RESULT: ${{ steps.verdict.outcome }} + with: + script: | + const {pathToFileURL} = await import('node:url'); + const {renderQualificationSummary} = await import(pathToFileURL( + `${process.env.GITHUB_WORKSPACE}/scripts/ci/qualification_summary.ts`).href); + let jobs = null; + try { + jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, { + ...context.repo, run_id: context.runId, + attempt_number: Number(process.env.GITHUB_RUN_ATTEMPT), per_page: 100, + request: {signal: AbortSignal.timeout(10000)}, + }); + } catch { + core.warning('Job evidence unavailable; the qualification verdict is unchanged.'); + } + await core.summary.addRaw(renderQualificationSummary({ + repository: `${context.repo.owner}/${context.repo.repo}`, + serverUrl: process.env.GITHUB_SERVER_URL, + runId: context.runId, runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), + sourceSha: context.payload.pull_request?.head.sha || context.sha, + checkoutSha: context.sha, + baseSha: context.payload.pull_request?.base.sha || context.payload.merge_group?.base_sha || '', + gateResult: process.env.GATE_RESULT, + needs: JSON.parse(process.env.NEEDS_JSON), jobs, + })).write(); diff --git a/docs/development/ci-impact-selection.md b/docs/development/ci-impact-selection.md index eac77ede20..dfa915d82a 100644 --- a/docs/development/ci-impact-selection.md +++ b/docs/development/ci-impact-selection.md @@ -61,6 +61,28 @@ No tests are removed. Without timing history the splitter uses equal weights; six shards do not establish perfect duration balancing. Each numbered shard shares its queue across workflow runs without replacing another current head. +Long Python shards now wait for the existing `checks` aggregate. An ordinary +PR enters those queues only after core qualification succeeds, so a known-red +candidate does not occupy the long-test lanes. This changes scheduling, not +qualification: skipped shards after a failed prerequisite still fail `pytest` +and `merge-gate`; a passing full candidate still requires every shard and the +same coverage floor. Main, scheduled, manual and merge-queue runs retain full +Python diagnostics after `checks` finishes, even if it failed, provided the +classification and source bundle succeeded and the workflow was not cancelled. +The tradeoff is extra serial latency for a healthy run in an idle queue, in +exchange for less work behind known failures under contention. `ci:full` still +forces the full profile; use a manual run for complete diagnostics of a failing +PR revision. This does not cancel previously queued runs of older workflows. + +长 Python 分片现在先等待现有 `checks` 汇总;普通 PR 仅在核心资格检查成功后 +进入长队列,避免已知失败的候选继续占用分片。前置失败导致的跳过仍使 `pytest` +和 `merge-gate` 失败,完整候选仍须通过全部分片与原覆盖率门槛。主干、定时、 +手动和 merge queue 在汇总结束后仍执行完整 Python 诊断,即使该汇总失败; +分类和源码构建必须成功,取消的 workflow 不再启动分片。代价是空闲队列中健康 +候选的串行等待增加;收益是拥塞时不再给已知失败的 PR 排长测试。`ci:full` 仍强制 +完整 profile;需要失败候选的全部诊断时可手动运行其分支。旧 workflow 已排队的 +任务不会因此自动取消。 + The shard command explicitly loads `scripts.ci.pytest_timing`. Its controller streams pytest's setup/call/teardown durations to Actions logs as each report arrives, before final JUnit or duration summaries exist. To inspect a downloaded @@ -152,6 +174,20 @@ per-job execution flags, reason and coverage scope. The merge gate requires success for required jobs and an explicit skip for exempt ones; failure, cancellation, missing outputs, contradictory flags or unexpected skips fail. +The merge gate also writes a **Qualification evidence** summary: source head, +workflow checkout, event base, attempt, dependency results and links to failed +jobs and steps. It separates execution failures, aggregate failures and incomplete +jobs without attributing a cause from status alone. The read-only job lookup uses +the current run attempt; an unavailable API or missing evidence is reported as +such. Advisory checks can appear in the report, but only the existing gate policy +decides qualification. Summary errors cannot override that verdict. There is no +inherited-main failure waiver or automatic flaky-test exemption. + +merge-gate 的摘要记录源码 head、实际 checkout、事件 base 与本次 attempt, +分别列出失败作业、聚合失败和未完成作业,并链接到失败步骤所在 job。 +摘要不根据状态猜根因;API 不可用时明确标记证据缺口。它也会列出 advisory +检查,但是否通过仍由原门禁规则决定,不因摘要失败而放行,不自动豁免历史失败。 + Specialized workflows already use GitHub's PR path filters: release artifacts follow packaged source/build inputs, desktop artifacts follow native/Desktop and shared Dashboard inputs, optional Ark follows its adapter and shared turn @@ -200,6 +236,43 @@ DCO and optional Ark PR checks cancel only superseded validation for the same PR Signed-desktop PRs use separate groups from each other and from channel publication. Release publication remains serialized and is never cancelled by a PR update. +Trusted obsolete-PR cleanup runs in the separate **CI Maintenance** workflow on +`pull_request_target` close/synchronize events. It never checks out PR content +and retains the same PR/repository/head checks before cancellation. A green +maintenance run means cleanup completed, not that Python tests passed. Its +concurrency group is separate from current-head qualification; `Python Tests` +still receives PR close events to cancel the old qualification lane. +Delayed PR events can create runs after the close timestamp. Cleanup also retires +those runs when their source repository, branch and exact head match the still +closed PR and no open PR shares that branch. Later runs at a different head stay +untouched, even with an association to the closed PR; reopening or source changes +stop cancellation. This extends the earlier close-time cutoff for exact-head +residue only. Cleanup can still wait for a runner or miss a run created after its +snapshot; it is not a continuous admission service. +For synchronize events, an omitted run-to-PR association may use the webhook's +exact `before` head only when `after`, source repository and branch match the +current PR. Other unassociated old heads remain protected; the same lifecycle +and sibling-PR checks still run before both cancellation stages. + +Dashboard browser qualification retains all three shards. The unchanged unit +coverage command runs only in shard 1, whose `lcov.info` the existing merger +already consumes; browser coverage and acceptance receipts remain required from +all three shards. The merger rejects missing unit coverage or incomplete browser +acceptance. This removes two duplicate unit-coverage invocations per full or +presentation run; it does not establish a hosted wall-time improvement. + +旧 PR 清理独立为 CI Maintenance,保留原有来源与 head 校验,也不执行 PR 代码。 +清理成功不再显示为一次 Python Tests 成功。Dashboard 的单元覆盖率只在第 1 片 +执行一次,三个浏览器分片与验收凭证仍全部必需;缺少单元覆盖率仍拒绝聚合。 +这减少了两次重复命令,实际等待时间与端到端耗时仍需上线后测量。 +延迟处理的 PR 事件可能在关闭后才创建运行。清理现在也覆盖与仍关闭 PR 的仓库、 +分支和 head 完全一致的残留;其他 head 的晚到运行仍保留,重开或来源变化会中止 +取消,同分支其他开放 PR 的保护不变。清理本身仍可能等待 runner,也可能遗漏其 +快照之后才创建的运行,不能代替持续的准入控制。 +同步事件若缺少运行到 PR 的关联,仅在 webhook 的 `after`、源仓库和分支与当前 +PR 一致时,允许用其确切 `before` head 识别被替换运行;其他无关联旧 head 仍保留, +普通及强制取消前的状态和同分支 PR 检查不变。 + 全量 smoke 改为每天一次与指定 ref 的手动运行,不再随普通主干提交启动。五个完整 分片共享一次源码绑定的前端构建,同一时刻最多运行两个分片;全量用例仍保留, 代价是后台 sweep 可能更久。每日完整 Python 验证与 PR/主干提交使用不同并发组, @@ -219,6 +292,7 @@ only hosted before/after observations can establish latency or capacity gains. ```bash uv run --extra test python -m unittest discover -s scripts/ci -p 'test_*.py' +node --test scripts/ci/qualification_summary.test.ts scripts/ci/merge-dashboard-coverage.test.mjs uv run --extra test python -m pytest tests/test_python_ci_workflow.py tests/test_sonarcloud_workflow.py tests/test_ci_resource_policy.py -q uv run --extra test python examples/full-public-smokes-workflow-smoke.py uv run --extra test python scripts/ci/review_gate.py classify --base origin/main --head HEAD --plan impact-plan.json diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 6c12cb704a..279516e9e6 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -192,13 +192,14 @@ 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` cleanup handles closed and synchronize events. -Closed events cancel remaining runs -of this workflow, using job-local `actions: write` without checking out PR code. +A trusted `pull_request_target` cleanup in **CI Maintenance** handles closed and +synchronize events. Closed events cancel remaining runs of Python Tests, 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 +it preserves reopened PRs, another open PR on that branch, newer runs at a +different head and foreign repositories. Delayed runs at the exact closed head +can be retired even when created after close. 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. Before either cancellation stage it rereads both the target PR and open PRs sharing its source branch; a new sibling or unavailable lookup prevents the effect. A fresh retry can resume after the @@ -208,9 +209,11 @@ providing a GitHub shared-pool priority guarantee. PR 关闭或合并后,以 PR 编号维持原并发 lane,关闭事件跳过分类与最终门;开放 PR 的必需检查及取消失败规则不变。来自目标分支的可信 `pull_request_target` -清理任务处理 closed 与 synchronize 事件(更新规则见下方共享分片说明),仅自身持有 `actions: write`,不检出 PR 代码。它核验当前关闭 -状态、源仓库/分支与关闭时间,保留重开的 PR、同源分支上的其他开放 PR、更新 -运行及其他仓库;缺少 PR 关联时只处理确切关闭 head。普通取消后仍未结束的 +CI Maintenance 清理任务处理 closed 与 synchronize 事件(更新规则见下方共享分片说明), +仅自身持有 `actions: write`,不检出 PR 代码。它核验当前关闭 +状态、源仓库/分支与关闭时间,保留重开的 PR、同源分支上的其他开放 PR、不同 head 的更新 +运行及其他仓库;确切关闭 head 的延迟运行即使在关闭后创建也可清理,缺少 PR 关联时 +同样只处理确切关闭 head。普通取消后仍未结束的 `always()` 汇总使用强制取消 API。普通及强制取消前都重读目标 PR 和同源分支的 开放 PR;新出现的共享分支 PR 或读取失败会阻止副作用,分支不再共享后可重新 触发清理。清理需要一个短 runner 任务;这会释放过期 @@ -744,15 +747,22 @@ PR 使用前述豁免,Sonar 配置变更仍全量运行,包括没有 token Full Python shards share six 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` +workflow version. Shards wait for `checks`; ordinary PRs enter only after it +succeeds. Non-PR runs still collect Python diagnostics after failed checks when +classification and the source bundle succeeded and the workflow is not cancelled. +Skipped required shards remain a qualification failure. This avoids long-test +work for known-red PRs but adds serial latency when capacity is idle; see the +[admission tradeoff](ci-impact-selection.md#six-complete-python-shards). +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 requires the event's current head, source repository and branch, +plus either an exact PR association or the exact webhook `before` head with +`after` and event source identity matching the current PR. It 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 +runs, other 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 @@ -767,12 +777,16 @@ 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, +携带本版 workflow 的 PR、main、merge queue 和手动运行。分片先等待 `checks`, +普通 PR 仅在其成功后进入队列;非 PR 在分类、源码构建成功且未取消时仍保留失败后 +的完整 Python 诊断。必需分片跳过仍判失败。此规则减少已知失败 PR 的长测试占用, +但会增加空闲队列中的串行延迟。每组允许一个运行中 job, 用 `queue: max` 保留至多 100 个等待 job,不替换其他有效候选。workflow 级取消 请求清退同 PR 的旧 head;可信 base 分支的 `synchronize` 清理还会取消遗留 旧 run,包括普通取消后仍继续的 `always()` 汇总。清理要求事件中的当前 head、 -精确 PR 关联、源仓库和分支,在普通及强制取消前重新读取 head;同分支另一 open -PR、当前 head 和无关联 run 均被保护。必过汇总条件不变,取消、跳过或缺失依赖 +源仓库和分支,以及精确 PR 关联,或 `after` 和事件来源均匹配当前 PR 时的确切 +webhook `before` head;普通及强制取消前重新读取 head。同分支另一 open PR、 +当前 head 和其他无关联 run 均被保护。必过汇总条件不变,取消、跳过或缺失依赖 不能变绿。每次更新增加一个短清理 job,不 checkout 或执行 PR 代码,不增加普通 验证 job 的权限;base 分支旧版 workflow 仍只清理 closed 事件。队列溢出、取消 和缺失 coverage 仍不能通过必需检查。 diff --git a/scripts/ci/merge-dashboard-coverage.test.mjs b/scripts/ci/merge-dashboard-coverage.test.mjs index 54a4df63b5..be6e78702d 100644 --- a/scripts/ci/merge-dashboard-coverage.test.mjs +++ b/scripts/ci/merge-dashboard-coverage.test.mjs @@ -17,12 +17,15 @@ test("coverage merge preserves disjoint hits and rejects missing or duplicate ac await mkdir(shard); await writeReceipt(index,`scenario-${index}`); await writeFile(resolve(shard,"browser-coverage.json"),JSON.stringify({[source]:file(index===2?1:0)})); - await writeFile(resolve(shard,"lcov.info"),"unit-coverage-preserved"); + if (index === 1) await writeFile(resolve(shard,"lcov.info"),"unit-coverage-preserved"); } let result = run(); assert.equal(result.status,0,result.stderr); assert.match(await readFile(resolve(root,"merged/browser-lcov.info"),"utf8"),/DA:1,1/); assert.equal(await readFile(resolve(root,"merged/lcov.info"),"utf8"),"unit-coverage-preserved"); + await rm(resolve(root,"dashboard-coverage-1/lcov.info")); + assert.notEqual(run().status,0,"unit coverage is still required"); + await writeFile(resolve(root,"dashboard-coverage-1/lcov.info"),"unit-coverage-preserved"); await writeReceipt(3,"scenario-2"); result=run();assert.notEqual(result.status,0);assert.match(result.stderr,/overlapping/); await rm(resolve(root,"dashboard-coverage-3"),{recursive:true}); diff --git a/scripts/ci/qualification_summary.test.ts b/scripts/ci/qualification_summary.test.ts new file mode 100644 index 0000000000..79f415e7ba --- /dev/null +++ b/scripts/ci/qualification_summary.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {renderQualificationSummary, type JobEvidence, type QualificationSummary} from "./qualification_summary.ts"; + +const job = (name: string, conclusion: string | null = "failure", overrides: Partial = {}): JobEvidence => ({ + id: 10, run_id: 42, run_attempt: 2, name, status: "completed", conclusion, + steps: [{name: "Run tests", conclusion: "failure"}], ...overrides, +}); +const input = (overrides: Partial = {}): QualificationSummary => ({ + repository: "example/project", serverUrl: "https://github.com", runId: 42, runAttempt: 2, + sourceSha: "a".repeat(40), checkoutSha: "b".repeat(40), baseSha: "c".repeat(40), + gateResult: "failure", needs: {changes: {result: "success", outputs: {change_kind: "full"}}, + pytest: {result: "failure"}}, jobs: [], ...overrides, +}); + +test("shows failed steps ahead of downstream rollups without inventing a cause", () => { + const result = renderQualificationSummary(input({jobs: [ + job("pytest", "failure", {id: 11}), job("test-shard (2)"), + job("checks", "failure", {id: 12}), job("test-shard (3)", "cancelled", {id: 13}), + ]})); + assert.match(result, /Gate step: failure/); + assert.ok(result.indexOf("test-shard") < result.indexOf("Aggregate failures")); + assert.match(result, /Run tests/); + assert.match(result, /https:\/\/github.com\/example\/project\/actions\/runs\/42\/job\/10/); + assert.match(result, /Aggregate failures[\s\S]*pytest/); + assert.match(result, /cancelled/); + for (const sha of ["a", "b", "c"]) assert.ok(result.includes(sha.repeat(40))); + assert.doesNotMatch(result, /inherited|flaky|safe to merge/i); +}); + +test("coverage or aggregation failures remain visible even without a failing test job", () => { + const result = renderQualificationSummary(input({jobs: [job("pytest", "failure", { + steps: [{name: "Combine complete coverage", conclusion: "failure"}], + })]})); + assert.match(result, /Aggregate failures[\s\S]*Combine complete coverage/); + assert.match(result, /Gate step: failure/); +}); + +test("exempt checks and unavailable evidence cannot become passing test claims", () => { + const docs = renderQualificationSummary(input({gateResult: "success", needs: { + changes: {result: "success", outputs: {change_kind: "docs"}}, pytest: {result: "skipped"}, + }, jobs: [job("test-shard", "skipped")]})); + assert.match(docs, /Gate step: success/); + assert.match(docs, /pytest \| skipped/); + assert.match(docs, /Skipped jobs are not passing test evidence/); + const missing = renderQualificationSummary(input({jobs: null})); + assert.match(missing, /Job evidence unavailable/); + assert.match(missing, /Gate step: failure/); + assert.doesNotMatch(missing, /all.*pass/i); +}); + +test("does not mix earlier attempts or other runs into the current attempt", () => { + const result = renderQualificationSummary(input({jobs: [ + job("old green", "success", {run_attempt: 1}), + job("unrelated workflow", "success", {run_id: 41}), job("current failure"), + ]})); + assert.doesNotMatch(result, /old green|unrelated workflow/); + assert.match(result, /Excluded 2 jobs from another run or attempt/); + assert.match(result, /current failure/); +}); + +test("reports unfinished jobs without calling them assertion failures", () => { + const result = renderQualificationSummary(input({jobs: [job("waiting", null, { + status: "queued", steps: [], + })]})); + assert.match(result, /Incomplete jobs[\s\S]*waiting[\s\S]*queued/); + assert.doesNotMatch(result, /Failing jobs/); +}); + +test("renders metadata as bounded text and constructs links from trusted run identity", () => { + const result = renderQualificationSummary(input({jobs: [job("\n| [click](javascript:x) " + "a".repeat(400), "failure", { + steps: [{name: "