diff --git a/.github/workflows/check-merge-group-trigger.yml b/.github/workflows/check-merge-group-trigger.yml new file mode 100644 index 00000000..77f4c7b3 --- /dev/null +++ b/.github/workflows/check-merge-group-trigger.yml @@ -0,0 +1,123 @@ +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@v4 + - 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."