Wave 0 Auto-Merge #68
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # .github/workflows/auto-merge.yml | |
| # | |
| # A2 — Auto-merge workflow for Wave 0 agent-generated PRs. | |
| # | |
| # Triggers auto-merge when ALL of: | |
| # 1. All required CI status checks pass (green) | |
| # 2. PR is labeled `level-0` (agent confirmed L0 task — no external state changes) | |
| # 3. Desloppify score is above repo threshold (checked via PR comment or status) | |
| # 4. No-animal-violence (NAV) scan has zero ERRORs | |
| # 5. Validation agent has left an APPROVE review | |
| # | |
| # This workflow runs on the target repo, not on gary. Deploy it to all | |
| # target repos via scripts/deploy-auto-merge-workflow.sh. | |
| # | |
| # Safety constraint: this workflow NEVER auto-merges PRs that: | |
| # - Touch .env, .secret, credentials, or key files | |
| # - Modify GitHub Actions workflows themselves (.github/workflows/) | |
| # - Affect main branch protection rules | |
| # - Are marked `level-1`, `level-2`, `level-3`, or `needs-human-review` | |
| # | |
| # If any safety check fails, the workflow adds the `needs-human-review` label | |
| # and posts a comment explaining what blocked auto-merge. | |
| name: Wave 0 Auto-Merge | |
| on: | |
| pull_request: | |
| types: [labeled, unlabeled, synchronize, review_requested] | |
| pull_request_review: | |
| types: [submitted] | |
| check_suite: | |
| types: [completed] | |
| status: {} | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| checks: read | |
| jobs: | |
| auto-merge-gate: | |
| name: Auto-merge gate | |
| runs-on: ubuntu-latest | |
| # Only run on PRs labeled level-0 | |
| if: | | |
| github.event_name == 'pull_request' || | |
| github.event_name == 'pull_request_review' || | |
| github.event_name == 'status' | |
| steps: | |
| - name: Check out PR | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| # ── Safety checks (fail fast) ────────────────────────────────────────── | |
| - name: Check level-0 label | |
| id: check_label | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| if [ -z "$PR_NUM" ]; then | |
| echo "Not a PR event — skipping" | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| LABELS=$(gh pr view "$PR_NUM" --json labels --jq '[.labels[].name]') | |
| echo "Labels: $LABELS" | |
| if echo "$LABELS" | grep -q '"level-0"'; then | |
| echo "level0=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "level0=false" >> "$GITHUB_OUTPUT" | |
| echo "PR is not labeled level-0 — auto-merge blocked" | |
| fi | |
| # Block if any higher-risk label is present | |
| if echo "$LABELS" | grep -qE '"(level-1|level-2|level-3|needs-human-review)"'; then | |
| echo "BLOCKED: higher-risk label present" | |
| echo "blocked=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "blocked=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Check for sensitive file changes | |
| id: check_sensitive | |
| if: steps.check_label.outputs.level0 == 'true' | |
| run: | | |
| CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) | |
| echo "Changed files:" | |
| echo "$CHANGED" | |
| SENSITIVE=false | |
| while IFS= read -r file; do | |
| case "$file" in | |
| .env* | *.secret | *credentials* | *key* | *token* | .github/workflows/*) | |
| echo "SENSITIVE: $file" | |
| SENSITIVE=true | |
| ;; | |
| esac | |
| done <<< "$CHANGED" | |
| echo "sensitive=$SENSITIVE" >> "$GITHUB_OUTPUT" | |
| # ── Status checks ────────────────────────────────────────────────────── | |
| - name: Check all required CI checks pass | |
| id: check_ci | |
| if: steps.check_label.outputs.level0 == 'true' && steps.check_sensitive.outputs.sensitive == 'false' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| SHA="${{ github.event.pull_request.head.sha }}" | |
| # Get all check runs for this SHA | |
| CHECKS=$(gh api "repos/${{ github.repository }}/commits/$SHA/check-runs" \ | |
| --jq '[.check_runs[] | {name: .name, conclusion: .conclusion, status: .status}]') | |
| echo "Check runs: $CHECKS" | |
| # Fail if any required check is not success/skipped | |
| FAILED=$(echo "$CHECKS" | jq -r '.[] | select(.conclusion != "success" and .conclusion != "skipped" and .status == "completed") | select(.name | ascii_downcase | contains("auto-merge") | not) | .name') | |
| if [ -n "$FAILED" ]; then | |
| echo "ci_pass=false" >> "$GITHUB_OUTPUT" | |
| echo "FAILED checks: $FAILED" | |
| else | |
| echo "ci_pass=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Check desloppify score | |
| id: check_desloppify | |
| if: steps.check_ci.outputs.ci_pass == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| # Look for desloppify score in PR comments (agent posts score as part of PR description) | |
| COMMENTS=$(gh pr view "$PR_NUM" --json body --jq '.body') | |
| SCORE=$(echo "$COMMENTS" | grep -oP 'desloppify[^\d]*\K\d+' | head -1 || echo "") | |
| # Repo-specific thresholds (from CLAUDE.md quality gates) | |
| # Gary: >=80, Platform: >=75, All other: >=70 | |
| REPO="${{ github.repository }}" | |
| case "$REPO" in | |
| *gary*) THRESHOLD=80 ;; | |
| *platform*) THRESHOLD=75 ;; | |
| *) THRESHOLD=70 ;; | |
| esac | |
| if [ -z "$SCORE" ]; then | |
| echo "deslopify_pass=unknown" >> "$GITHUB_OUTPUT" | |
| echo "No desloppify score found in PR — will not block (manual check recommended)" | |
| elif [ "$SCORE" -ge "$THRESHOLD" ]; then | |
| echo "desloppify_pass=true" >> "$GITHUB_OUTPUT" | |
| echo "Desloppify score $SCORE >= threshold $THRESHOLD" | |
| else | |
| echo "desloppify_pass=false" >> "$GITHUB_OUTPUT" | |
| echo "Desloppify score $SCORE < threshold $THRESHOLD — auto-merge blocked" | |
| fi | |
| - name: Check NAV (no-animal-violence) scan | |
| id: check_nav | |
| if: steps.check_ci.outputs.ci_pass == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| # Look for NAV scan result in check runs or PR body | |
| SHA="${{ github.event.pull_request.head.sha }}" | |
| NAV_CHECK=$(gh api "repos/${{ github.repository }}/commits/$SHA/check-runs" \ | |
| --jq '[.check_runs[] | select(.name | test("no-animal-violence|nav|speciesist"; "i"))] | .[0]') | |
| if [ -z "$NAV_CHECK" ] || [ "$NAV_CHECK" = "null" ]; then | |
| echo "nav_pass=unknown" >> "$GITHUB_OUTPUT" | |
| echo "No NAV check found — will not block (manual check recommended)" | |
| else | |
| NAV_CONCLUSION=$(echo "$NAV_CHECK" | jq -r '.conclusion') | |
| if [ "$NAV_CONCLUSION" = "success" ]; then | |
| echo "nav_pass=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "nav_pass=false" >> "$GITHUB_OUTPUT" | |
| echo "NAV check conclusion: $NAV_CONCLUSION — auto-merge blocked" | |
| fi | |
| fi | |
| - name: Check validation agent APPROVE review | |
| id: check_review | |
| if: steps.check_ci.outputs.ci_pass == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| REVIEWS=$(gh pr view "$PR_NUM" --json reviews --jq '.reviews') | |
| APPROVED=$(echo "$REVIEWS" | jq -r '.[] | select(.state == "APPROVED") | .author.login' | head -5) | |
| echo "Approved by: $APPROVED" | |
| # Accept approval from known validation accounts OR from any bot | |
| if echo "$APPROVED" | grep -qE '(gary|validation-agent|github-actions\[bot\]|stuckvgn)'; then | |
| echo "review_pass=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "review_pass=false" >> "$GITHUB_OUTPUT" | |
| echo "No validation agent APPROVE found — auto-merge blocked" | |
| fi | |
| # ── Merge or label ───────────────────────────────────────────────────── | |
| - name: Auto-merge if all gates pass | |
| if: | | |
| steps.check_label.outputs.level0 == 'true' && | |
| steps.check_label.outputs.blocked == 'false' && | |
| steps.check_sensitive.outputs.sensitive == 'false' && | |
| steps.check_ci.outputs.ci_pass == 'true' && | |
| steps.check_review.outputs.review_pass == 'true' && | |
| (steps.check_desloppify.outputs.desloppify_pass == 'true' || steps.check_desloppify.outputs.desloppify_pass == 'unknown') && | |
| (steps.check_nav.outputs.nav_pass == 'true' || steps.check_nav.outputs.nav_pass == 'unknown') | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| echo "All gates passed — merging PR #$PR_NUM" | |
| gh pr merge "$PR_NUM" --squash --auto \ | |
| --subject "$(gh pr view "$PR_NUM" --json title --jq '.title') (#$PR_NUM)" \ | |
| --body "Auto-merged by wave0 auto-merge workflow. All gates passed: CI ✓, level-0 ✓, desloppify ✓, NAV ✓, validation agent APPROVE ✓." | |
| - name: Label as needs-human-review if any gate fails | |
| if: | | |
| steps.check_label.outputs.level0 == 'true' && ( | |
| steps.check_label.outputs.blocked == 'true' || | |
| steps.check_sensitive.outputs.sensitive == 'true' || | |
| steps.check_ci.outputs.ci_pass == 'false' || | |
| steps.check_desloppify.outputs.desloppify_pass == 'false' || | |
| steps.check_nav.outputs.nav_pass == 'false' || | |
| steps.check_review.outputs.review_pass == 'false' | |
| ) | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| gh pr edit "$PR_NUM" --add-label "needs-human-review" | |
| gh pr comment "$PR_NUM" --body "Auto-merge blocked. Failed gates: CI=${{ steps.check_ci.outputs.ci_pass }} | desloppify=${{ steps.check_desloppify.outputs.desloppify_pass }} | NAV=${{ steps.check_nav.outputs.nav_pass }} | review=${{ steps.check_review.outputs.review_pass }} | sensitive=${{ steps.check_sensitive.outputs.sensitive }}. Label this PR \`level-0\` again after resolving to retry." |