From 1720c6711f86f0113efd085887f374ad51c93e08 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:13:30 -0700 Subject: [PATCH] ci(commitlint): make the PR-title check report on merge_group for the queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling the merge queue requires every required status check to also report on the `merge_group` event. The Conventional-Commits PR-title check only ran on `pull_request` (it reads `pull_request.title`), so as a required check it would deadlock every queued PR — which is why it had to be dropped from the required set when the queue was enabled. This restores it as a hard gate, queue-compatible: on `merge_group` the job recovers the PR number from the queue ref (`gh-readonly-queue//pr--`), fetches that PR's title, and re-lints it against the same Conventional-Commits pattern — the squash subject that will actually land on main. The pull_request path (sticky advisory comment, fast author feedback) is unchanged; the merge_group path is a pass/fail gate with no comment. The check context name is unchanged, so it can be re-added to the ruleset's required set. The queue is configured for single-PR groups (max_entries_to_build / max_entries_to_merge = 1) so the ref always names exactly one PR. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/commitlint.yml | 56 ++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index abef85faa..6af627355 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -75,6 +75,13 @@ on: # `reopened` covers the reopen-after-close case so a closed-then- # reopened PR re-evaluates fresh. types: [opened, edited, synchronize, reopened] + # `merge_group` makes this a queue-compatible REQUIRED check: GitHub's + # merge queue requires every required status check to also report on + # the `merge_group` event, but that event carries no PR title. We + # recover the PR from the queue ref (`gh-readonly-queue//pr-- + # `) and re-lint its title (the squash subject that will land). + # Without this, a title-required check would deadlock the queue. + merge_group: # Comment posting requires write access to the PR (which is the # `pull-requests` scope, not `contents`). Read on contents is implicit @@ -87,7 +94,10 @@ permissions: # arrives. Title edits land in bursts (typo → realize → fix); we only # care about the latest state. concurrency: - group: commitlint-${{ github.event.pull_request.number }} + # Keyed per-PR on `pull_request`, and per-queue-ref on `merge_group` + # (each merge-group ref is unique, so queue validations never cancel + # one another — only successive title edits on the same PR do). + group: commitlint-${{ github.event.pull_request.number || github.event.merge_group.head_ref }} cancel-in-progress: true jobs: @@ -98,15 +108,23 @@ jobs: # Skip on PRs from forks: forked PRs don't have write tokens, so # `gh pr comment` would fail. Forks are advised separately via the # CONTRIBUTING.md docs; they get a hint from the PR template instead. - if: github.event.pull_request.head.repo.full_name == github.repository + # `merge_group` always originates from an in-repo PR that already + # reached the queue, so it is never a fork — let it through. + if: github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository steps: - name: Validate PR title against Conventional Commits env: # `pull_request.title` is the canonical squash-merge subject. # We deliberately do NOT inspect commits on the branch — those # get squashed away on merge and are noise for release-plz. + # (Empty on `merge_group`; that path derives the title below.) TITLE: ${{ github.event.pull_request.title }} PR_NUMBER: ${{ github.event.pull_request.number }} + # Which event fired — drives the title-source branch below. + EVENT_NAME: ${{ github.event_name }} + # Queue ref on `merge_group`, e.g. + # `gh-readonly-queue/main/pr-472-`; empty on pull_request. + MERGE_GROUP_REF: ${{ github.event.merge_group.head_ref }} # `github.token` is the workflow's ephemeral GITHUB_TOKEN. # The `gh` CLI auto-detects this env var. GH_TOKEN: ${{ github.token }} @@ -126,6 +144,40 @@ jobs: # commit_parsers in Phase R2). PATTERN='^(feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert)(\([a-z0-9-]+\))?!?: .{1,}$' + # ── Merge-queue path (merge_group event) ─────────────────── + # The merge_group event has no PR payload, so recover the PR + # number from the queue ref and re-lint its title (the squash + # subject that will land on main). No sticky comment here — + # that already happened at PR-open time on the pull_request + # run; the queue only needs a pass/fail gate. The queue is + # configured for single-PR groups (max_entries_to_{build, + # merge}=1), so the ref always names exactly one PR. + if [ "${EVENT_NAME}" = "merge_group" ]; then + PR_NUMBER=$(printf '%s' "${MERGE_GROUP_REF}" \ + | sed -nE 's#.*/pr-([0-9]+)-[0-9a-fA-F]+$#\1#p') + if [ -z "${PR_NUMBER}" ]; then + echo "::error::cannot parse a PR number from merge_group ref '${MERGE_GROUP_REF}'" + exit 1 + fi + TITLE=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json title --jq .title) + { + echo "## 📝 Commitlint — queued PR title check" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| PR | #${PR_NUMBER} |" + echo "| Title | \`${TITLE}\` |" + } >> "$GITHUB_STEP_SUMMARY" + if printf '%s' "${TITLE}" | grep -qE "${PATTERN}"; then + echo "::notice::queued PR #${PR_NUMBER} title conforms: ${TITLE}" + echo "| Result | ✅ conforms |" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "::error::queued PR #${PR_NUMBER} title does not conform: ${TITLE}" + echo "| Result | 🔴 does not conform — blocking the queue |" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + # Group redirects to avoid the SC2129 shellcheck warning # and minimise filesystem churn on the GitHub Actions side. {