Skip to content

Commit c537035

Browse files
CI: skip PR re-test after a clean main merge (#1373) (#1383)
* CI: skip PR re-test after a clean main merge Merging main into a PR branch re-ran the whole PR tier on a diff that had already passed, though the merge queue re-tests every entry on the latest main before it lands. During post-landing bursts about a quarter of full PR runs were these re-syncs (#1373). clippy now runs scripts/ci-reuse-pr-head.py on pull_request. When the head is a two-parent merge, the PR's own diff matches its previous head hunk for hunk (line numbers ignored, like git patch-id), and CI passed on that head with clippy actually running, it sets pr_unchanged=true. The build and test jobs then skip; clippy, the cheap preflight jobs and hosted-e2e still run. Conflict resolutions, merges of other branches, binary or oversized diffs and any API error run everything. Refs #1373 Assisted-by: Claude Code:claude-opus-5-5 * CI: reuse only on PRs the merge queue re-tests Review of the first cut found gaps where a skipped PR job would never run again before landing: - PRs into branches other than main have no merge queue, so they now always run everything. - The release/v5-prerelease PR runs a full tier the queue skips, so it never reuses either. - test-release is excluded from merge_group runs, so it now runs on an unchanged merge-main push too. The script also checks for a green previous run before fetching the two compare payloads, since most merge pushes follow a cancelled run. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent da076f0 commit c537035

4 files changed

Lines changed: 329 additions & 10 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ concurrency:
7272
# runs (test-release, e2e-full, yarn-berry-full, cargo-vex-matrix-full) and
7373
# hosted-e2e still run. API errors, missing evidence and direct pushes run
7474
# everything.
75+
# MERGE-MAIN REUSE: a pull_request push that only merged the base branch,
76+
# with the PR's own diff unchanged and the previous head green
77+
# (scripts/ci-reuse-pr-head.py, run by clippy), runs only clippy, the cheap
78+
# preflight jobs, test-release and hosted-e2e. The merge queue re-tests the
79+
# PR on the latest main before it lands; PRs into other branches and the
80+
# release/v5-prerelease PR never reuse.
7581
jobs:
7682
# Required independently of ci-ok so the merge queue sees a compile/lint
7783
# failure immediately. Expensive jobs also depend on this preflight.
@@ -84,6 +90,7 @@ jobs:
8490
actions: read
8591
outputs:
8692
reuse: ${{ steps.merge-queue.outputs.reuse }}
93+
pr_unchanged: ${{ steps.pr-head.outputs.unchanged }}
8794
steps:
8895
- name: Checkout
8996
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -99,6 +106,22 @@ jobs:
99106
GH_TOKEN: ${{ github.token }}
100107
run: python3 scripts/ci-reuse-merge-group.py
101108

109+
# A PR push that only merged the base branch, with the PR's own diff
110+
# unchanged and the previous head green, skips the test jobs: the
111+
# merge queue re-tests every entry on the latest main anyway (#1373).
112+
# This job and hosted-e2e still run. Only PRs into main (the only
113+
# queue), and never the v5 integration PR, whose full tier the queue
114+
# does not run.
115+
- name: Check for an unchanged PR diff
116+
id: pr-head
117+
if: github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref != 'release/v5-prerelease'
118+
env:
119+
GH_TOKEN: ${{ github.token }}
120+
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
121+
PR_BASE_REF: ${{ github.base_ref }}
122+
PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
123+
run: python3 scripts/ci-reuse-pr-head.py
124+
102125
- name: Install Rust
103126
# rustup is pre-installed on GitHub-hosted runners. `toolchain
104127
# install` with no name reads rust-toolchain.toml in the repo root,
@@ -133,7 +156,7 @@ jobs:
133156
# The napi addon is only ever loaded by Node, so cargo's own tests never
134157
# exercise its JS loader or the engine/provider boundary.
135158
node-addon:
136-
if: github.event.pull_request.draft != true
159+
if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true'
137160
needs: clippy
138161
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
139162
timeout-minutes: 30
@@ -299,7 +322,7 @@ jobs:
299322
bash scripts/release-lint.sh --sync-only
300323
301324
test:
302-
if: (github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
325+
if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.clippy.outputs.pr_unchanged != 'true'
303326
needs: clippy
304327
# No ubuntu-latest leg: `coverage` runs this same `cargo test
305328
# --workspace` (debug, default features, plus Go and vexctl) on ubuntu,
@@ -488,7 +511,8 @@ jobs:
488511
489512
test-release:
490513
# Each PR and main push runs the complete release-mode suite. The merge
491-
# queue already skips this job because each constituent PR ran it.
514+
# queue already skips this job because each constituent PR ran it, so
515+
# an unchanged merge-main PR push (pr_unchanged) still runs it too.
492516
if: (github.event.pull_request.draft != true && github.event_name != 'merge_group') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
493517
needs: clippy
494518
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-16' }}
@@ -535,7 +559,7 @@ jobs:
535559
run: python3 scripts/ci-test-shard.py "$TEST_SHARD" "$TEST_SHARD_COUNT" --locked --profile ci-release
536560

537561
coverage:
538-
if: github.event.pull_request.draft != true
562+
if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true'
539563
needs: clippy
540564
# Code coverage via cargo-llvm-cov (LLVM source-based instrumentation).
541565
# Reports as a markdown table in the job summary and uploads the raw
@@ -662,7 +686,7 @@ jobs:
662686
# Dockerfile.base compiles the full-LTO release binary inside Docker, with
663687
# no cache. Build it once per run and hand the image to every docker leg.
664688
docker-base:
665-
if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'release/v5-prerelease')
689+
if: ((github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'release/v5-prerelease')) && needs.clippy.outputs.pr_unchanged != 'true'
666690
needs: clippy
667691
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-22.04' || 'depot-ubuntu-22.04-4' }}
668692
timeout-minutes: 30
@@ -952,7 +976,7 @@ jobs:
952976
# test binaries directly from the same checkout path, so `CARGO_BIN_EXE_*`
953977
# and `CARGO_MANIFEST_DIR` resolve as they did under `cargo test`.
954978
e2e-build:
955-
if: github.event.pull_request.draft != true
979+
if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true'
956980
needs: clippy
957981
outputs:
958982
reuse: ${{ needs.clippy.outputs.reuse }}
@@ -1018,7 +1042,7 @@ jobs:
10181042
# Lean scope still compiles every test target on Windows in the merge
10191043
# queue (one job): lean pull_request runs skip Windows, and a cfg(unix)
10201044
# slip only shows up here (#1324). Its consumers stay full-scope only.
1021-
if: (github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'merge_group' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
1045+
if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'merge_group' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.clippy.outputs.pr_unchanged != 'true'
10221046
needs: clippy
10231047
outputs:
10241048
reuse: ${{ needs.clippy.outputs.reuse }}
@@ -2115,7 +2139,7 @@ jobs:
21152139
# Dropping that `needs` also dropped the draft skip it inherited, so
21162140
# gate on draft here directly (push/merge_group/schedule still run).
21172141
# Only wait for the clippy preflight.
2118-
if: github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true'
2142+
if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && needs.clippy.outputs.pr_unchanged != 'true'
21192143
needs: clippy
21202144
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
21212145
timeout-minutes: 40
@@ -2159,7 +2183,7 @@ jobs:
21592183
# Dropping that `needs` also dropped the draft skip it inherited, so
21602184
# gate on draft here directly (push/merge_group/schedule still run).
21612185
# Only wait for the clippy preflight.
2162-
if: github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true'
2186+
if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && needs.clippy.outputs.pr_unchanged != 'true'
21632187
needs: clippy
21642188
strategy:
21652189
fail-fast: false
@@ -2398,7 +2422,7 @@ jobs:
23982422
# Dropping that `needs` also dropped the draft skip it inherited, so
23992423
# gate on draft here directly (push/merge_group/schedule still run).
24002424
# Only wait for the clippy preflight.
2401-
if: (github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
2425+
if: ((github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && needs.clippy.outputs.pr_unchanged != 'true'
24022426
needs: clippy
24032427
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
24042428
timeout-minutes: 30

‎scripts/ci-reuse-pr-head.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env python3
2+
"""Skip re-testing a PR push that only merged the base branch.
3+
4+
With a merge queue, a PR never has to be up to date with main: the queue
5+
re-tests every entry on top of the latest main before it lands. Merging main
6+
into a PR branch therefore re-runs the whole PR tier on a diff that already
7+
passed. This reports `unchanged=true` only when all of these hold:
8+
9+
- the PR targets main, the only branch with a merge queue;
10+
- the PR head is a two-parent merge commit;
11+
- the PR's own diff (merge-base with the base branch to head) is the same,
12+
hunk for hunk, as the diff of its first parent, the previous head;
13+
- this workflow completed successfully on that previous head, for the same
14+
head repository, and its clippy job ran (a draft run skips everything).
15+
16+
A conflict resolution changes a hunk, and a merge of anything other than the
17+
base branch adds files or hunks, so both run the full tier. Any API error,
18+
oversized or binary diff, or missing evidence also runs the full tier.
19+
"""
20+
21+
import json
22+
import os
23+
import re
24+
import subprocess
25+
from pathlib import Path
26+
27+
# The compare API lists at most 300 files and leaves out the patch of a
28+
# binary or very large file. Either way the diff cannot be proven unchanged.
29+
MAX_FILES = 300
30+
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@", re.MULTILINE)
31+
32+
33+
def api(endpoint):
34+
result = subprocess.run(["gh", "api", endpoint], check=True, capture_output=True,
35+
text=True, timeout=15)
36+
return json.loads(result.stdout)
37+
38+
39+
def diff_signature(compare):
40+
"""The PR diff with line numbers dropped, like `git patch-id --stable`."""
41+
files = compare["files"]
42+
if not files or len(files) >= MAX_FILES:
43+
return None
44+
signature = []
45+
for entry in files:
46+
patch = entry.get("patch")
47+
if patch is None:
48+
return None
49+
signature.append((entry["filename"], entry.get("previous_filename"), entry["status"],
50+
HUNK_HEADER.sub("@@", patch)))
51+
return sorted(signature, key=repr)
52+
53+
54+
def passed_run(runs, jobs_of, head_sha, head_repository):
55+
for run in runs["workflow_runs"]:
56+
if (run.get("event") == "pull_request"
57+
and run.get("head_sha") == head_sha
58+
and run.get("status") == "completed"
59+
and run.get("conclusion") == "success"
60+
and run.get("path") == ".github/workflows/ci.yml"
61+
and run.get("head_repository", {}).get("full_name") == head_repository):
62+
# The run's success already covers ci-ok. A draft run also ends in
63+
# success with every job skipped, so clippy must have run. It is
64+
# the first job, so it is on the first page even of a full run.
65+
jobs = {job.get("name"): job.get("conclusion") for job in jobs_of(run["id"])["jobs"]}
66+
if jobs.get("clippy") == "success":
67+
return run["id"]
68+
return None
69+
70+
71+
def reusable_run(env):
72+
# Only main has a merge queue to re-test the PR before it lands.
73+
if env.get("GITHUB_EVENT_NAME") != "pull_request" or env.get("PR_BASE_REF") != "main":
74+
return None
75+
try:
76+
repository, head = env["GITHUB_REPOSITORY"], env["PR_HEAD_SHA"]
77+
head_repository = env["PR_HEAD_REPOSITORY"]
78+
parents = api(f"repos/{repository}/commits/{head}")["parents"]
79+
if len(parents) != 2:
80+
return None
81+
previous = parents[0]["sha"]
82+
# The cheap evidence first: most merge pushes follow a cancelled run.
83+
runs = api(f"repos/{repository}/actions/workflows/ci.yml/runs"
84+
f"?event=pull_request&head_sha={previous}&per_page=20")
85+
run = passed_run(runs, lambda run_id: api(
86+
f"repos/{repository}/actions/runs/{run_id}/jobs?filter=latest&per_page=100"),
87+
previous, head_repository)
88+
if run is None:
89+
return None
90+
# Three-dot compares diff from the merge-base, which is what the PR
91+
# shows. per_page=1 keeps the commit list short; files stay complete.
92+
now = diff_signature(api(f"repos/{repository}/compare/main...{head}?per_page=1"))
93+
before = diff_signature(api(f"repos/{repository}/compare/main...{previous}?per_page=1"))
94+
return run if now is not None and now == before else None
95+
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError,
96+
AttributeError, IndexError) as error:
97+
# Do not print response bodies or stderr, which may contain credentials.
98+
print(f"::notice::Could not compare with the previous PR head ({type(error).__name__}); "
99+
"running all checks.")
100+
return None
101+
102+
103+
def main():
104+
run = reusable_run(os.environ)
105+
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
106+
output.write(f"unchanged={'true' if run is not None else 'false'}\n")
107+
if run is not None:
108+
print(f"This push only merged {os.environ['PR_BASE_REF']}; the PR diff is unchanged and "
109+
f"CI run {run} passed the previous head. The merge queue re-tests it on the "
110+
"latest base, so only the preflight runs here.")
111+
else:
112+
print("Not a clean base-branch merge onto a passing head; running all checks.")
113+
114+
115+
if __name__ == "__main__":
116+
main()

0 commit comments

Comments
 (0)