Skip to content

[ci-gap][ci-health] Add merge-queue (merge_group) gating and post-merge main verification to close the logical-merge-conflict hole #185

Description

@claude

Current state

Coverage 3/5, enforcement 4/5 → target 5/5.

Every gating workflow in this repo runs only on pull_request targeting main — verified across all 21 files in .github/workflows/: static-testing.yml, unit-testing.yml, bats-testing.yml, dependency-cruiser.yml, eslint-suppressions.yml, mutation-testing.yml, e2e-testing.yml, etc. all declare on: pull_request: branches: ['main'] and nothing else. No workflow anywhere in the repo has a merge_group, schedule, or test-running push: branches: [main] trigger.

The only workflow that fires on push:main is autorelease.yml ("generate changelog and create release") — and it runs zero tests: it generates a token, checks out, and cuts a changelog/release. So the moment anything lands on main, the very next automated action is a release from a tree nothing has verified.

Why PR-only gating is insufficient: checks run against the PR branch head, not the true merge result. Two individually-green PRs can compose into a broken main (a logical/semantic merge conflict), and nothing in this repo ever tests the merged state. Even if "require branches to be up to date" is enabled in branch protection (unverifiable from the repo — the protection API returns 403 and /rulesets returns [], so protection is classic-config only and invisible), that setting merely serializes merges; it does not re-run checks on the actual merge commit the way a merge queue does.

Defect class prevented

Semantically conflicting green PRs breaking main undetected — with the breakage misattributed and immediately released.

Concretely: PR A renames an exported symbol; PR B, branched before A merged, consumes the old name. Both are individually green (each was tested against a main that made it valid). Both merge. main now fails make lint-tsc — but nothing runs on push:main except autorelease.yml, which happily cuts a changelog/release from the broken tree. The breakage surfaces only when the next unrelated PR's checks go red on code its author never touched, burning that contributor's time on misattributed triage while main (and the latest release) stay broken.

Evidence

  • 085caf1 "fix(ci): bump python3 alpine pin to 3.12.13-r0" — Alpine's package repo rolled python3 forward and deleted the old version; per the commit message this broke every Docker-based CI job (static, unit, integration, dep-cruiser, e2e, mutation, memory-leak, perf, visual) with unable to select packages: python3-3.12.13-r0 breaks world[python3=3.12.12-r0]. The outage was discovered reactively when unrelated PR pipelines went red, and the same fix had to be re-applied inside the 0706f61 main hot-patch ("fix: update alpine python package pin"). With no scheduled or push:main verification run anywhere, the first signal of a repo-wide red state was an innocent contributor's PR failing on code they didn't touch — a main verification run would have surfaced and attributed the breakage immediately. (Verified via git show 085caf1 and the Dockerfile FROM node:24.8.0-alpine3.21 tag-only pin.)
  • Issue Fix qlty CI check in main #79 / PR Fix qlty CI check in main #80 (merged 2026-05-17, "Fix qlty CI check in main") — the qlty quality check went red on the default branch itself and stayed red until a dedicated repair PR landed, touching 30 files: 16 workflow YAMLs, .markdownlint.yaml, docker-compose.test.yml, common-healthchecks.yml, two Dockerfiles, three scripts under scripts/, the apollo-server bootstrap, and src/ components. Drift accumulated and merged green through PRs until main's own signal broke — exactly the "main broken, discovered later, misattributed" hole this issue closes.

No OpenSSF Scorecard baseline datum applies directly to merge-time verification; the gap is grounded in the incidents above and the verified absence of any merge_group / push:main / schedule test trigger.

Proposed check

Phase 1 (S — land immediately and independently): post-merge main verification. Add .github/workflows/main-verification.yml on push: branches: [main] running make start && make lint && make test-unit-all && make codegen-check (all existing Makefile targets), with a failure path that opens or updates a single pinned "main is red" issue — it must query for an existing open issue with the main-is-red label first, so consecutive red pushes update one issue instead of spamming. Use concurrency: group: main-verification with cancel-in-progress: false so back-to-back merges are each verified in order, none skipped.

Important scoping: Phase 1 detects but does not prevent autorelease.yml cutting a release from a broken tree — both workflows fire concurrently on push:main. Sell Phase 1 as detection + attribution, not release protection. Sequencing autorelease behind verification (e.g. workflow_run on main-verification success, or folding the verification steps ahead of the changelog step) depends on issue #138's release-repair work — add that as an explicit follow-up dependency.

Phase 2 (M — gated on the #141 branch-protection policy work; do not block Phase 1 on it): merge queue. Enable the repository merge queue and add merge_group: to the on: block of the fast, deterministic gate workflows only: static-testing.yml, unit-testing.yml, bats-testing.yml, eslint-suppressions.yml, dependency-cruiser.yml. Do NOT add mutation-testing.yml — it is a 4-way sharded matrix with an ~1h wall-clock path (per CLAUDE.md's "CI parallelization"); queuing it would add that latency plus 4x runner cost to every merge. Consequence to handle in #141: if mutation-testing remains a required branch-protection check unscoped by context, the merge queue will wait forever for a check that never reports on merge_group — the #141 coordination must explicitly scope required checks per context (mutation stays PR-required only). Keep the slow perf suites (performance-testing.yml, load-testing.yml, memory-leak-testing.yml) PR-only. Update concurrency groups so queue runs are not cancelled by PR pushes.

# .github/workflows/main-verification.yml  (Phase 1)
name: main verification
on:
  push:
    branches: ['main']
permissions:
  contents: read
  issues: write
concurrency:
  group: main-verification
  cancel-in-progress: false   # verify every merge, in order
jobs:
  verify:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - run: make start
      - run: make lint
      - run: make test-unit-all
      - run: make codegen-check
      - name: Open or update pinned "main is red" issue
        if: failure()
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          existing=$(gh issue list --label main-is-red --state open --json number --jq '.[0].number')
          run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
          if [ -n "$existing" ]; then
            gh issue comment "$existing" --body "Still red @ ${GITHUB_SHA} — ${run_url}"
          else
            gh issue create --title "main is red @ ${GITHUB_SHA}" \
              --label main-is-red --label ci-health \
              --body "Post-merge verification failed on main: ${run_url}"
          fi

# Phase 2 — add to static-testing.yml, unit-testing.yml, bats-testing.yml,
# eslint-suppressions.yml, dependency-cruiser.yml (NOT mutation-testing.yml):
on:
  pull_request:
    branches: ['main']
  merge_group:
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.merge_group.head_ref || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Benchmark: grafana/grafana and getsentry/sentry gate merges through GitHub merge queues; rust-lang pioneered never-break-main with bors (test the merge commit, not the branch); mui/material-ui runs CI on push to master/next so a broken default branch is detected and attributed within minutes.

Overlap note: issue #141 tracks documenting/drift-checking branch protection and required checks — Phase 2's merge-queue enablement and the required-check context scoping land inside that governance change, but #141 nowhere proposes merge_group triggers or post-merge main verification, so this gap is distinct. Sequencing autorelease behind verification depends on issue #138.

Effort

M — Phase 1 is a single new workflow reusing existing Make targets (S on its own); Phase 2 requires merge-queue settings, on:/concurrency edits across five workflows, and required-check context scoping coordinated with #141.

Acceptance criteria

  • .github/workflows/main-verification.yml exists and runs make lint, make test-unit-all, and make codegen-check on every push to main, with concurrency: main-verification / cancel-in-progress: false, and its failure path opens or updates a single open main-is-red issue (verified: two consecutive red pushes produce one issue, not two); Phase 2: merge queue is enabled and merge_group: triggers report on static-testing, unit-testing, bats-testing, eslint-suppressions, and dependency-cruiser (and not on mutation-testing).
  • The check is blocking for merge: the five fast workflows are required checks in the merge-queue context per the Establish security governance baseline: SECURITY.md, truthful README claims, CODEOWNERS, branch-protection policy #141 policy (mutation-testing scoped to PR context only), and until Phase 2 lands, main-verification failures page loudly via the pinned issue so a red main cannot persist silently.
  • The check has failed at least once on a deliberately seeded defect — e.g. merge two individually-green branches with a rename/consumer conflict (or push a commit that breaks make lint-tsc) and confirm main-verification goes red and files/updates the main-is-red issue; for Phase 2, confirm a queue run rejects a PR that is green on its branch but red against the speculative merge result.

Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    ciCI/CD pipelinesci-healthCI pipeline health, flakes, runtime, thresholdsdeveloper-experienceDeveloper experienceenhancementNew feature or requestnfrNon-functional requirementpriority-mediumMedium priorityqualityQuality engineering / verification coverage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions