Skip to content

ci(l1,l2): stop the merge queue satisfying the required integration checks with a skipped job - #7213

Open
ilitteri wants to merge 5 commits into
mainfrom
ci/merge-queue-required-checks
Open

ci(l1,l2): stop the merge queue satisfying the required integration checks with a skipped job#7213
ilitteri wants to merge 5 commits into
mainfrom
ci/merge-queue-required-checks

Conversation

@ilitteri

@ilitteri ilitteri commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Motivation

A pull request whose Hive run was red, or whose re-run was still in flight, could merge
through the merge queue without that result ever being consulted.

Integration Test and Integration Test L2 are required status checks on main, and both
gate jobs bailed out whenever a dependency was skipped:

if: ${{ ... && needs.run-assertoor.result != 'skipped' && needs.run-hive.result != 'skipped' }}

Hive, assertoor and the L2 suites are all excluded from merge_group to keep the queue
cheap, so inside the queue that condition was always false and the gate job was skipped.
GitHub counts a skipped check run as satisfying a required status check, so every merge
group satisfied both requirements without running or even consulting the suites they exist to
enforce.

Merge when ready compounds it: GitHub decides queue eligibility when the pull request is
enqueued and does not re-evaluate the head afterwards. A stale green Integration Test is
enough to get in, a later red result cannot evict it, and the merge group's own gate was
vacuous. So re-running a failed Hive job did not protect main — the merge went ahead while
the re-run was still going, and the re-run's failure landed after the merge.

Observed on three merges the same day:

pull request Integration Test on its head outcome
#7200 failure at 16:49:47 merge group started 16:50:00, merged
#7201 failure at 17:03:50 merged at 17:52

Both had a red Hive - Devp2p tests, and main went red on devp2p immediately afterwards
until #7204 fixed the underlying discv5 bug.

Description

No suite is added to the merge queue. All fourteen github.event_name != 'merge_group'
exclusions are untouched, so hive, assertoor, reorg, engine-EF and the L2 suites still do not
run there, and the queue's cost profile is unchanged. What changes is what the gate does.

On the pull request side, three things stop the gate from passing vacuously:

  • It no longer bails out on a skipped dependency, because a gate that skips is a gate that
    always passes. This is also what makes a broken docker build — which turns every hive and
    assertoor job into a skip — produce a red Integration Test rather than no verdict at all.
  • A failed detect-changes publishes no outputs, so '' == 'true' skipped the gate and
    turned both required checks green with nothing evaluated. The gate now runs and fails.
  • !cancelled() replaces always(), so a concurrency-cancelled run does not stamp a red
    required check on a commit that has already been superseded.

On merge_group the gate runs .github/scripts/check-queued-pr-checks.sh, which asserts
that this same gate was green on each queued pull request's own head. Per queued pull
request:

  1. Resolve the pull request from the merge group's commit subjects via the compare API, not
    from the queue branch name, because a batched group's ref names only its last pull request.
  2. Find the latest pull_request run of this workflow on that pull request's head. The
    workflow path comes from GITHUB_WORKFLOW_REF rather than an argument, so it cannot drift
    if the file is renamed.
  3. Require that run to be completed. Re-running a suite bumps the run attempt, so a Hive
    re-run in flight now blocks the merge group instead of being bypassed — the case this pull
    request is really about. Because this runs at merge time rather than at enqueue time, it
    also catches a result that turned red after the pull request was queued.
  4. Require this gate job's verdict inside that run to be success or skipped.

Reading the gate's own verdict rather than matching suite check-run names is what keeps the
gate honest, and it is the part of this pull request worth reviewing closely:

  • It leaves one definition of what is required — the workflow's own Check if any job failed step. A prefix list is a second definition that can disagree with it, which is
    exactly how Integration Test - TDX ended up matched by the merge-group side while the
    pull_request side does not require it, and how Engine EF tests ended up in the gate's
    needs but in no prefix list. Neither is expressible now, and a suite added later is
    covered without touching the script.
  • It cannot be confused by a job another workflow happens to name the same way. Jobs are read
    from one workflow run resolved by .path, so daily_hive_report.yaml — which publishes
    Hive - <name> with continue-on-error: true on any pull request touching its trigger
    paths — is out of scope by construction rather than by a tie-break on started_at.
  • skipped remains a pass, but now means something checkable: this gate was not required,
    which is what an L1-only pull request looks like to the L2 workflow and a docs-only one to
    both. A suite skipped for the wrong reason no longer produces it, because a failed
    dependency makes the gate run and fail.
  • Finding no run, or no job carrying the gate's name, fails. A gate that cannot see what it
    is verifying must not report success.

One more hole closed on the same dependency: on merge_group, run_tests is code_changed,
which matches only **/*.rs, **/*.toml and **/*.lock. Any pull request touching just
workflows, scripts, fixtures or configs therefore skipped the gate in the queue anyway — this
branch included. PR #7193 is the worked example: its merge group skipped every job,
Integration Test among them, and it merged, while its head carried seven green Hive - *
results the queue never read. The gate now runs on merge_group regardless of run_tests; a
pull request that genuinely required nothing still passes, because the verdict it then reads
on the head is skipped.

The gate's checkout is also limited to the merge_group path, the only one that reads the
tree.

Verification

The gate's decision logic is not something CI can exercise, so it was driven directly against
live repository data, with only the merge group's compare response stubbed so the case under
test could name any pull request. Fifteen cases, all as expected:

case data expected
green L1 gate #7204's merge group pass
gate skipped #7194 (L1-only) through the L2 gate pass
CI-only pull request #7193's merge group pass
red gate #7200 (Integration Test = failure, red Hive - Rpc Compat tests) block
run in flight #7239 (L1 run in_progress) block
no run on the head #6760 block
workflow never approved #7059 (run action_required, zero jobs) block
batch, one red #7200 + #7217 block
batch, both green #7217 + #7180 pass
gate name matches nothing #7217, bogus name block
workflow path never ran #7217, bogus path block
no (#N) in any subject empty commit list block
no arguments usage, exit 2
not a merge_group event pull_request payload exit 2
GITHUB_WORKFLOW_REF unset non-zero

Head e104cdbc — the docker-build failure that produced Build Docker = failure with hive
and assertoor skipped — is the case the old prefix loop reported green. It is not in the
table because its recorded gate verdict predates this branch: with the != 'skipped' guards
dropped, that head's Check if any job failed step sees needs.run-assertoor.result =
skipped and exits 1, so the gate is failure and the merge group blocks.

Known limitations

If the merge group's gate has already reported green and a Hive job is re-run after that, the
merge can still complete. Closing that would need the suites to run in the queue, which is the
trade this pull request deliberately does not make: it would add roughly twenty minutes per
queued pull request and let a hive flake evict pull requests from the queue.

Not addressed here

  • Integration Test is declared by three workflows: pr-main_l1.yaml,
    pr-main_l1_l2_dev.yaml (which also runs on merge_group) and pr-main_levm.yaml. A
    required check name shared by several workflows is ambiguous to branch protection — on PR
    fix(l1): spend blob cost out of the balance eth_estimateGas recaps against #7209's head the L1 one was failure while the L2-Dev one was success, and it merged.
    This pull request makes pr-main_l1.yaml's own verdict real in the queue, but resolving the
    collision needs one of the jobs renamed and the required-check settings updated together,
    which cannot be done from a pull request alone.
  • check-cargo-locks is in the L1 gate's needs but its result is still never inspected, so
    Check Cargo.lock remains unenforced by the required check. That is a separate policy call.
  • Integration Test - TDX stays not required. It is in the L2 gate's needs with no
    continue-on-error, and concluded failure or cancelled in 5 of 12 sampled pull_request
    runs; promoting it to a merge blocker is a policy change, not a fix to this hole. Both sides
    now agree that it does not gate.

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync.

…of skipping them.

`Integration Test` and `Integration Test L2` are required status checks on main,
but both gate jobs bailed out whenever a dependency was skipped:

    if: ${{ ... && needs.run-hive.result != 'skipped' ... }}

Hive, assertoor and the L2 suites are all excluded from `merge_group` to keep the
queue cheap, so inside the queue that condition was always false and the gate job
was skipped. GitHub counts a skipped check run as satisfying a required status
check, so every merge group satisfied both requirements without running or even
consulting the suites they exist to enforce. A pull request queued while its hive
run was red, or while a re-run was still in flight, merged on that vacuous green.

Two of the three commits that landed on main on the day this was found had a red
`Hive - Devp2p tests` and a failed `Integration Test` on their own head, and main
went red on devp2p immediately afterwards.

The gate jobs now always run when the workflow is not skipped wholesale, and on
`merge_group` they read the queued pull request's own results for those suites
via `check-queued-pr-checks.sh`. That keeps the queue's cost profile unchanged
while making the requirement real, and because it runs at merge time it also
catches a suite that turned red or was re-triggered after the pull request was
added to the queue: a still-running suite blocks the group rather than passing.

The script resolves the queued pull requests from the merge group's commit
subjects, so a batched group is covered rather than only its last pull request,
and it keeps the most recently started check run per name so a superseded red run
cannot block a head that is now green. A skipped suite still counts as satisfied,
which is what an L2-only pull request looks like to the L1 workflow. Finding no
matching check run at all fails: a gate that cannot see what it is verifying must
not report success.

Not addressed here: `check-cargo-locks` is in the L1 gate's `needs` but its
result is still never inspected, so `Check Cargo.lock` remains unenforced by the
required check. That is a separate policy call.
…ude.

Same bug class as the parent commit, one dependency further up. The gate keys off
`needs.detect-changes.outputs.run_tests`, and a job that failed publishes no
outputs, so `'' == 'true'` was false and the gate skipped — which GitHub counts as
satisfying the required check. A broken change-detection step therefore turned
both `Integration Test` and `Integration Test L2` green without anything having
been evaluated.

The gate now also runs when `detect-changes` did not succeed, and fails
immediately in that case: with no `run_tests` answer there is no way to tell
whether the suites were required, and an unanswerable gate must not report
success.
@ilitteri
ilitteri requested a review from a team as a code owner August 24, 2026 20:30
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rsunsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot added L1 Ethereum client L2 Rollup client labels Aug 24, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This PR correctly implements a fail-closed gate for GitHub merge queues to prevent merging PRs with failing/skipped expensive test suites (Hive, Assertoor, L2 integration tests). The approach of reading the original PR's check runs rather than relying on skipped jobs in the merge group is the correct solution to GitHub's behavior where skipped checks satisfy required status checks.

.github/scripts/check-queued-pr-checks.sh

Security & Correctness:

  • Line 24-28: The regex extraction of PR numbers from commit messages assumes GitHub's squash-merge format (Title (#1234)). This is robust for the intended workflow, but consider adding a comment noting this dependency on squash-merge conventions.
  • Line 38: Unquoted variable in pr_head=$(gh api ...). While SHA values are alphanumeric, defensive quoting is preferred: pr_head="$(gh api ...)".
  • Line 40-41: The --slurp flag loads all paginated responses into memory. For repositories with extensive check run histories, this could be memory-intensive, but likely acceptable for typical PRs.

Robustness:

  • Line 30-33: Excellent defensive programming—refusing to pass when no PRs are identified prevents false positives.
  • Line 65-68: Good handling of the case where expected check suites never reported (fail-closed behavior).

Minor:

  • Line 53: The empty check [[ -z "$name" ]] handles jq's potential empty output, but since mapfile with -t removes trailing newlines and the while loop processes the here-string, this is safe.

.github/workflows/pr-main_l1.yaml & .github/workflows/pr-main_l2.yaml

Architecture:

  • Line 471 (L1) / Line 975 (L2): The condition always() && (needs.detect-changes.result != 'success' || ...) correctly ensures the gate runs even when dependencies fail, allowing the job to report the actual failure reason rather than being silently skipped.
  • Line 490-493 (L1) / Line 1000-1003 (L2): The explicit failure on detect-changes non-success is critical—an unanswerable gate must not report success.

Permissions:

  • Line 476-479 (L1) / Line 980-983 (L2): The permissions block correctly scopes to minimum required (contents: read, checks: read, pull-requests: read). This follows least-privilege principles.

Logic:

  • Line 495 (L1) / Line 1005 (L2): Using actions/checkout@v6 is up-to-date.
  • Line 502-505 (L1): The script invocation correctly targets the expensive suite prefixes. Note that in L2 (line 1007), "State Reconstruction Tests" and "Uniswap Swap Token Flow" are passed as exact matches (prefixes without wildcards), which works correctly due to the == "$prefix"* pattern in the script.

Nitpick:

  • Line 513 (L1): Comment mentions engine-ef-tests being skipped in merge queue, but the script doesn't verify engine-ef-tests results from the PR. This appears intentional (defensive comment), but verify this aligns with your security model—if engine-ef-tests is required but skipped in merge queue, it should likely be added to the script arguments or documented why it's exempt.

Summary

The implementation is secure and correct. The fail-closed approach (exiting 1 when uncertain) properly mitigates the risk of merging red builds. The bash script follows best practices (set -euo pipefail, proper array handling with mapfile).

Recommendation: Accept with optional minor quoting fix on line 38 of the shell script.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. High: .github/scripts/check-queued-pr-checks.sh only checks that there was at least one matching check overall, not at least one match for each requested prefix. If Hive - ... exists but every Assertoor - ... run is missing, matched is still nonzero and the gate passes. The same bug applies to the L2 invocation if one whole suite family never reported. Track matches per prefix and fail any prefix with zero hits.

  2. High: .github/workflows/pr-main_l1.yaml verifies only Hive - and Assertoor - in merge-group runs, but the aggregate job still includes engine-ef-tests in needs at .github/workflows/pr-main_l1.yaml, and that job is explicitly skipped on merge_group at .github/workflows/pr-main_l1.yaml. Result: a PR with failing Engine EF tests can still merge through the queue. Add Engine EF tests to the PR-head verification path.

  3. Medium: .github/scripts/check-queued-pr-checks.sh treats skipped and neutral as passing conclusions. That weakens the exact invariant this change is trying to restore: a required suite on the PR head can still be non-successful and yet allow the merge queue to go green. Unless there is a concrete, intended neutral/skipped mode for these suites, require conclusion == success.

This PR only touches CI/workflow code, so I did not find any Rust/EVM/consensus-path changes to review here.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 7213 — merge queue enforcement of required integration checks

This is a CI/workflow change rather than core protocol code, so the review focuses on the correctness of the gating logic and the new shell script rather than EVM/consensus concerns.

Findings

1. engine-ef-tests reintroduces the exact vacuous-pass hole this PR is fixing (high confidence)

.github/workflows/pr-main_l1.yaml:470 still lists engine-ef-tests as a dependency of the required Integration Test gate, and per the PR's own description, engine-ef-tests is one of the suites excluded from merge_group (same as Hive and Assertoor). On merge_group:

  • The Check if any job failed step that used to tolerate needs.engine-ef-tests.result == 'skipped' now only runs if: github.event_name != 'merge_group' (line 504), so it never executes in the queue.
  • The new Check the queued pull request's suites step (line 497-501) only checks "Hive - " and "Assertoor - " prefixes — "Engine EF tests" (the check run name at line 143) is never passed to check-queued-pr-checks.sh.

The result: in the merge queue, engine-ef-tests is neither directly inspected (it's skipped) nor verified against the queued PR's own head via the script. It silently passes exactly the way Hive/Assertoor used to before this fix. Unlike check-cargo-locks, this gap isn't called out in the "Not addressed here" section, so it reads as an oversight rather than a deliberate scope decision. Suggest adding "Engine EF tests" to the prefix list at line 501.

2. check-queued-pr-checks.sh verifies "at least one prefix matched", not "every prefix matched" (medium confidence)

In check-queued-pr-checks.sh, matched is a single counter incremented across all supplied prefixes (lines ~59-72), and the failure condition is matched -eq 0 (line 74). If, for some reason, one requested suite (e.g. "Assertoor - ") never produces a matching check run on the PR's head — while another suite (e.g. "Hive - ") does — the script reports success for the whole call, because the aggregate matched count is non-zero. In the current L1/L2 workflows this is likely masked because GitHub Actions still creates a named check run per static matrix entry even when the job-level if is false, so every expected name should normally appear with a skipped/success/failure conclusion. But this makes the safety property depend on that implicit behavior rather than something the script itself verifies. Tracking per-prefix match counts (e.g. a match-count map, failing if any prefix has zero matches) would make the "a gate that cannot see what it verifies must not report success" guarantee (which the script explicitly aims for) hold per-suite instead of in aggregate.

3. Reliance on squash-commit-subject convention for identifying queued PRs (low severity, flagged for awareness)

grep -oE '\(#[0-9]+\)$' on the first line of each commit in the compare diff assumes every queued commit is a GitHub squash-merge with the default Title (#1234) format. If the repo ever allows merge or rebase commits, or a commit message happens to end with (#NNN) referencing something unrelated (e.g., a manually written commit subject citing an issue number), the script could either silently drop a real PR (safe: fails closed via the pr_numbers empty check) or misattribute a commit to the wrong PR number (unsafe, though very unlikely given GitHub's squash format). Given this was validated against live merge groups per the PR description, this is more a documented fragility than a bug — but it may be worth a comment noting the assumption is enforced by requiring squash-merge in repo settings, if that's the case.

Positive notes

  • Permissions are scoped down to contents: read, checks: read, pull-requests: read at the job level rather than widened globally — good least-privilege practice.
  • The script fails closed in every ambiguous case (no PR identified, no matching check run, change detection inconclusive) rather than defaulting to pass — the right posture for a required-check gate.
  • Good use of max_by(.started_at // "") per check name to avoid a stale/superseded run blocking a now-green head.
  • The detect-changes non-success case (Item in commit 2) is a correct and easy-to-miss fix — '' == 'true' silently skipping the gate is a real footgun in GitHub Actions conditionals.
  • Test coverage described in the PR body (stubbed API cases + live merge groups) is appropriately thorough for logic that "CI cannot exercise" otherwise.

Automated review by Claude (Anthropic) · sonnet · custom prompt

@MegaRedHand MegaRedHand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the merge-queue gate change against live repo data. The problem it targets is real and I confirmed it end to end: PR #7209 merged on 2026-08-24 with Hive - Devp2p tests red, and its merge group reported Integration Test as skipped. Reading the queued pull request's own check runs is the right shape for a fix, and the bash is careful.

Two things stop it from actually closing the hole. Both are inline, both verified against real commits.

1. skipped is accepted, and that state is reachable with the suites genuinely not run. Head e104cdbc really exists: Build Docker = failure, Hive - ${{ matrix.name }} = skipped, Assertoor - ${{ matrix.name }} = skipped. Replaying this script's loop over that head prints All required suites passed.

The natural defense is that such a pull request cannot enter the queue because its own Integration Test is red. That does not hold: Integration Test is declared by three workflows (pr-main_l1.yaml, pr-main_l1_l2_dev.yaml, pr-main_levm.yaml), and on PR #7209's head the L1 one was failure while the L2-Dev one was success. It merged.

2. The gate still skips entirely in the queue for any non-Rust change. On merge_group both workflows set run_tests = code_changed, which matches only **/*.rs, **/*.toml, **/*.lock. PR #7193 (CI-only) merged with every job in its merge group skipped, Integration Test included, while its head carried seven real green Hive - * results the queue never read. This pull request is in that same class, so it would merge under the behavior it is fixing.

What it gets right. Dropping the needs.<job>.result != 'skipped' guards is a genuine fix on the pull_request side: run-hive and run-assertoor both need docker_build, so a broken image used to skip the gate into a green required check. I also checked and found no problems with injection (PR numbers are digits after grep -oE/tr, names are quoted, jq @tsv escapes), set -euo pipefail behavior (the empty-array guard catches the grep miss, [[ -z ... ]] && continue does not trip set -e, the here-string keeps matched/failed in scope), the --paginate --slurp shape, the (#N) title heuristic (the repo is squash-only with PR_TITLE), and the permissions: blocks, which are minimal and sufficient.

Remaining inline notes are smaller.

if [[ "$status" != "completed" ]]; then
echo "PENDING ${name} (status=${status}) is still running, so it cannot be merged yet"
failed=1
elif [[ "$conclusion" != "success" && "$conclusion" != "skipped" && "$conclusion" != "neutral" ]]; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipped is accepted unconditionally, and it is reachable with the suites genuinely not run.

Real head e104cdbccb2aad1c771ea4ab397fff8a10520f5b carries:

Build Docker                    failure
Hive - ${{ matrix.name }}       skipped
Assertoor - ${{ matrix.name }}  skipped

run-hive and run-assertoor both declare needs: [detect-changes, docker_build], so a broken image turns the whole suite into skips. Feeding that head's check runs through this loop prints All required suites passed on every queued pull request head.

Rejecting skipped outright is not the fix: an L1-only pull request legitimately shows all four L2 suites skipped (PR #7194 is exactly that), so it would become unmergeable. Telling the two apart needs a second signal, for example the head's own Integration Test / Integration Test L2 check run, or the pull_request run conclusion from /actions/runs?head_sha=....

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

95a1f6f02

The prefix loop is gone — the gate now reads its own verdict on the queued head, so skipped is no longer a suite-level signal at all.

e104cdbc no longer passes. With the != 'skipped' guards dropped, that head's Check if any job failed step sees needs.run-assertoor.result = skipped and exits 1, so the gate itself is failure. skipped still passes, but now only means this gate was not required, which is the #7194 case you point at. A dependency failure cannot produce it.

done
done <<<"$all_checks"

if [[ $matched -eq 0 ]]; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matched is one counter across every prefix, so this net only fires when all prefixes come up empty. Rename or delete a single suite and it silently stops being enforced while the others keep the gate green.

Reproduced under bash with the L2 caller's four prefixes: rename state-diff-test's job, leave it red, and the script still prints All required suites passed and exits 0.

Worth noting that none of the six suite jobs carry the # "..." is a required check, don't change the name comment that all-tests has, and the prefix strings live in a third file with no link back to them. A per-prefix counter closes this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

95a1f6f02

No prefix list remains. There is one gate name, and finding no job carrying it fails closed, so a rename cannot silently drop enforcement — and a suite added later is covered without touching the script.

"repos/${GITHUB_REPOSITORY}/commits/${pr_head}/check-runs?per_page=100" |
jq -r '[.[].check_runs[]]
| group_by(.name)
| map(max_by(.started_at // ""))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This collapses same-named check runs from different workflows, keeping whichever started later.

daily_hive_report.yaml:24 declares name: Hive - ${{ matrix.test.name }} with a Rpc Compat tests matrix entry and continue-on-error: true, and it triggers on pull_request for .github/workflows/daily_hive_report.yaml and .github/scripts/publish_hive.sh. On PR #7170's head both workflows reported:

Hive - Rpc Compat tests   success   2026-08-20T17:35:44Z   (daily_hive_report)
Hive - Rpc Compat tests   success   2026-08-20T17:53:25Z   (pr-main_l1)

Which one survives is runner scheduling. Across ten recent daily runs its hive jobs concluded 104 success, 1 failure, 5 cancelled, so this can mask a red L1 hive, or fail the gate on a cancelled that blocks nothing.

Two smaller points on the same expression: the comment above attributes the dedupe to re-triggers, but commits/{sha}/check-runs already defaults to filter=latest, so that is not what it is doing. And started_at has second granularity, so ties resolve by API array order; .id descending would at least be deterministic. Matching on the workflow or .app.id alongside the name would be better than either.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

95a1f6f02

Check runs are no longer read. Jobs come from one workflow run resolved by .path (taken from GITHUB_WORKFLOW_REF), so daily_hive_report is out of scope by construction rather than by a tie-break on started_at. The inaccurate filter=latest comment went with it.


failed=0
for pr in "${pr_numbers[@]}"; do
pr_head=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr}" --jq '.head.sha')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This resolves the pull request's head at gate time, not the SHA that was squashed into the merge group, and the merge_group payload carries no PR-head SHA to compare it against.

Every branch I could construct is fail-closed: a force-pushed head has no check runs, so matched == 0; a fresh push leaves them in_progress, so PENDING. What is lost is the property this file's header advertises, catching a result that turned red after queueing, during that window. Low priority, but a comment saying the live head is deliberate would help.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

95a1f6f02

Documented in the per-pull-request loop, including why every way the live head and the squashed commit can disagree is fail-closed.

Comment thread .github/workflows/pr-main_l1.yaml Outdated
# status check, so a gate that skips is a gate that always passes: inside the
# merge queue, where assertoor and hive do not run, that let a pull request
# whose suites were red merge on a vacuous green.
if: ${{ always() && (needs.detect-changes.result != 'success' || needs.detect-changes.outputs.run_tests == 'true') }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still skips the gate whenever run_tests is false, and on merge_group both workflows set run_tests = code_changed, which matches only **/*.rs, **/*.toml and **/*.lock. Any pull request touching only workflows, scripts, fixtures or configs keeps the old behavior.

PR #7193 (CI-only) is the worked example: merge group run 32502079792 skipped every job including Integration Test, and it merged. Its head was not untested; it carried seven green Hive - * runs, because the pull_request filter is !crates/l2/** and .github/** passes it. The queue just never read them.

This pull request changes only .github/** .yaml and .sh, so it is in that class itself.

Separately, always() also runs the job when the run is cancelled. The old expression short-circuited on outputs.run_tests == 'true' being empty, so a concurrency-cancelled run skipped the gate; now it runs and the step below exits 1, stamping a red Integration Test on superseded commits. !cancelled() && (...) avoids that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

35172eb57

The gate now runs on merge_group regardless of run_tests. A pull request that genuinely required nothing still passes, because the verdict it then reads on the head is skipped.

!cancelled() replaces always() for the reason you give: with the condition widened, a concurrency-cancelled run would otherwise run the gate and stamp a red required check on a superseded commit.

exit 1

- name: Checkout sources
uses: actions/checkout@v6

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the merge_group step uses the checked-out tree; the other two steps are inline shell over the needs context. if: ${{ github.event_name == 'merge_group' }} here would skip a full clone on every pull_request and push run of both gates.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ae2f3dd9c

Gated on merge_group in both gates.

Comment thread .github/workflows/pr-main_l1.yaml Outdated
if: ${{ github.event_name == 'merge_group' }}
env:
GH_TOKEN: ${{ github.token }}
run: ./.github/scripts/check-queued-pr-checks.sh "Hive - " "Assertoor - "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

engine-ef-tests is in this job's needs, skips in the merge queue exactly like hive and assertoor, and is not a required context on its own, but it is missing from this prefix list. PR #7194's head carries a real Engine EF tests success run, so adding the prefix closes it in one line.

The Hive - prefix also over-matches: daily_hive_report.yaml emits check runs under that same prefix on any pull request touching its trigger paths. See the note on the jq dedupe in the script.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

95a1f6f02

Both halves are subsumed rather than patched. The gate reads its own verdict, which already covers everything in its needs, so Engine EF tests cannot be omitted from a list that no longer exists; and jobs are read from one workflow run, so daily_hive_report's Hive - cannot collide.

Comment thread .github/workflows/pr-main_l2.yaml Outdated
GH_TOKEN: ${{ github.token }}
run: |
./.github/scripts/check-queued-pr-checks.sh \
"Integration Test - " \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integration Test - matches Integration Test - TDX (integration-test-tdx, line 509), but the Check if any job failed step below checks only integration-test, state-diff-test, uniswap-swap and integration-test-shared-bridge. TDX is in needs and has no continue-on-error.

So a red TDX leaves Integration Test L2 green on the pull request, the pull request is queueable, and the merge group then rejects it with nothing the author can turn green by re-running. Sampling twelve recent pr-main_l2 pull_request runs, TDX concluded 2 failure, 3 cancelled, 4 skipped, 1 success, so this is not a rare state.

Either add the tdx branch to the step below or narrow the prefix, but the two sides should agree on what is required.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

95a1f6f02

TDX stays not required, and the two sides now agree by construction: the merge group reads the pull_request gate's verdict, and that step is the only definition of what is required. Promoting a job that concluded failure or cancelled in 5 of your 12 sampled runs to a merge blocker felt like a policy change rather than a fix to this hole, so it is recorded under Not addressed here instead.

…uite check-run names.

Matching check-run names on a commit was ambiguous in four ways, each of which
let a real red result through:

- A suite `skipped` because a dependency failed was indistinguishable from one
  skipped by design. Head e104cdb had `Build Docker` = failure, which turns
  every hive and assertoor job into a skip, and the old loop reported it green.
- `matched` was one counter across all prefixes, so renaming or deleting a
  single suite silently stopped enforcing it while the others kept the gate
  green.
- `group_by(.name) | max_by(.started_at)` collapsed same-named check runs from
  different workflows. `daily_hive_report.yaml` publishes `Hive - <name>` on any
  pull request touching its trigger paths, with `continue-on-error: true`, so
  which run survived was runner scheduling.
- `Engine EF tests` was in the gate's `needs` and skips in the queue exactly
  like hive, but was missing from the prefix list, and `Integration Test - `
  matched `Integration Test - TDX`, which the pull_request side deliberately
  does not require.

The gate now resolves the latest `pull_request` run of its own workflow on each
queued pull request's head, taking the workflow path from `GITHUB_WORKFLOW_REF`
so it cannot drift, and reads the verdict of this same gate job inside that run.
That leaves one definition of what is required — the `Check if any job failed`
step — so the two sides cannot disagree about TDX or about any suite added
later, and an unrelated workflow's identically named job is out of scope by
construction.

`skipped` still passes, but now means something checkable: the gate itself was
not required, which is what an L1-only pull request looks like to the L2
workflow. A dependency failure no longer produces it, because dropping the
`needs.<job>.result != 'skipped'` guards makes the gate run and fail in that
case. A run that is not yet completed fails, which is the case this exists for:
re-running a suite bumps the run attempt, so a re-run in flight blocks the merge
group instead of being bypassed.

Resolving the pull request's live head rather than the squashed commit is
deliberate and now documented in the script: the merge_group payload carries no
pull request head to compare against, reading the newest head is what catches a
result that turned red after queueing, and every way the two can disagree is
fail-closed.
… decided.

`run_tests` on `merge_group` is `code_changed`, which matches only `**/*.rs`,
`**/*.toml` and `**/*.lock`. Any pull request touching just workflows, scripts,
fixtures or configs therefore still skipped the gate in the queue and turned
both required checks green with nothing consulted — the exact hole this branch
set out to close, and the class this branch itself belongs to. PR #7193 is the
worked example: its merge group skipped every job, `Integration Test` included,
and it merged, while its head carried seven green `Hive - *` results the queue
never read.

The gate now runs on `merge_group` regardless. A pull request whose changes
genuinely required nothing still passes, because the verdict it reads on the
head is then `skipped`.

`!cancelled()` replaces `always()` at the same time. The old expression
short-circuited on an empty `outputs.run_tests`, so a concurrency-cancelled run
skipped the gate; with the condition widened it would instead run and fail,
stamping a red required check on a commit that has already been superseded.
Only the `merge_group` step uses the checked-out tree; the pull_request and push
paths evaluate the `needs` context in inline shell and need nothing from disk.
Both gates cloned the repository on every run regardless.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client L2 Rollup client

Projects

Status: No status
Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants