Skip to content

Commit ebdb9aa

Browse files
authored
ci: let a merge queue qualify candidates on both required checks (#5297)
1 parent 97bfca2 commit ebdb9aa

6 files changed

Lines changed: 104 additions & 13 deletions

File tree

‎.github/workflows/dco.yml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ name: DCO
22

33
on:
44
pull_request:
5+
# A required check must also report on merge-queue candidates.
6+
merge_group:
57

68
permissions:
79
contents: read
@@ -19,12 +21,15 @@ jobs:
1921

2022
- name: Require DCO trailers on contribution commits
2123
env:
22-
BASE_REF: ${{ github.event.pull_request.base.ref }}
23-
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
24+
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }}
25+
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
2426
GH_TOKEN: ${{ github.token }}
2527
shell: bash
2628
run: |
2729
set -euo pipefail
30+
# merge_group reports the base as a full ref (refs/heads/main).
31+
BASE_REF="${BASE_REF#refs/heads/}"
32+
test -n "${BASE_REF}" && test -n "${HEAD_SHA}"
2833
2934
# The event's base SHA can predate upstream commits already in the PR.
3035
# Refresh the exact target branch before selecting PR-only commits.

‎.github/workflows/python-tests.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ name: Python Tests
33
on:
44
pull_request:
55
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
6+
# Inert until the main ruleset enables a merge queue; queue runs are full.
7+
merge_group:
68
workflow_dispatch:
79
push:
810
branches:
@@ -388,7 +390,7 @@ jobs:
388390
needs: changes
389391
# The unsharded full suite outlasts a PR's qualification window, so the
390392
# non-blocking probe runs on main and on demand where it can finish.
391-
if: github.event_name != 'pull_request' && needs.changes.outputs.core_tests == 'true'
393+
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && needs.changes.outputs.core_tests == 'true'
392394
continue-on-error: true
393395
runs-on: ubuntu-latest
394396
# This runs the full core conformance suite. Allow forward-runtime/runner
@@ -528,6 +530,8 @@ jobs:
528530

529531
sonar:
530532
needs: pytest
533+
# Queue refs are temporary; analyse PRs and main, not gh-readonly-queue/*.
534+
if: github.event_name != 'merge_group'
531535
uses: ./.github/workflows/sonarcloud.yml
532536
secrets:
533537
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

‎docs/development/testing-and-quality.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,24 @@ is authoritative for activation. The lead maintainer alone retains the
113113
existing bypass exception; record the exact head, reason, validation and known
114114
failures whenever using it. A bypass does not turn failed tests into a pass.
115115

116+
Both required workflows also run on `merge_group`, so a GitHub merge queue can
117+
qualify the exact candidate that would land on `main`. Queue candidates never
118+
receive a job exemption: the classifier plans them as full, exactly like
119+
`main`. `Sign-off` checks the same contribution range and exempts only
120+
verified GitHub-generated two-parent merges, so configure the queue with the
121+
merge method `merge`. The trigger is inert until the live ruleset enables a
122+
merge queue. Enabling the queue, and then relaxing the up-to-date-branch
123+
requirement, is a ruleset decision for the lead maintainer.
124+
116125
每个 PR 都会收到 `merge-gate` 结果。代码、工作流、治理规则和未知路径必须通过
117126
原有核心测试;失败、取消、缺失或意外跳过均不能通过。仅白名单根目录 Markdown
118127
或 `docs/**/*.md` 的修改可显式跳过昂贵测试;运行时 prompt、可执行文档、代码删除
119128
及代码移入文档均不享受豁免。实际启用状态以在线规则为准,使用 owner bypass
120129
必须留下版本、原因、验证和已知失败的记录。
130+
两个必需工作流同样响应 `merge_group`,合并队列可在合入 `main` 前验证确切候选;
131+
队列候选一律全量验证、不享受豁免。队列合并方式应设为 `merge`,因为 `Sign-off`
132+
只豁免已验证的 GitHub 双父合并提交。在线规则启用合并队列前该触发不生效;启用
133+
队列并放宽“分支必须最新”要求由首席维护者决定。
121134

122135
To validate or change the classifier locally:
123136

‎tests/test_dco_workflow.py‎

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,37 @@ def history(tmp_path: Path, git_env: dict[str, str]):
6767
return runner, old_base, upstream
6868

6969

70+
def expression(value: str, context: dict[str, str]) -> str:
71+
"""Evaluate the workflow's `${{ a || b }}` env expressions over one event."""
72+
inner = value.removeprefix("${{").removesuffix("}}")
73+
assert value != inner, value
74+
return next((context[name] for name in (part.strip() for part in inner.split("||"))
75+
if context.get(name)), "")
76+
77+
7078
def check_dco(
7179
repo: Path, env: dict[str, str], old_base: str, head: str,
72-
*, base_ref: str = "release/next",
80+
*, base_ref: str = "release/next", event: str = "pull_request",
7381
) -> subprocess.CompletedProcess[str]:
7482
workflow = yaml.safe_load((ROOT / ".github/workflows/dco.yml").read_text(encoding="utf-8"))
75-
event_values = {
76-
"${{ github.event.pull_request.base.sha }}": old_base,
77-
"${{ github.event.pull_request.base.ref }}": base_ref,
78-
"${{ github.event.pull_request.head.sha }}": head,
79-
"${{ github.token }}": "synthetic-read-only-token",
80-
}
83+
assert event in workflow[True]
84+
if event == "pull_request":
85+
context = {
86+
"github.event.pull_request.base.sha": old_base,
87+
"github.event.pull_request.base.ref": base_ref,
88+
"github.event.pull_request.head.sha": head,
89+
}
90+
else:
91+
# A merge-queue event carries a full base ref and no pull_request.
92+
context = {
93+
"github.event.merge_group.base_sha": old_base,
94+
"github.event.merge_group.base_ref": f"refs/heads/{base_ref}",
95+
"github.event.merge_group.head_sha": head,
96+
}
97+
context["github.token"] = "synthetic-read-only-token"
8198
steps = [step for step in workflow["jobs"]["signoff"]["steps"] if "run" in step]
8299
for step in steps:
83-
step_env = {key: event_values[value] for key, value in step.get("env", {}).items()}
100+
step_env = {key: expression(value, context) for key, value in step.get("env", {}).items()}
84101
result = subprocess.run(
85102
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
86103
cwd=repo, env={**env, **step_env}, check=False,
@@ -270,3 +287,33 @@ def test_signed_pr_with_current_event_base_passes(history, git_env):
270287
head = commit(runner, git_env, "Signed contribution")
271288
result = check_dco(runner, git_env, upstream, head)
272289
assert result.returncode == 0, result.stdout + result.stderr
290+
291+
292+
@pytest.mark.parametrize("signed", [False, True])
293+
def test_merge_queue_candidate_checks_the_same_contribution_range(history, git_env, signed):
294+
runner, old_base, upstream = history
295+
head = commit(runner, git_env, "Queued contribution", signed=signed)
296+
result = check_dco(runner, git_env, old_base, head, event="merge_group")
297+
assert (result.returncode == 0) == signed, result.stdout + result.stderr
298+
assert (f"Commit {head} is missing" in result.stdout) == (not signed)
299+
assert f"Commit {upstream} is missing" not in result.stdout
300+
301+
302+
def test_merge_queue_platform_merge_is_exempt_only_with_verified_provenance(history, github_api):
303+
runner, old_base, _ = history
304+
env, calls = github_api
305+
head = github_merge(runner, env)
306+
metadata = verified_merge_record(runner, env, head)
307+
result = check_dco(runner, {**env, "DCO_TEST_METADATA": json.dumps(metadata)}, old_base, head,
308+
event="merge_group")
309+
assert result.returncode == 0, result.stdout + result.stderr
310+
assert f"Verified GitHub-generated merge {head}" in result.stdout
311+
assert calls.read_text().strip() == f"api repos/qualification/dco/commits/{head}"
312+
313+
314+
def test_missing_event_coordinates_fail_closed(history, git_env):
315+
runner, old_base, _ = history
316+
head = commit(runner, git_env, "Signed contribution")
317+
for base_ref, head_sha in (("", head), ("release/next", "")):
318+
result = check_dco(runner, git_env, old_base, head_sha, base_ref=base_ref)
319+
assert result.returncode != 0, (base_ref, head_sha)

‎tests/test_python_ci_workflow.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,5 +415,20 @@ def test_typescript_core_shards_feed_one_complete_coverage_report() -> None:
415415
assert "name: typescript-control-plane-coverage" in report
416416
assert "path: coverage/control-plane/lcov.info" in report
417417
forward = WORKFLOW.split(" node-forward-compatibility:\n", 1)[1].split(" test-shard:\n", 1)[0]
418-
assert "github.event_name != 'pull_request'" in forward
418+
assert "(github.event_name == 'push' || github.event_name == 'workflow_dispatch')" in forward
419419
assert "continue-on-error: true" in forward
420+
421+
422+
def test_merge_queue_candidates_run_full_qualification() -> None:
423+
import yaml
424+
425+
workflow = yaml.safe_load(WORKFLOW)
426+
assert "merge_group" in workflow[True]
427+
classify = WORKFLOW.split("name: Classify the exact pull-request change", 1)[1].split(" - uses:", 1)[0]
428+
# Only pull_request may classify an exemption; queue candidates take the
429+
# non-PR branch, which the classifier always plans as full.
430+
assert 'if [[ "$EVENT_NAME" == pull_request ]]; then' in classify
431+
assert "--non-pr" in classify.split("else", 1)[1]
432+
assert "github.event_name != 'merge_group'" in WORKFLOW.split(" sonar:\n", 1)[1].split(" uses:", 1)[0]
433+
# The required check list is unchanged: merge-gate always reports.
434+
assert "if: always()" in WORKFLOW.split(" merge-gate:\n", 1)[1]

‎tests/test_sonarcloud_workflow.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55

66
import pytest
7+
import yaml
78

89
WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "sonarcloud.yml"
910

@@ -71,6 +72,12 @@ def test_sonar_reuses_same_run_coverage_without_a_privileged_trigger() -> None:
7172
assert "name: python-coverage-xml" in workflow
7273
assert "run-id:" not in workflow
7374
assert "github-token:" not in workflow
74-
assert "needs: pytest\n uses: ./.github/workflows/sonarcloud.yml" in caller
75+
sonar = yaml.safe_load(caller)["jobs"]["sonar"]
76+
# Coverage comes from this run's pytest job, handed to the local reusable
77+
# workflow. The only condition may skip merge-queue refs; a status function
78+
# such as always() would let analysis run without that coverage.
79+
assert sonar["needs"] == "pytest"
80+
assert sonar["uses"] == "./.github/workflows/sonarcloud.yml"
81+
assert sonar.get("if") == "github.event_name != 'merge_group'"
7582
assert '"apps/**"' in caller
7683
assert '"sonar-project.properties"' in caller

0 commit comments

Comments
 (0)