name: Merge Ready # Posts the "Merge Ready" commit status on the PR head SHA -- the single # required branch-protection check, backed by the REQUIRED list inside # this workflow. Triggers: `/merge` comment (write-access commenter only), # `pull_request_target` labeled (acts only with `automerge`), # `workflow_run` on CI completion (same-repo AND fork PRs -- ctx resolves the # PR from the head SHA), and `workflow_dispatch` (programmatic/manual # re-evaluation of one PR). Posted via the REST API (not the job's implicit # check run) so the status lands on the PR head SHA, since these jobs run on # the default branch. # # Labels: # automerge enable GitHub auto-merge (one-shot on label add) + opt # into continuous gate updates (green AND red). # # There is no CI bypass label. To land a PR despite red required checks, # fix or delete the offending test; for a genuine emergency, a repo admin # uses GitHub's native "merge without waiting for requirements" affordance # (branch protection has enforce_admins=false). on: # `labeled` only; `workflow_run` re-evaluates on CI completion for all PRs # (same-repo and fork -- ctx resolves the PR from the head SHA). # pull_request_target (not pull_request) so this workflow always runs from # main -- a PR cannot modify the gate logic by editing this file. pull_request_target: types: [labeled] workflow_run: workflows: [PR Template, CI, Lint, Docker build, E2E UI Tests, E2E Tests, Integration Tests] types: [completed] issue_comment: types: [created] # Programmatic / manual re-evaluation of a single PR. workflow_dispatch: inputs: pr: description: PR number to (re)evaluate. required: true type: string sha: description: Head SHA to post on (defaults to the PR's current head). required: false type: string # Read-only at top level; write scopes live on the job below. permissions: contents: read concurrency: group: merge-ready-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr || github.event.workflow_run.head_sha }} cancel-in-progress: true jobs: evaluate: name: evaluate permissions: contents: write # enable PR merge for `/merge` pull-requests: write # post comments + enable auto-merge checks: read actions: read # evaluate-checks.sh reads GET /actions/runs to classify missing checks statuses: write # Fire on automerge label adds, PR CI workflow_run completions (same-repo # and fork), `/merge` comments, or a workflow_dispatch re-eval. Runs with no # open PR (push to main, etc.) are dropped by the ctx step. if: >- ( github.event_name == 'pull_request_target' && github.event.label.name == 'automerge' ) || ( github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' ) || github.event_name == 'workflow_dispatch' || ( github.event_name == 'issue_comment' && github.event.issue.pull_request != null && contains(github.event.comment.body, '/merge') && !endsWith(github.actor, '[bot]') && ( github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' ) ) runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check out scripts uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: main # trusted gate scripts; never the PR head sparse-checkout: .github/scripts/merge-ready persist-credentials: false - name: Resolve PR + head SHA id: ctx env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} # Via env, not interpolated: author-controlled, so direct # interpolation would be a shell-injection vector. WF_PRS: ${{ toJSON(github.event.workflow_run.pull_requests) }} COMMENT_BODY: ${{ github.event.comment.body }} PR_INPUT: ${{ inputs.pr }} SHA_INPUT: ${{ inputs.sha }} run: | # Resolve the open PR from a head SHA -- fork-PR events leave the # payload's pull_requests array empty (cross-repo). Use the search # API, not GET /commits/{sha}/pulls: that endpoint does not associate # a fork PR's head commit (it lives in the fork, not this repo), so it # returns nothing for every fork PR and the gate silently skips them. # The search index covers fork-PR head SHAs. resolve_pr_from_sha() { gh api "search/issues?q=repo:$REPO+type:pr+state:open+sha:$1" \ --jq '.items[0].number // empty' 2>/dev/null || true } if [[ "${{ github.event_name }}" == "pull_request_target" ]]; then PR="${{ github.event.pull_request.number }}" SHA="${{ github.event.pull_request.head.sha }}" elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then # PR_INPUT is dispatcher-controlled; validate before shell use. if ! [[ "$PR_INPUT" =~ ^[0-9]+$ ]]; then echo "::error::workflow_dispatch input 'pr' must be a PR number" exit 1 fi PR="$PR_INPUT" if [[ "$SHA_INPUT" =~ ^[0-9a-f]{7,40}$ ]]; then SHA="$SHA_INPUT" else SHA=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid') fi elif [[ "${{ github.event_name }}" == "issue_comment" ]]; then # The job `if` contains() pre-filter also fires on incidental # mentions; re-validate `/merge` as a command (first non-space # token on a line is exactly `/merge`, optional args). if ! grep -qE '^[[:space:]]*/merge([[:space:]]|$)' <<<"$COMMENT_BODY"; then echo "::notice::Skipped: comment mentions '/merge' but not as a command" echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi PR="${{ github.event.issue.number }}" SHA=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq '.headRefOid') else PR=$(echo "$WF_PRS" | jq -r '.[0].number // empty') SHA="${{ github.event.workflow_run.head_sha }}" [[ -z "$PR" ]] && PR=$(resolve_pr_from_sha "$SHA") if [[ -z "$PR" ]]; then echo "::notice::Skipped: workflow_run has no associated PR (push to main, etc)" echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi fi echo "pr=$PR" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT" - name: Read PR labels id: labels if: steps.ctx.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ steps.ctx.outputs.pr }} run: | NAMES=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name') if echo "$NAMES" | grep -qx "automerge"; then echo "automerge=true" >> "$GITHUB_OUTPUT" else echo "automerge=false" >> "$GITHUB_OUTPUT" fi # post_red gates posting a red status: /merge needs it, automerge opts # in; otherwise post green only so partial CI doesn't paint red. - name: Determine eligibility id: eligible if: steps.ctx.outputs.skip != 'true' env: EVENT: ${{ github.event_name }} AUTOMERGE: ${{ steps.labels.outputs.automerge }} run: | echo "run=true" >> "$GITHUB_OUTPUT" if [[ "$EVENT" == "issue_comment" ]] || [[ "$AUTOMERGE" == "true" ]]; then echo "post_red=true" >> "$GITHUB_OUTPUT" else echo "post_red=false" >> "$GITHUB_OUTPUT" echo "::notice::No 'automerge' label; will post Merge Ready only if the gate is green." fi - name: Evaluate required checks id: eval if: >- steps.ctx.outputs.skip != 'true' && steps.eligible.outputs.run == 'true' continue-on-error: true env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ steps.ctx.outputs.sha }} run: bash .github/scripts/merge-ready/evaluate-checks.sh - name: Compute gate outcome id: gate if: >- steps.ctx.outputs.skip != 'true' && steps.eligible.outputs.run == 'true' env: EVAL: ${{ steps.eval.outcome }} FAILED: ${{ steps.eval.outputs.failed }} run: bash .github/scripts/merge-ready/compute-gate.sh # Skipped when post_red is false AND gate is red: leaves prior # status untouched so partial CI doesn't paint red. - name: Post Merge Ready status on PR head SHA if: >- steps.ctx.outputs.skip != 'true' && steps.eligible.outputs.run == 'true' && ( steps.eligible.outputs.post_red == 'true' || steps.gate.outputs.state == 'success' ) env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ steps.ctx.outputs.sha }} STATE: ${{ steps.gate.outputs.state }} DESC: ${{ steps.gate.outputs.short_desc }} run: | gh api "repos/$REPO/statuses/$SHA" \ -f state="$STATE" \ -f context="Merge Ready" \ -f description="$DESC" >/dev/null echo "Posted Merge Ready=$STATE on $SHA ($DESC)" # Authoritative /merge authz: the job `if` pre-filters on # author_association, but an org MEMBER may lack write here, so # confirm write access via the permission API before merging. - name: Authorize /merge commenter id: authz if: >- github.event_name == 'issue_comment' && steps.ctx.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} AUTHOR: ${{ github.event.comment.user.login }} PR: ${{ steps.ctx.outputs.pr }} run: bash .github/scripts/merge-ready/authorize-merge-comment.sh - name: Enable auto-merge on /merge if: >- github.event_name == 'issue_comment' && steps.ctx.outputs.skip != 'true' && steps.authz.outputs.authorized == 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ steps.ctx.outputs.pr }} AUTHOR: ${{ github.event.comment.user.login }} GATE: ${{ steps.gate.outputs.long_desc }} run: bash .github/scripts/merge-ready/enable-automerge-comment.sh - name: Enable auto-merge on automerge label if: >- steps.ctx.outputs.skip != 'true' && github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'automerge' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ steps.ctx.outputs.pr }} run: bash .github/scripts/merge-ready/enable-automerge-label.sh # Not on pull_request_target-labeled: auto-merge was enabled in an earlier # step there, so failing here would make the label look broken even # though it worked. Safe on workflow_run/workflow_dispatch. - name: Fail job when gate is red if: >- ( github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' ) && steps.ctx.outputs.skip != 'true' && steps.eligible.outputs.run == 'true' && steps.eligible.outputs.post_red == 'true' && steps.gate.outputs.state == 'failure' run: | echo "::error::Merge Ready gate is red for SHA ${{ steps.ctx.outputs.sha }}" exit 1