name: Check merge_group trigger on required workflows # Pre-merge guard against the deadlock pattern where a workflow whose # check is in `required_status_checks` lacks a `merge_group:` trigger. # Without it, GitHub merge queue stalls forever in AWAITING_CHECKS # because the required check can't fire on `gh-readonly-queue/...` refs. # # This workflow: # 1. Lists required status checks on the branch protection rule for `staging` # 2. For each required check, finds the workflow that produces it (by job # name match) # 3. Fails if any such workflow lacks `merge_group:` in its triggers # # Reasoning for staging-only: main has its own CI gating model (PR review), # but staging is what the merge queue runs on, so it's the trigger that # matters. on: pull_request: paths: - '.github/workflows/**.yml' - '.github/workflows/**.yaml' push: branches: [staging, main] paths: - '.github/workflows/**.yml' - '.github/workflows/**.yaml' # Self-listen on merge_group so the linter passes its own queue run. merge_group: types: [checks_requested] jobs: check: name: Required workflows have merge_group trigger runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Verify merge_group trigger on required-check workflows env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} shell: bash run: | set -euo pipefail # Branch we care about — the one merge queue runs on. BRANCH=staging # Pull the list of required status check contexts. If the branch # has no protection or no required checks, exit clean — nothing # to lint. REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \ --jq '.contexts[]' 2>/dev/null || true) if [ -z "$REQUIRED" ]; then echo "No required status checks on ${BRANCH} — nothing to verify." exit 0 fi echo "Required checks on ${BRANCH}:" echo "${REQUIRED}" | sed 's/^/ - /' echo # Build a map: workflow file -> set of job names declared in it. # We use yq if available, otherwise grep the `name:` lines under # `jobs:`. Stick with grep for portability — runner image always # has it; yq isn't in the default image as of 2026-04. declare -A workflow_jobs shopt -s nullglob for wf in .github/workflows/*.yml .github/workflows/*.yaml; do [ -f "$wf" ] || continue # Extract the workflow name (the `name:` at file root). wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf") # Extract job step names from the `jobs:` block. A job step is: # - id under `jobs:` (key with 2-space indent followed by colon) # - the `name:` field inside that job (4-space indent) # We collect both because required_status_checks contexts can # match either, depending on how the workflow was authored. jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf") job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}') workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}" done # For each required check, find the workflow that produces it. # Then verify that workflow lists merge_group as a trigger. FAILED=0 while IFS= read -r check; do [ -z "$check" ] && continue owning_wf="" for wf in "${!workflow_jobs[@]}"; do if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then owning_wf="$wf" break fi done if [ -z "$owning_wf" ]; then echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)." continue fi # Does the workflow's trigger list include merge_group? # Match either bare `merge_group:` line or merge_group with # subsequent indented config (types: [checks_requested]). if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then echo "OK: '${check}' (in $owning_wf) — has merge_group trigger" else echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:" echo "::error file=${owning_wf}:: merge_group:" echo "::error file=${owning_wf}:: types: [checks_requested]" FAILED=1 fi done <<< "$REQUIRED" if [ "$FAILED" -ne 0 ]; then echo echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')." exit 1 fi echo echo "All required workflows on ${BRANCH} declare merge_group triggers."