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
44 changes: 34 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ concurrency:
# runs (test-release, e2e-full, yarn-berry-full, cargo-vex-matrix-full) and
# hosted-e2e still run. API errors, missing evidence and direct pushes run
# everything.
# MERGE-MAIN REUSE: a pull_request push that only merged the base branch,
# with the PR's own diff unchanged and the previous head green
# (scripts/ci-reuse-pr-head.py, run by clippy), runs only clippy, the cheap
# preflight jobs, test-release and hosted-e2e. The merge queue re-tests the
# PR on the latest main before it lands; PRs into other branches and the
# release/v5-prerelease PR never reuse.
jobs:
# Required independently of ci-ok so the merge queue sees a compile/lint
# failure immediately. Expensive jobs also depend on this preflight.
Expand All @@ -84,6 +90,7 @@ jobs:
actions: read
outputs:
reuse: ${{ steps.merge-queue.outputs.reuse }}
pr_unchanged: ${{ steps.pr-head.outputs.unchanged }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -99,6 +106,22 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/ci-reuse-merge-group.py

# A PR push that only merged the base branch, with the PR's own diff
# unchanged and the previous head green, skips the test jobs: the
# merge queue re-tests every entry on the latest main anyway (#1373).
# This job and hosted-e2e still run. Only PRs into main (the only
# queue), and never the v5 integration PR, whose full tier the queue
# does not run.
- name: Check for an unchanged PR diff
id: pr-head
if: github.event_name == 'pull_request' && github.base_ref == 'main' && github.head_ref != 'release/v5-prerelease'
env:
GH_TOKEN: ${{ github.token }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_REF: ${{ github.base_ref }}
PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
run: python3 scripts/ci-reuse-pr-head.py

- name: Install Rust
# rustup is pre-installed on GitHub-hosted runners. `toolchain
# install` with no name reads rust-toolchain.toml in the repo root,
Expand Down Expand Up @@ -133,7 +156,7 @@ jobs:
# The napi addon is only ever loaded by Node, so cargo's own tests never
# exercise its JS loader or the engine/provider boundary.
node-addon:
if: github.event.pull_request.draft != true
if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true'
needs: clippy
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 30
Expand Down Expand Up @@ -299,7 +322,7 @@ jobs:
bash scripts/release-lint.sh --sync-only

test:
if: (github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
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'
needs: clippy
# No ubuntu-latest leg: `coverage` runs this same `cargo test
# --workspace` (debug, default features, plus Go and vexctl) on ubuntu,
Expand Down Expand Up @@ -488,7 +511,8 @@ jobs:

test-release:
# Each PR and main push runs the complete release-mode suite. The merge
# queue already skips this job because each constituent PR ran it.
# queue already skips this job because each constituent PR ran it, so
# an unchanged merge-main PR push (pr_unchanged) still runs it too.
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')
needs: clippy
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-16' }}
Expand Down Expand Up @@ -535,7 +559,7 @@ jobs:
run: python3 scripts/ci-test-shard.py "$TEST_SHARD" "$TEST_SHARD_COUNT" --locked --profile ci-release

coverage:
if: github.event.pull_request.draft != true
if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true'
needs: clippy
# Code coverage via cargo-llvm-cov (LLVM source-based instrumentation).
# Reports as a markdown table in the job summary and uploads the raw
Expand Down Expand Up @@ -662,7 +686,7 @@ jobs:
# Dockerfile.base compiles the full-LTO release binary inside Docker, with
# no cache. Build it once per run and hand the image to every docker leg.
docker-base:
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')
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'
needs: clippy
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-22.04' || 'depot-ubuntu-22.04-4' }}
timeout-minutes: 30
Expand Down Expand Up @@ -952,7 +976,7 @@ jobs:
# test binaries directly from the same checkout path, so `CARGO_BIN_EXE_*`
# and `CARGO_MANIFEST_DIR` resolve as they did under `cargo test`.
e2e-build:
if: github.event.pull_request.draft != true
if: (github.event.pull_request.draft != true) && needs.clippy.outputs.pr_unchanged != 'true'
needs: clippy
outputs:
reuse: ${{ needs.clippy.outputs.reuse }}
Expand Down Expand Up @@ -1018,7 +1042,7 @@ jobs:
# Lean scope still compiles every test target on Windows in the merge
# queue (one job): lean pull_request runs skip Windows, and a cfg(unix)
# slip only shows up here (#1324). Its consumers stay full-scope only.
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')
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'
needs: clippy
outputs:
reuse: ${{ needs.clippy.outputs.reuse }}
Expand Down Expand Up @@ -2113,7 +2137,7 @@ jobs:
# Dropping that `needs` also dropped the draft skip it inherited, so
# gate on draft here directly (push/merge_group/schedule still run).
# Only wait for the clippy preflight.
if: github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true'
if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && needs.clippy.outputs.pr_unchanged != 'true'
needs: clippy
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 40
Expand Down Expand Up @@ -2157,7 +2181,7 @@ jobs:
# Dropping that `needs` also dropped the draft skip it inherited, so
# gate on draft here directly (push/merge_group/schedule still run).
# Only wait for the clippy preflight.
if: github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true'
if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && needs.clippy.outputs.pr_unchanged != 'true'
needs: clippy
strategy:
fail-fast: false
Expand Down Expand Up @@ -2394,7 +2418,7 @@ jobs:
# Dropping that `needs` also dropped the draft skip it inherited, so
# gate on draft here directly (push/merge_group/schedule still run).
# Only wait for the clippy preflight.
if: (github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
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'
needs: clippy
runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
timeout-minutes: 30
Expand Down
116 changes: 116 additions & 0 deletions scripts/ci-reuse-pr-head.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Skip re-testing a PR push that only merged the base branch.

With a merge queue, a PR never has to be up to date with main: the queue
re-tests every entry on top of the latest main before it lands. Merging main
into a PR branch therefore re-runs the whole PR tier on a diff that already
passed. This reports `unchanged=true` only when all of these hold:

- the PR targets main, the only branch with a merge queue;
- the PR head is a two-parent merge commit;
- the PR's own diff (merge-base with the base branch to head) is the same,
hunk for hunk, as the diff of its first parent, the previous head;
- this workflow completed successfully on that previous head, for the same
head repository, and its clippy job ran (a draft run skips everything).

A conflict resolution changes a hunk, and a merge of anything other than the
base branch adds files or hunks, so both run the full tier. Any API error,
oversized or binary diff, or missing evidence also runs the full tier.
"""

import json
import os
import re
import subprocess
from pathlib import Path

# The compare API lists at most 300 files and leaves out the patch of a
# binary or very large file. Either way the diff cannot be proven unchanged.
MAX_FILES = 300
HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@", re.MULTILINE)


def api(endpoint):
result = subprocess.run(["gh", "api", endpoint], check=True, capture_output=True,
text=True, timeout=15)
return json.loads(result.stdout)


def diff_signature(compare):
"""The PR diff with line numbers dropped, like `git patch-id --stable`."""
files = compare["files"]
if not files or len(files) >= MAX_FILES:
return None
signature = []
for entry in files:
patch = entry.get("patch")
if patch is None:
return None
signature.append((entry["filename"], entry.get("previous_filename"), entry["status"],
HUNK_HEADER.sub("@@", patch)))
return sorted(signature, key=repr)


def passed_run(runs, jobs_of, head_sha, head_repository):
for run in runs["workflow_runs"]:
if (run.get("event") == "pull_request"
and run.get("head_sha") == head_sha
and run.get("status") == "completed"
and run.get("conclusion") == "success"
and run.get("path") == ".github/workflows/ci.yml"
and run.get("head_repository", {}).get("full_name") == head_repository):
# The run's success already covers ci-ok. A draft run also ends in
# success with every job skipped, so clippy must have run. It is
# the first job, so it is on the first page even of a full run.
jobs = {job.get("name"): job.get("conclusion") for job in jobs_of(run["id"])["jobs"]}
if jobs.get("clippy") == "success":
return run["id"]
return None


def reusable_run(env):
# Only main has a merge queue to re-test the PR before it lands.
if env.get("GITHUB_EVENT_NAME") != "pull_request" or env.get("PR_BASE_REF") != "main":
return None
try:
repository, head = env["GITHUB_REPOSITORY"], env["PR_HEAD_SHA"]
head_repository = env["PR_HEAD_REPOSITORY"]
parents = api(f"repos/{repository}/commits/{head}")["parents"]
if len(parents) != 2:
return None
previous = parents[0]["sha"]
# The cheap evidence first: most merge pushes follow a cancelled run.
runs = api(f"repos/{repository}/actions/workflows/ci.yml/runs"
f"?event=pull_request&head_sha={previous}&per_page=20")
run = passed_run(runs, lambda run_id: api(
f"repos/{repository}/actions/runs/{run_id}/jobs?filter=latest&per_page=100"),
previous, head_repository)
if run is None:
return None
# Three-dot compares diff from the merge-base, which is what the PR
# shows. per_page=1 keeps the commit list short; files stay complete.
now = diff_signature(api(f"repos/{repository}/compare/main...{head}?per_page=1"))
before = diff_signature(api(f"repos/{repository}/compare/main...{previous}?per_page=1"))
return run if now is not None and now == before else None
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError,
AttributeError, IndexError) as error:
# Do not print response bodies or stderr, which may contain credentials.
print(f"::notice::Could not compare with the previous PR head ({type(error).__name__}); "
"running all checks.")
return None


def main():
run = reusable_run(os.environ)
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output:
output.write(f"unchanged={'true' if run is not None else 'false'}\n")
if run is not None:
print(f"This push only merged {os.environ['PR_BASE_REF']}; the PR diff is unchanged and "
f"CI run {run} passed the previous head. The merge queue re-tests it on the "
"latest base, so only the preflight runs here.")
else:
print("Not a clean base-branch merge onto a passing head; running all checks.")


if __name__ == "__main__":
main()
Loading
Loading