Skip to content

Wave 0 Auto-Merge

Wave 0 Auto-Merge #68

Workflow file for this run

# .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."