--- # Destination: .github/workflows/on_issue_approved.yaml # # Tier 3 of the AI triage system — the approval executor. # # Tier 2 (daily_ai_fix.yaml) posts a full `` plan on any issue # it is not confident enough to fix unattended, and labels it `ai:plan-pending`. # When @alexbelgium reviews that plan and applies the `ai:approved` label, this # workflow fires within a minute and executes the plan on Opus, opening a # ready-for-review pull request — no waiting for the next daily sweep. # # Manual precedence: the label is the maintainer's explicit go-ahead, so this # runs regardless of `no-ai`. Only @alexbelgium may approve; a label applied by # anyone else is stripped and ignored (revoke job below). # # Auth: CR_PAT environment (CLAUDE_CODE_OAUTH_TOKEN) + AI_PR_TOKEN (so the PR # triggers onpr_check-pr.yaml, which GITHUB_TOKEN-authored PRs would not). # Kill switch: repo variable AI_DISABLED=true pauses this (and every AI workflow). name: AI approved-plan executor on: issues: types: [labeled] workflow_dispatch: inputs: issue: description: "Issue number whose approved plan to execute" required: true permissions: contents: write issues: write pull-requests: write concurrency: group: ai-approve-${{ github.event.issue.number || inputs.issue }} cancel-in-progress: false jobs: # A non-maintainer applied ai:approved: strip it and do nothing else. Only # users with triage rights can label at all, so this is belt-and-braces. revoke: if: >- github.event_name == 'issues' && github.event.label.name == 'ai:approved' && github.event.sender.login != 'alexbelgium' runs-on: ubuntu-latest permissions: issues: write steps: - name: Remove unauthorised approval env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number }} run: | set -euo pipefail gh issue edit "$ISSUE" --repo "$REPO" --remove-label ai:approved || true gh issue comment "$ISSUE" --repo "$REPO" --body \ "The \`ai:approved\` label only takes effect when applied by @alexbelgium; removing it." execute: # workflow_dispatch is a maintainer override, so it must also be gated to # @alexbelgium — otherwise any collaborator with run-workflow rights could # execute a Tier 3 plan without the approval label or sender check. if: >- vars.AI_DISABLED != 'true' && ( ( github.event_name == 'workflow_dispatch' && github.actor == 'alexbelgium' ) || ( github.event.label.name == 'ai:approved' && github.event.sender.login == 'alexbelgium' ) ) runs-on: ubuntu-latest timeout-minutes: 60 environment: CR_PAT steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 token: ${{ secrets.AI_PR_TOKEN }} - name: Configure git run: | git config user.name "claude-ai-fix[bot]" git config user.email "claude-ai-fix[bot]@users.noreply.github.com" - name: Ensure control labels exist env: GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} REPO: ${{ github.repository }} run: | set -euo pipefail for l in ai:fixed ai:plan-pending ai:approved ai:needs-human ai:blocked; do gh label create "$l" --repo "$REPO" --color ededed --force >/dev/null 2>&1 || true done - name: Bundle the approved plan id: bundle env: GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number || inputs.issue }} run: | set -euo pipefail mkdir -p /tmp/ai-exec gh issue view "$ISSUE" --repo "$REPO" \ --json number,title,body,author,labels,comments > /tmp/ai-exec/issue.json # The plan is the most recent ai-plan comment FROM A TRUSTED AUTHOR. # The marker alone is not proof of origin: any reporter can paste # "" into a comment, and picking it by marker+last # would let them swap in a plan that then executes on approval. # Tier 2 posts under AI_PR_TOKEN, whose identity is a repo OWNER/ # MEMBER/COLLABORATOR; a reporter is never one of those. (If you # switch AI_PR_TOKEN to a GitHub App, add its bot login here.) jq -r '[.comments[] | select((.body | contains("")) and (.authorAssociation == "OWNER" or .authorAssociation == "MEMBER" or .authorAssociation == "COLLABORATOR"))] | last | .body // ""' /tmp/ai-exec/issue.json > /tmp/ai-exec/plan.md if [ ! -s /tmp/ai-exec/plan.md ]; then echo "has_plan=false" >> "$GITHUB_OUTPUT" echo "::warning::no trusted comment on issue #$ISSUE; nothing to execute" # Consume the approval here too, so a later real plan can be # re-approved (re-adding ai:approved to an issue that still carries # it would not fire a fresh labeled event). gh issue edit "$ISSUE" --repo "$REPO" --remove-label ai:approved >/dev/null 2>&1 || true gh issue comment "$ISSUE" --repo "$REPO" --body \ "No AI plan (\`\`) from the triage bot was found on this issue, so \`ai:approved\` has nothing to execute (removed). Run the tier-2 sweep on it first (\`AI fix sweep\` → issue $ISSUE), then approve the plan it posts." else echo "has_plan=true" >> "$GITHUB_OUTPUT" echo "plan: $(wc -c < /tmp/ai-exec/plan.md) bytes" fi - name: Execute the plan if: steps.bundle.outputs.has_plan == 'true' uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Skip the OIDC -> Claude App token exchange, which 401s whenever # github.actor lacks write access. AI_PR_TOKEN, not GITHUB_TOKEN, so # a PR Claude opens triggers CI. github_token: ${{ secrets.AI_PR_TOKEN }} # Only the `issues` path has a comment thread to track progress in. # On workflow_dispatch there is none, and passing true there fails # the action's input validation outright. track_progress: ${{ github.event_name == 'issues' }} prompt: | The approved plan is /tmp/ai-exec/plan.md and the issue it belongs to is /tmp/ai-exec/issue.json. Follow .github/prompts/issue-execute-plan.md exactly. Do not deviate from the path restrictions under any circumstances. claude_args: | --model claude-opus-5 --effort high --max-turns 200 --allowedTools "Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(shellcheck:*),Bash(yamllint:*),Bash(docker build:*)" env: GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} # Consume the approval no matter how the run ended, so a stuck label can # never re-fire this workflow, and flag a human if nothing shipped. - name: Guard against repeat processing if: always() && steps.bundle.outputs.has_plan == 'true' env: GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number || inputs.issue }} run: | set -euo pipefail gh issue edit "$ISSUE" --repo "$REPO" \ --remove-label ai:approved --remove-label ai:plan-pending >/dev/null 2>&1 || true NOW=$(gh issue view "$ISSUE" --repo "$REPO" --json labels --jq '[.labels[].name]') if ! echo "$NOW" | jq -e 'index("ai:fixed")!=null' >/dev/null \ && ! echo "$NOW" | jq -e 'index("ai:upstream")!=null' >/dev/null; then echo "::warning::issue #$ISSUE produced no PR/upstream outcome, flagging for a human" gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-human >/dev/null 2>&1 || true fi # The plan should never touch shared infra, but enforce it on the PR just # as the tier-2 sweep does — same script, single source of truth. - name: Guard forbidden paths if: always() && steps.bundle.outputs.has_plan == 'true' env: GH_TOKEN: ${{ secrets.AI_PR_TOKEN }} REPO: ${{ github.repository }} run: | set -euo pipefail # Run the guard from the trusted default-branch copy, never the in-tree # copy the model could have modified in this same job. DEFAULT=$(gh api "repos/${REPO}" --jq '.default_branch' 2>/dev/null || echo master) if git fetch --depth=1 origin "$DEFAULT" >/dev/null 2>&1 \ && git cat-file -e "FETCH_HEAD:.github/scripts/ai_guard_paths.sh" 2>/dev/null; then git show "FETCH_HEAD:.github/scripts/ai_guard_paths.sh" | bash else echo "::warning::trusted guard copy unavailable, using in-tree copy" bash .github/scripts/ai_guard_paths.sh fi