From b9defb767d9b2586603e436dcc11ff20abd59262 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:24:23 +0000 Subject: [PATCH 1/2] feat(workflows): add reusable auto-arm-merge workflow Reusable workflow that arms 'gh pr merge --auto --squash --delete-branch' on every non-draft PR. Consumed via: jobs: arm: uses: chittyfoundation/.github/.github/workflows/auto-arm-merge.yml@main Skips drafts, dependabot/renovate, WIP-titled PRs, and forks. Relies on repo ruleset to gate merge on required checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/auto-arm-merge.yml | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/auto-arm-merge.yml diff --git a/.github/workflows/auto-arm-merge.yml b/.github/workflows/auto-arm-merge.yml new file mode 100644 index 0000000..39e7581 --- /dev/null +++ b/.github/workflows/auto-arm-merge.yml @@ -0,0 +1,71 @@ +# Auto-Arm Auto-Merge — Reusable Workflow +# Arms `gh pr merge --auto --squash --delete-branch` on every non-draft PR. +# Usage from a consuming repo (.github/workflows/auto-arm-merge.yml): +# +# name: Auto-Arm Merge +# on: +# pull_request: +# types: [opened, ready_for_review, reopened] +# jobs: +# arm: +# uses: chittyfoundation/.github/.github/workflows/auto-arm-merge.yml@main +# +# Skip conditions (PR is left alone): +# - draft PRs +# - authors: dependabot[bot], renovate[bot] (they have their own auto-merge logic) +# - title starts with: WIP, [WIP], Draft:, DO NOT MERGE +# - PRs from forks (GITHUB_TOKEN lacks write on the head ref; we no-op gracefully) +# +# Repos consuming this MUST have: +# - Settings -> General -> "Allow auto-merge" enabled +# - Settings -> General -> "Automatically delete head branches" enabled +# - A branch ruleset that gates merge on required checks (otherwise auto-merge +# would fire instantly with no protection) + +name: Auto-Arm Merge (reusable) + +on: + workflow_call: + inputs: + merge-method: + description: 'Merge method: squash | merge | rebase' + required: false + type: string + default: squash + +permissions: + pull-requests: write + contents: write + +jobs: + arm: + name: Arm auto-merge + runs-on: ubuntu-latest + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.user.login != 'dependabot[bot]' && + github.event.pull_request.user.login != 'renovate[bot]' && + !startsWith(github.event.pull_request.title, 'WIP') && + !startsWith(github.event.pull_request.title, '[WIP]') && + !startsWith(github.event.pull_request.title, 'Draft:') && + !startsWith(github.event.pull_request.title, 'DO NOT MERGE') && + github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Enable auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + METHOD: ${{ inputs.merge-method }} + run: | + set -euo pipefail + echo "Arming auto-merge on ${REPO}#${PR_NUMBER} (method=${METHOD})" + # --delete-branch is honored by GitHub at merge time when the repo + # has "Automatically delete head branches" enabled. + if ! gh pr merge --auto "--${METHOD}" --delete-branch \ + --repo "${REPO}" "${PR_NUMBER}"; then + echo "::warning::Could not arm auto-merge on PR #${PR_NUMBER}." \ + "This is expected for forks, repos without auto-merge enabled," \ + "or PRs that are already merged/closed." + exit 0 + fi From f91edb5be47b62fc0cf8ef60ab184977287c1a21 Mon Sep 17 00:00:00 2001 From: chitcommit Date: Thu, 4 Jun 2026 10:08:37 +0000 Subject: [PATCH 2/2] fix(auto-arm-merge): drop contents:write, classify gh errors, harden guards Critical: - Drop contents:write from permissions (auto-merge only needs pull-requests:write) - Classify gh pr merge stderr instead of blanket exit 0 (surface auth/method/config drift) Important: - Add job-level concurrency to collapse opened+ready_for_review duplicates - Validate merge-method input against squash|merge|rebase allowlist - Preflight check that gh is installed on the runner - Move WIP/Draft/DO-NOT-MERGE title check into the step using bash regex with a boundary class so WIPER/WIPE/WIPED no longer false-match Doc: - Rewrite header to reflect fork PRs are excluded at trigger level, not via no-op - Inline rationale next to permissions, concurrency, and the regex boundary Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/auto-arm-merge.yml | 64 ++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/.github/workflows/auto-arm-merge.yml b/.github/workflows/auto-arm-merge.yml index 39e7581..ac815fe 100644 --- a/.github/workflows/auto-arm-merge.yml +++ b/.github/workflows/auto-arm-merge.yml @@ -13,8 +13,11 @@ # Skip conditions (PR is left alone): # - draft PRs # - authors: dependabot[bot], renovate[bot] (they have their own auto-merge logic) -# - title starts with: WIP, [WIP], Draft:, DO NOT MERGE -# - PRs from forks (GITHUB_TOKEN lacks write on the head ref; we no-op gracefully) +# - title starts with: WIP, [WIP], Draft:, DO NOT MERGE (enforced inside the step +# via regex; GitHub Actions expressions lack regex, and startsWith('WIP') would +# false-match WIPER/WIPE) +# - Fork PRs are skipped at the trigger level (GITHUB_TOKEN is read-only on the +# head ref of a fork, so the gh call would never succeed anyway) # # Repos consuming this MUST have: # - Settings -> General -> "Allow auto-merge" enabled @@ -33,22 +36,25 @@ on: type: string default: squash +# Least privilege: arming auto-merge only mutates the PR object. We do NOT need +# contents:write — a compromised step with contents:write could push to the +# default branch. permissions: pull-requests: write - contents: write jobs: arm: name: Arm auto-merge runs-on: ubuntu-latest + # Collapse duplicate runs from rapid opened+ready_for_review sequences on the + # same PR. Cancel any in-flight arming for the same PR before starting a new one. + concurrency: + group: auto-arm-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true if: >- github.event.pull_request.draft == false && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.user.login != 'renovate[bot]' && - !startsWith(github.event.pull_request.title, 'WIP') && - !startsWith(github.event.pull_request.title, '[WIP]') && - !startsWith(github.event.pull_request.title, 'Draft:') && - !startsWith(github.event.pull_request.title, 'DO NOT MERGE') && github.event.pull_request.head.repo.full_name == github.repository steps: - name: Enable auto-merge @@ -57,15 +63,49 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} METHOD: ${{ inputs.merge-method }} + PR_TITLE: ${{ github.event.pull_request.title }} run: | set -euo pipefail + + # Preflight: gh must be present on the runner. + command -v gh >/dev/null || { echo "::error::gh CLI missing on runner"; exit 1; } + + # Validate merge-method to prevent injecting arbitrary flags via the + # workflow_call input. + case "$METHOD" in + squash|merge|rebase) ;; + *) echo "::error::invalid merge-method '$METHOD' (must be squash/merge/rebase)"; exit 1 ;; + esac + + # Title-prefix guard (regex-based; YAML expressions can't do regex, so + # this lives here). The boundary class [[:space:]:/-] (or end-of-string) + # ensures WIPER / WIPE / WIPED do NOT match, while accepting: + # "WIP fix", "WIP: fix", "[WIP] fix", "Draft: fix", "DO NOT MERGE - x" + if [[ "$PR_TITLE" =~ ^(WIP|\[WIP\]|Draft:|DO[[:space:]]NOT[[:space:]]MERGE)([[:space:]:/-]|$) ]]; then + echo "::notice::skipped — title prefix signals not-ready: $PR_TITLE" + exit 0 + fi + echo "Arming auto-merge on ${REPO}#${PR_NUMBER} (method=${METHOD})" # --delete-branch is honored by GitHub at merge time when the repo # has "Automatically delete head branches" enabled. - if ! gh pr merge --auto "--${METHOD}" --delete-branch \ - --repo "${REPO}" "${PR_NUMBER}"; then - echo "::warning::Could not arm auto-merge on PR #${PR_NUMBER}." \ - "This is expected for forks, repos without auto-merge enabled," \ - "or PRs that are already merged/closed." + set +e + err=$(gh pr merge --auto "--${METHOD}" --delete-branch --repo "${REPO}" "${PR_NUMBER}" 2>&1) + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + echo "::notice::auto-merge armed for ${REPO}#${PR_NUMBER} (${METHOD})" exit 0 fi + case "$err" in + *"Pull request is not mergeable"*|*"already"*|*"closed"*) + echo "::notice::benign skip (${REPO}#${PR_NUMBER}): $err"; exit 0 ;; + *"auto merge is not allowed"*|*"not enabled"*) + echo "::error::repo missing allow_auto_merge=true on ${REPO}"; exit 1 ;; + *"not allowed to merge"*|*"merge method"*) + echo "::error::merge-method '${METHOD}' not permitted on ${REPO} (ruleset/setting): $err"; exit 1 ;; + *"HTTP 401"*|*"HTTP 403"*|*"Bad credentials"*|*"Resource not accessible"*) + echo "::error::token scope/auth (${REPO}): $err"; exit 1 ;; + *) + echo "::error::unexpected gh failure (${REPO}#${PR_NUMBER}, rc=${rc}): $err"; exit 1 ;; + esac