From 0cc4c2da8cc9d742a8c9cb91519562325f58222a Mon Sep 17 00:00:00 2001 From: coseto6125 <80243681+coseto6125@users.noreply.github.com> Date: Sat, 23 May 2026 21:06:14 +0800 Subject: [PATCH 1/3] fix(mergify): migrate routing from pull_request_rules to queue_conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: every PR satisfying the routing rules sat at 'Mergify Merge Queue / completed / neutral / Merge queue is ready' indefinitely, never embarking. Manual '@Mergifyio queue main-docs-only' on PR #386 worked immediately, proving queue_rules themselves are wired up. Root cause: in modern Mergify, pull_request_rules with a 'queue' action is an EXPLICIT-enqueue mechanism (for commands / advanced filters). It does not auto-route based on its 'conditions:' block. Auto-routing lives in queue_rules.queue_conditions: Mergify evaluates each PR against every queue_rule's queue_conditions and embarks it into the first match. Fix: move the routing conditions (base, labels, required-checks) from each pull_request_rule into the corresponding queue_rule's queue_conditions, and drop pull_request_rules entirely. The queue_rule order matters — most-specific (area=test/parser/cli/docs) before the fallback (no area label) so the catch-all doesn't grab specialized PRs. --- .mergify.yml | 131 +++++++++++++++++++++------------------------------ 1 file changed, 53 insertions(+), 78 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 0a0f4485..16be8da0 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,58 +1,26 @@ # Mergify config. # -# Per Mergify best practice: -# - queue_rules pin `base=` so cross-target-branch PRs can't batch together -# (Mergify speculative-trial would re-base each onto the wrong tip). -# - One pull_request_rule per (area, base) pair selects the target queue -# explicitly; without an explicit routing rule, Mergify can't know which -# area-specific queue a PR belongs in. -# - Priority is set declaratively per rule via `queue.priority`, so the -# risk-low PRs land at the front of their area queue. +# Routing pattern (modern Mergify, post-2024): +# - Each queue_rule carries its own `queue_conditions` — labels, base, and +# required-check list a PR must satisfy to auto-enter THIS queue. +# - `merge_conditions` is what gates the actual merge once embarked. +# - No `pull_request_rules` needed for routing: Mergify checks each PR +# against every queue's `queue_conditions` and embarks it into the first +# match. Order queue_rules from most-specific to least-specific. # -# The required-check list is duplicated across routing rules — YAML anchors -# splice nested lists which Mergify rejects, so DRY isn't available here. -# Keep the list synced with `gh api repos/.../branches/main/protection`. +# Why not pull_request_rules.queue? +# - That action exists for EXPLICIT enqueuing (commands, advanced filters), +# not auto-routing. With only pull_request_rules, the Mergify Merge Queue +# check stays at "Merge queue is ready" without ever embarking — confirmed +# on PR #386 (manual `@Mergifyio queue` worked, auto did not). # -# Adding a new target branch (e.g. `develop`): replicate the 5 queue_rules -# under `develop-*` names + 5 routing pull_request_rules with `base=develop`. +# The required-check list is duplicated across queue_conditions — YAML +# anchors splice nested lists which Mergify rejects, so DRY isn't available. +# Keep the list synced with `gh api repos/.../branches/main/protection`. queue_rules: - name: main-test-only - merge_conditions: - - base=main - - check-success=ecp/cross-pr-conflict - batch_size: 10 - batch_max_wait_time: 30s - - - name: main-parser-changes - merge_conditions: - - base=main - - check-success=ecp/cross-pr-conflict - batch_size: 1 - - - name: main-cli-changes - merge_conditions: - - base=main - - check-success=ecp/cross-pr-conflict - batch_size: 3 - batch_max_wait_time: 2m - - - name: main-docs-only - merge_conditions: - - base=main - - check-success=ecp/cross-pr-conflict - batch_size: 20 - batch_max_wait_time: 10s - - - name: main-default - merge_conditions: - - base=main - - check-success=ecp/cross-pr-conflict - batch_size: 2 - -pull_request_rules: - - name: route area-test to main-test-only - conditions: + queue_conditions: - base=main - label=merge-queue - label=ecp:area-test @@ -62,12 +30,14 @@ pull_request_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review - actions: - queue: - name: main-test-only + merge_conditions: + - base=main + - check-success=ecp/cross-pr-conflict + batch_size: 10 + batch_max_wait_time: 30s - - name: route area-parser to main-parser-changes - conditions: + - name: main-parser-changes + queue_conditions: - base=main - label=merge-queue - label=ecp:area-parser @@ -77,12 +47,13 @@ pull_request_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review - actions: - queue: - name: main-parser-changes + merge_conditions: + - base=main + - check-success=ecp/cross-pr-conflict + batch_size: 1 - - name: route area-cli to main-cli-changes - conditions: + - name: main-cli-changes + queue_conditions: - base=main - label=merge-queue - label=ecp:area-cli @@ -92,12 +63,14 @@ pull_request_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review - actions: - queue: - name: main-cli-changes + merge_conditions: + - base=main + - check-success=ecp/cross-pr-conflict + batch_size: 3 + batch_max_wait_time: 2m - - name: route area-docs to main-docs-only - conditions: + - name: main-docs-only + queue_conditions: - base=main - label=merge-queue - label=ecp:area-docs @@ -107,12 +80,16 @@ pull_request_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review - actions: - queue: - name: main-docs-only + merge_conditions: + - base=main + - check-success=ecp/cross-pr-conflict + batch_size: 20 + batch_max_wait_time: 10s - - name: route default to main-default - conditions: + # Fallback queue — catches PRs with `merge-queue` label but no area label. + # Must come last; queue_conditions match via negation on the area-label set. + - name: main-default + queue_conditions: - base=main - label=merge-queue - -label~=^ecp:area- @@ -122,14 +99,12 @@ pull_request_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review - actions: - queue: - name: main-default + merge_conditions: + - base=main + - check-success=ecp/cross-pr-conflict + batch_size: 2 - # Priority overrides removed in this PR. - # Modern Mergify exposes priority via `queue_rules.priority_rules` (per-queue - # nested rule list), not via `actions.queue.priority` on pull_request_rules. - # Implementing it cleanly requires duplicating priority_rules across all 5 - # queue_rules (no shared anchor since Mergify YAML rejects nested-list - # splice). Tracked in FU-2026-05-23-037 — to be added when priority gets - # observed value (i.e. when same-area queue actually backs up). +# Priority overrides: deferred to FU-2026-05-23-037. +# Modern Mergify exposes priority via `queue_rules.priority_rules` (per-queue +# nested rule list). Adding it cleanly requires duplicating priority_rules +# across all 5 queue_rules. Implement when same-area queue actually backs up. From b81228af8dbf65e91ee6e8f0b30c3435451c8585 Mon Sep 17 00:00:00 2001 From: coseto6125 <80243681+coseto6125@users.noreply.github.com> Date: Sat, 23 May 2026 21:14:25 +0800 Subject: [PATCH 2/3] fix(ci): drop ci:run label gate (replaced by merge-queue label) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci:run predated Mergify and served as a manual opt-in to fire the Test matrix on PRs (the GitHub-personal-repo substitute for self-approve, when no merge queue existed). With Mergify queue_conditions taking over the merge gate, ci:run became redundant AND actively harmful: - Mergify queue_conditions require check-success=Test (all platforms), but Test would only fire after the label landed → chicken/egg. - Toggling ci:run between pushes triggered cancellation races with in-flight CI workflows (confirmed via the t-mergify-* test PRs). Remove: - 'labeled' / 'unlabeled' from pull_request event types (no longer needed to trigger workflow runs). - The contains(...ci:run) check from the test job's if-guard. Test now fires unconditionally on PRs; detect-changes still short-circuits the matrix to skipped for docs-only changes. - The PR-skipped FAIL branch in the test-result aggregator — skip only happens via [skip ci] now, which is a legitimate opt-out. The new merge gate is the 'merge-queue' label (applied by author when ready), evaluated by Mergify queue_conditions alongside the green-check requirement. --- .github/workflows/ci.yml | 42 ++++++++++------------------------------ 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d04adce1..16bb3f5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,6 @@ on: push: branches: [main] pull_request: - # `labeled` / `unlabeled` listed so adding the `ci:run` label fires a - # workflow run. Defaults [opened, synchronize, reopened] must be re- - # declared because specifying `types:` drops the implicit defaults. - types: [opened, synchronize, reopened, labeled, unlabeled] branches: [main] # Merge queue: GitHub builds a temporary `gh-readonly-queue/main/...` branch # combining PRs + main, and runs CI on that combined state. Without this @@ -199,25 +195,14 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - # PR-time test gating: the author opts in by adding the `ci:run` label. - # Pattern is the GitHub-personal-repo substitute for merge queue / self- - # approve (PR authors can't approve their own PRs). Once labeled, - # subsequent `synchronize` events keep firing test as long as the label - # is present; remove the label to pause test on further pushes. - # - # Non-PR events (push:main / merge_group / workflow_dispatch) always run - # test — they're the post-merge / pre-merge-queue / manual paths and have - # no opt-in semantic. - # - # test-result aggregator below distinguishes PR-skipped (blocking — author - # forgot the label) from non-PR-skipped (allowed — docs-only short-circuit - # on push:main is legitimate). + # Test fires unconditionally on every PR + push + merge_group. Author + # opt-in via `ci:run` label was removed once Mergify queue_conditions + # took over the merge gate — the label dance conflicted with auto- + # routing (queue needs Test green; label toggle to fire Test caused + # cancellation races) and was redundant with `detect-changes` already + # skipping the test job for docs-only changes. if: | !(contains(github.event.pull_request.title, '[skip ci]') || contains(github.event.head_commit.message, '[skip ci]')) - && ( - github.event_name != 'pull_request' - || contains(github.event.pull_request.labels.*.name, 'ci:run') - ) env: # 14 tree-sitter parsers × 30+ test binaries × debug-info defaulted to # `true` produced ~30 GB in target/debug/deps/ and filled the ubuntu @@ -306,11 +291,8 @@ jobs: # # Skip-handling rules: # - `success` → pass (real green) - # - `skipped` on non-PR event → pass (docs-only short-circuit on push:main - # is legitimate; nothing to validate) - # - `skipped` on PR event → FAIL (author opted out by not adding `ci:run` - # label, but branch protection requires test green before merge — this - # forces the label to come on before merge can happen) + # - `skipped` → pass (only possible via [skip ci] in title/message; legit + # opt-out, gate is title-prefix not label) # - anything else (failure / cancelled) → FAIL test-result: name: Test (all platforms) @@ -327,13 +309,9 @@ jobs: if [ "$TEST_RESULT" = "success" ]; then exit 0 fi - if [ "$TEST_RESULT" = "skipped" ] && [ "$EVENT_NAME" != "pull_request" ]; then - echo "✓ Skipped on $EVENT_NAME — docs-only short-circuit OK" - exit 0 - fi if [ "$TEST_RESULT" = "skipped" ]; then - echo "::error::Test was skipped on PR — add 'ci:run' label to fire it before merge" - exit 1 + echo "✓ Skipped — [skip ci] in commit/title" + exit 0 fi echo "::error::At least one test shard failed or was cancelled" exit 1 From 083bafa72d2ede54b7c2618bfb4fe8303c6c43f7 Mon Sep 17 00:00:00 2001 From: coseto6125 <80243681+coseto6125@users.noreply.github.com> Date: Sat, 23 May 2026 21:23:16 +0800 Subject: [PATCH 3/3] fix(mergify): move cross-pr-conflict to queue_conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #386 entered main-docs-only then immediately dequeued with 'Checks failed' — Mergify's required-conditions box showed `check-success=ecp/cross-pr-conflict` still unchecked even though the PR's HEAD commit had that status as success. Cause: Mergify's speculative trial creates a fresh commit (PR diff + main tip). merge_conditions are evaluated on THAT commit, not the PR's HEAD. ecp-pr-analyze.yml triggers only on `pull_request` events, so it never fires against the trial commit → no cross-pr-conflict status ever reports → merge_conditions blocked → dequeue. Move the check from merge_conditions into queue_conditions. The question 'is it safe to queue this PR?' is evaluated on the PR HEAD, where the status exists. Once queued, Mergify's own speculative CI (branch protection's required checks running on the trial commit) is the merge gate. --- .mergify.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 16be8da0..8c8a24cf 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -17,6 +17,15 @@ # The required-check list is duplicated across queue_conditions — YAML # anchors splice nested lists which Mergify rejects, so DRY isn't available. # Keep the list synced with `gh api repos/.../branches/main/protection`. +# +# Where does `ecp/cross-pr-conflict` belong? +# queue_conditions, NOT merge_conditions. Mergify's speculative trial +# creates a fresh commit (PR diff + main tip) — ecp-pr-analyze.yml +# triggers on `pull_request` events only, so the trial commit has no +# cross-pr-conflict status reported against it. Anything required in +# merge_conditions would thus block the trial indefinitely → dequeue +# (PR #386 was the live repro). Keeping the check in queue_conditions +# evaluates it on the PR HEAD where the status actually exists. queue_rules: - name: main-test-only @@ -30,9 +39,9 @@ queue_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review + - check-success=ecp/cross-pr-conflict merge_conditions: - base=main - - check-success=ecp/cross-pr-conflict batch_size: 10 batch_max_wait_time: 30s @@ -47,9 +56,9 @@ queue_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review + - check-success=ecp/cross-pr-conflict merge_conditions: - base=main - - check-success=ecp/cross-pr-conflict batch_size: 1 - name: main-cli-changes @@ -63,9 +72,9 @@ queue_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review + - check-success=ecp/cross-pr-conflict merge_conditions: - base=main - - check-success=ecp/cross-pr-conflict batch_size: 3 batch_max_wait_time: 2m @@ -80,9 +89,9 @@ queue_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review + - check-success=ecp/cross-pr-conflict merge_conditions: - base=main - - check-success=ecp/cross-pr-conflict batch_size: 20 batch_max_wait_time: 10s @@ -99,9 +108,9 @@ queue_rules: - check-success=Lint GitHub Actions workflows - check-success=CodeQL - check-success=dependency-review + - check-success=ecp/cross-pr-conflict merge_conditions: - base=main - - check-success=ecp/cross-pr-conflict batch_size: 2 # Priority overrides: deferred to FU-2026-05-23-037.