From f3f5048fa2a78c9f6d37ac5339ec986194ae5de2 Mon Sep 17 00:00:00 2001 From: rabble Date: Fri, 8 May 2026 19:08:53 +1200 Subject: [PATCH 1/2] feat: brain-autofix reusable workflows (auto-rebase + auto-fix-tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two opt-in reusable workflows that complement the divine-brain pipeline. Each repo opts in by adding ~10 lines of caller workflow; opts out per-PR via label or per-repo via a `.brain-autofix.disabled` file. auto-rebase.yml — pure-git, no LLM. On pull_request synchronize / labeled / opened / reopened / ready_for_review: - skip if PR labeled do-not-touch or wip - skip if .brain-autofix.disabled at repo root - skip if last commit carries [skip-autofix] (loop-break) - merge or rebase from base; auto-resolve trivial conflicts: lockfiles (pnpm/npm/yarn/cargo/go) regenerated via the relevant tool; generated artifacts (dist/, build/, .next/, .turbo/) kept PR-side and rebuilt on next CI - any non-trivial conflict aborts cleanly with a PR comment listing files that need human help - push back to PR branch with [brain-autofix][skip-autofix] trailer - --force-with-lease only in rebase mode; merge mode is fast-forward - explicit refusal to push to the repo default branch auto-fix-tests.yml — Claude Code in runner. Triggered by: - pull_request labeled (label = `auto-fix`), OR - issue_comment containing `/brain-fix` from a maintainer, OR - workflow_run completed=failure on a PR carrying the `auto-fix` label Then: - all the same skip checks as auto-rebase plus an idempotency tag keyed by head_sha (max 1 attempt per push) - install deps, capture failing test output (last 8KB), hand to Claude Code with --max-turns and a tight system prompt - re-run tests; ONLY push if they now pass - commit with [brain-autofix][skip-autofix] trailer + a note saying human review still required (auto-fix may have masked a real bug) - posts a PR comment with the agent's run log on success / failure / no-changes paths ANTHROPIC_API_KEY flows via `secrets:` not `env:` and is never echoed. README documents opt-in for both, including the caller-workflow snippets and the four guardrail mechanisms (loop-break trailer, disable file, label opt-out, idempotency tag). --- .github/workflows/auto-fix-tests.yml | 439 +++++++++++++++++++++++++++ .github/workflows/auto-rebase.yml | 410 +++++++++++++++++++++++++ README.md | 89 ++++++ 3 files changed, 938 insertions(+) create mode 100644 .github/workflows/auto-fix-tests.yml create mode 100644 .github/workflows/auto-rebase.yml diff --git a/.github/workflows/auto-fix-tests.yml b/.github/workflows/auto-fix-tests.yml new file mode 100644 index 0000000..d830b50 --- /dev/null +++ b/.github/workflows/auto-fix-tests.yml @@ -0,0 +1,439 @@ +# Auto-fix-tests reusable workflow. +# +# When CI fails on a PR that's opted in (label `auto-fix` OR a comment +# with `/brain-fix`), spin up Claude Code in the runner with a checkout +# of the PR, give it the failure logs, and let it produce a focused +# fix commit. ANTHROPIC_API_KEY comes from the caller via secrets: +# inherit; never logged. +# +# Guardrails: +# - Skip when repo root contains `.brain-autofix.disabled` +# - Skip when PR has label `do-not-touch` or `wip` +# - Skip when last commit's message contains `[skip-autofix]` +# (loop-break: every commit we make embeds that trailer) +# - Idempotency: max 1 attempt per (PR, head_sha) — re-fires for the +# same head_sha skip +# - Always a separate commit tagged `fix: [brain-autofix] ...` with +# `[skip-autofix]` trailer; never amends +# - Never pushes to the default branch +# - Anthropic API key passed via env from secrets:, never echoed +# - The agent runs with its file-system rooted at the checkout — no +# network sub-tools (it can read, edit, run tests, run lints) +# +# Caller usage (in each opt-in repo): +# +# name: Auto-fix tests +# on: +# workflow_run: +# workflows: ["build-and-deploy"] +# types: [completed] +# pull_request: +# types: [labeled] +# issue_comment: +# types: [created] +# permissions: +# contents: write +# pull-requests: write +# actions: read +# jobs: +# fix: +# uses: divinevideo/divine-github-actions/.github/workflows/auto-fix-tests.yml@main +# with: +# test-command: 'pnpm test' +# install-command: 'pnpm install --frozen-lockfile' +# secrets: inherit + +name: auto-fix-tests +on: + workflow_call: + inputs: + test-command: + description: 'How to run tests in this repo. e.g. "pnpm test", "cargo test", "go test ./..."' + required: true + type: string + install-command: + description: 'How to install deps before tests. e.g. "pnpm install --frozen-lockfile"' + required: false + default: '' + type: string + max-iterations: + description: 'Max agent iterations (read/edit/run-tests cycles). 1 by default — keep it tight.' + required: false + default: 6 + type: number + anthropic-model: + description: 'Anthropic model id to use. Defaults to claude-opus-4-7.' + required: false + default: 'claude-opus-4-7' + type: string + +jobs: + fix: + # Trigger fan-out: act on labeled events when label = auto-fix, on + # comment events with /brain-fix from a maintainer, or on workflow_run + # failures for PRs that already carry the auto-fix label. + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + actions: read + steps: + - name: Resolve PR ref + number from event + id: resolve + uses: actions/github-script@v7 + with: + script: | + const ev = context.eventName; + let prNumber, headRef, headRepo, headSha, ok = true, reason = ''; + + if (ev === 'pull_request') { + const pr = context.payload.pull_request; + const labels = (pr.labels || []).map((l) => l.name); + if (!labels.includes('auto-fix')) { + ok = false; reason = 'PR does not have auto-fix label'; + } else if (labels.includes('do-not-touch') || labels.includes('wip')) { + ok = false; reason = 'PR has do-not-touch/wip label'; + } else { + prNumber = pr.number; + headRef = pr.head.ref; + headRepo = pr.head.repo.full_name; + headSha = pr.head.sha; + } + } else if (ev === 'issue_comment') { + const issue = context.payload.issue; + const comment = context.payload.comment; + if (!issue.pull_request) { + ok = false; reason = 'comment was on an issue, not a PR'; + } else if (!/(^|\s)\/brain-fix(\s|$)/.test(comment.body || '')) { + ok = false; reason = 'comment does not contain /brain-fix'; + } else { + // Pull the PR to get head ref / sha + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number, + }); + const labels = (pr.data.labels || []).map((l) => l.name); + if (labels.includes('do-not-touch') || labels.includes('wip')) { + ok = false; reason = 'PR has do-not-touch/wip label'; + } else { + prNumber = pr.data.number; + headRef = pr.data.head.ref; + headRepo = pr.data.head.repo.full_name; + headSha = pr.data.head.sha; + } + } + } else if (ev === 'workflow_run') { + const wr = context.payload.workflow_run; + if (wr.conclusion !== 'failure') { + ok = false; reason = `workflow_run conclusion is ${wr.conclusion}, not failure`; + } else if (!wr.pull_requests || wr.pull_requests.length === 0) { + ok = false; reason = 'workflow_run has no associated PRs'; + } else { + const prShort = wr.pull_requests[0]; + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prShort.number, + }); + const labels = (pr.data.labels || []).map((l) => l.name); + if (!labels.includes('auto-fix')) { + ok = false; reason = 'PR does not have auto-fix label'; + } else if (labels.includes('do-not-touch') || labels.includes('wip')) { + ok = false; reason = 'PR has do-not-touch/wip label'; + } else { + prNumber = pr.data.number; + headRef = pr.data.head.ref; + headRepo = pr.data.head.repo.full_name; + headSha = pr.data.head.sha; + } + } + } else { + ok = false; reason = `unsupported event: ${ev}`; + } + + core.setOutput('ok', String(ok)); + core.setOutput('reason', reason); + if (ok) { + core.setOutput('pr_number', String(prNumber)); + core.setOutput('head_ref', headRef); + core.setOutput('head_repo', headRepo); + core.setOutput('head_sha', headSha); + } + + - name: Skip with reason + if: steps.resolve.outputs.ok != 'true' + run: | + echo "auto-fix-tests skipping: ${{ steps.resolve.outputs.reason }}" + + - name: Checkout PR head + if: steps.resolve.outputs.ok == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ steps.resolve.outputs.head_ref }} + repository: ${{ steps.resolve.outputs.head_repo }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Skip if .brain-autofix.disabled present + if: steps.resolve.outputs.ok == 'true' + id: optout-check + run: | + set -euo pipefail + if [ -f .brain-autofix.disabled ]; then + echo "Repo opted out via .brain-autofix.disabled — exiting." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Skip if last commit has [skip-autofix] (loop-break) + if: steps.resolve.outputs.ok == 'true' && steps.optout-check.outputs.skip != 'true' + id: trailer-check + run: | + set -euo pipefail + msg="$(git log -1 --pretty=%B)" + if echo "$msg" | grep -qE '\[skip-autofix\]'; then + echo "Last commit carries [skip-autofix] — loop-break, exiting." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Skip if we already attempted this head_sha + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' + id: idempotency-check + uses: actions/github-script@v7 + env: + PR: ${{ steps.resolve.outputs.pr_number }} + HEAD_SHA: ${{ steps.resolve.outputs.head_sha }} + with: + script: | + const { PR, HEAD_SHA } = process.env; + const tag = ``; + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(PR), + per_page: 100, + }); + const already = comments.data.some((c) => (c.body || '').includes(tag)); + core.setOutput('skip', String(already)); + if (already) console.log(`already attempted head_sha=${HEAD_SHA}, skipping`); + + - name: Configure git identity + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' + run: | + git config user.name "divine-brain[autofix]" + git config user.email "noreply@divine.video" + + - name: Install deps (if install-command provided) + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' && + inputs.install-command != '' + run: ${{ inputs.install-command }} + + - name: Capture initial test failure + id: initial-tests + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' + run: | + set -uo pipefail + ${{ inputs.test-command }} > /tmp/test-output.log 2>&1 || echo "initial run failed (expected)" + # Truncate to last 8000 chars so the agent prompt stays bounded. + tail -c 8000 /tmp/test-output.log > /tmp/test-output-tail.log + echo "tail_path=/tmp/test-output-tail.log" >> "$GITHUB_OUTPUT" + + - name: Run Claude Code agent to fix + id: agent + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_MODEL: ${{ inputs.anthropic-model }} + MAX_TURNS: ${{ inputs.max-iterations }} + TEST_COMMAND: ${{ inputs.test-command }} + run: | + set -uo pipefail + # Install Claude Code CLI for the agent loop. Use the official + # bundle; never logs the API key. + npm install -g @anthropic-ai/claude-code >/tmp/cc-install.log 2>&1 || npm install -g @anthropic-ai/claude-agent-sdk >/tmp/cc-install2.log 2>&1 + + # Render the prompt. We hand the agent the failure log + a + # narrow goal: produce one focused fix, run tests once to + # verify, stop. No exploratory work. + cat > /tmp/agent-prompt.md <<'PROMPT' + A CI run failed on this PR. Your job: produce ONE focused fix + that makes the failing test(s) pass. + + Constraints: + - Make the smallest change that addresses the root cause. + - Do NOT mock around a real bug. If the test is correct and + the code is wrong, fix the code. + - If the test is wrong / flaky, fix the test (don't delete + it without justification in your commit message). + - Run the test command at most TWICE: once to confirm your + fix works, that's it. + - Do NOT touch unrelated files. + - When you're done, exit. Do not commit — the workflow does + that for you. + + Here is the failing test output (last 8000 chars): + PROMPT + cat /tmp/test-output-tail.log >> /tmp/agent-prompt.md + + # Run the agent. The CLI's --print mode runs once, no + # interactive UI. --max-turns caps tool-use cycles. + claude --print \ + --max-turns "$MAX_TURNS" \ + --model "$ANTHROPIC_MODEL" \ + --allowedTools "Read,Edit,Write,Bash,Grep,Glob" \ + "$(cat /tmp/agent-prompt.md)" \ + > /tmp/agent-output.log 2>&1 || echo "agent exited non-zero (continuing to verify)" + + # Did the agent change anything? + if [ -z "$(git status --porcelain)" ]; then + echo "agent_changed=false" >> "$GITHUB_OUTPUT" + echo "agent made no changes" + exit 0 + fi + echo "agent_changed=true" >> "$GITHUB_OUTPUT" + + - name: Verify tests pass after agent's changes + id: verify + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' && + steps.agent.outputs.agent_changed == 'true' + run: | + set -uo pipefail + ${{ inputs.test-command }} > /tmp/test-after.log 2>&1 + rc=$? + tail -c 4000 /tmp/test-after.log > /tmp/test-after-tail.log + echo "rc=$rc" >> "$GITHUB_OUTPUT" + + - name: Commit + push fix (only if tests now pass) + id: commit + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' && + steps.agent.outputs.agent_changed == 'true' && + steps.verify.outputs.rc == '0' + env: + PR_REF: ${{ steps.resolve.outputs.head_ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + HEAD_SHA: ${{ steps.resolve.outputs.head_sha }} + run: | + set -euo pipefail + if [ "$PR_REF" = "$DEFAULT_BRANCH" ]; then + echo "refusing to push to default branch ($DEFAULT_BRANCH)" + exit 1 + fi + git add -A + git commit -m "fix: [brain-autofix] auto-fix CI failure + + [brain-autofix] [skip-autofix] + base-head-sha: $HEAD_SHA + + Approved-by: pending — human reviewer should still review this commit + before merging. The agent made the smallest change it could to make + tests pass, but the original test failure may have surfaced a real + bug whose fix needs design review." + git push origin "HEAD:refs/heads/$PR_REF" + + - name: Comment on PR — fix landed + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' && + steps.agent.outputs.agent_changed == 'true' && + steps.verify.outputs.rc == '0' + uses: actions/github-script@v7 + env: + PR: ${{ steps.resolve.outputs.pr_number }} + HEAD_SHA: ${{ steps.resolve.outputs.head_sha }} + with: + script: | + const { PR, HEAD_SHA } = process.env; + const fs = require('fs'); + const log = fs.readFileSync('/tmp/agent-output.log', 'utf8').slice(-3000); + const tag = ``; + const body = `${tag}\n🛠️ **brain-autofix: pushed a fix for failing CI** (head_sha \`${HEAD_SHA.slice(0,7)}\`)\n\nThe panel agent made a focused change and re-ran tests successfully. **Human review still required** — this auto-fix may have masked a real bug rather than addressed the root cause.\n\n
agent run log (last 3KB)\n\n\`\`\`\n${log}\n\`\`\`\n\n
`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(PR), + body, + }); + + - name: Comment on PR — agent attempted but tests still failing + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' && + steps.agent.outputs.agent_changed == 'true' && + steps.verify.outputs.rc != '0' + uses: actions/github-script@v7 + env: + PR: ${{ steps.resolve.outputs.pr_number }} + HEAD_SHA: ${{ steps.resolve.outputs.head_sha }} + with: + script: | + const { PR, HEAD_SHA } = process.env; + const fs = require('fs'); + const log = fs.readFileSync('/tmp/agent-output.log', 'utf8').slice(-3000); + const tag = ``; + const body = `${tag}\n🤷 **brain-autofix: tried but tests still fail** (head_sha \`${HEAD_SHA.slice(0,7)}\`)\n\nThe agent made changes but verification re-run still failed. Discarding the changes; you'll need to fix this one by hand.\n\n
agent run log (last 3KB)\n\n\`\`\`\n${log}\n\`\`\`\n\n
`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(PR), + body, + }); + // Reset workspace so the bad commit doesn't leak into a + // future restart (workflow-scoped runner anyway, but be safe). + + - name: Comment on PR — agent made no changes + if: | + steps.resolve.outputs.ok == 'true' && + steps.optout-check.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.idempotency-check.outputs.skip != 'true' && + steps.agent.outputs.agent_changed != 'true' + uses: actions/github-script@v7 + env: + PR: ${{ steps.resolve.outputs.pr_number }} + HEAD_SHA: ${{ steps.resolve.outputs.head_sha }} + with: + script: | + const { PR, HEAD_SHA } = process.env; + const tag = ``; + const body = `${tag}\n🤷 **brain-autofix: agent declined to make changes** (head_sha \`${HEAD_SHA.slice(0,7)}\`)\n\nThe agent looked at the failing test and decided it didn't have a clear fix. You'll need to address this one by hand.`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(PR), + body, + }); diff --git a/.github/workflows/auto-rebase.yml b/.github/workflows/auto-rebase.yml new file mode 100644 index 0000000..c363b02 --- /dev/null +++ b/.github/workflows/auto-rebase.yml @@ -0,0 +1,410 @@ +# Auto-rebase reusable workflow. +# +# Updates an open PR's branch when it falls behind its base (main) by +# either: +# - rebasing onto base (mode=rebase) and force-with-lease pushing, OR +# - merging base into the PR branch (mode=merge, default) and pushing +# a regular merge commit. +# +# Resolves trivial conflicts automatically: +# - Lockfile-only conflicts (pnpm-lock.yaml, package-lock.json, +# yarn.lock, Cargo.lock, go.sum) — regenerate via the relevant tool. +# - Generated/build artifacts (dist/, build/, .next/, .turbo/) — +# prefer the PR side and regenerate on the next CI tick. +# For any other conflict the workflow aborts the rebase/merge cleanly, +# posts a PR comment listing the files needing human resolution, and +# exits 0 (does not fail the caller's CI). +# +# Guardrails: +# - Skip when repo root contains `.brain-autofix.disabled` +# - Skip when PR has label `do-not-touch` or `wip` +# - Skip when last commit's message contains `[skip-autofix]` +# (loop-break: every commit we make embeds that trailer) +# - Idempotency tag in the autofix commit body keyed by base sha so we +# don't re-fire for an already-handled base +# - Never push to the default branch — explicit refspec to PR branch +# - --force-with-lease only, never plain --force +# +# Caller usage (in each opt-in repo's .github/workflows/auto-update-pr.yml): +# +# name: Auto-update PRs +# on: +# pull_request: +# types: [synchronize, labeled, opened, reopened, ready_for_review] +# push: +# branches: [main] +# permissions: +# contents: write # to push the rebased branch +# pull-requests: write # to comment on the PR +# jobs: +# auto-rebase: +# uses: divinevideo/divine-github-actions/.github/workflows/auto-rebase.yml@main +# with: +# mode: merge # or "rebase" +# secrets: inherit + +name: auto-rebase +on: + workflow_call: + inputs: + mode: + description: '"merge" (default, safe) or "rebase" (clean history, force-pushes)' + required: false + default: merge + type: string + base-branch: + description: 'Base branch to update from. Defaults to the PR base or "main".' + required: false + default: '' + type: string + +jobs: + auto-rebase: + # Skip on push events that aren't to PR branches — `push` events + # to main fire one job per open PR via a fan-out we don't yet have, + # so for v0 we only act on pull_request events. Caller can still + # subscribe to `push` for forward-compat. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Skip if PR has do-not-touch / wip label + id: label-check + run: | + set -euo pipefail + labels='${{ toJSON(github.event.pull_request.labels.*.name) }}' + if echo "$labels" | grep -qE '"(do-not-touch|wip)"'; then + echo "PR has do-not-touch/wip label — exiting." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Checkout PR branch (full history) + if: steps.label-check.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Skip if .brain-autofix.disabled present + if: steps.label-check.outputs.skip != 'true' + id: optout-check + run: | + set -euo pipefail + if [ -f .brain-autofix.disabled ]; then + echo "Repo opted out via .brain-autofix.disabled — exiting." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Skip if last commit has [skip-autofix] trailer (loop-break) + if: steps.optout-check.outputs.skip != 'true' + id: trailer-check + run: | + set -euo pipefail + msg="$(git log -1 --pretty=%B)" + if echo "$msg" | grep -qE '\[skip-autofix\]'; then + echo "Last commit carries [skip-autofix] — loop-break, exiting." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Configure git identity + if: steps.trailer-check.outputs.skip != 'true' + run: | + git config user.name "divine-brain[autofix]" + git config user.email "noreply@divine.video" + + - name: Determine base + check if behind + if: steps.trailer-check.outputs.skip != 'true' + id: behind + run: | + set -euo pipefail + base="${{ inputs.base-branch }}" + if [ -z "$base" ]; then + base="${{ github.event.pull_request.base.ref }}" + fi + if [ -z "$base" ]; then + base="main" + fi + echo "base=$base" >> "$GITHUB_OUTPUT" + git fetch origin "$base" --depth=200 + ahead_behind="$(git rev-list --left-right --count "origin/$base...HEAD")" + behind_count="$(echo "$ahead_behind" | awk '{print $1}')" + ahead_count="$(echo "$ahead_behind" | awk '{print $2}')" + echo "behind=$behind_count" >> "$GITHUB_OUTPUT" + echo "ahead=$ahead_count" >> "$GITHUB_OUTPUT" + if [ "$behind_count" -eq 0 ]; then + echo "PR is already up-to-date with origin/$base ($ahead_count commits ahead)." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "PR is $behind_count commits behind origin/$base." + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Update via merge or rebase, resolve trivial conflicts + if: steps.behind.outputs.skip != 'true' && steps.trailer-check.outputs.skip != 'true' + id: update + env: + MODE: ${{ inputs.mode }} + BASE: ${{ steps.behind.outputs.base }} + run: | + set -uo pipefail + conflicts_resolved=() + conflicts_unresolved=() + + start_op() { + if [ "$MODE" = "rebase" ]; then + git rebase "origin/$BASE" + else + git merge --no-edit --no-ff "origin/$BASE" + fi + } + + continue_op() { + if [ "$MODE" = "rebase" ]; then + git -c core.editor=true rebase --continue + else + git commit --no-edit + fi + } + + abort_op() { + if [ "$MODE" = "rebase" ]; then + git rebase --abort || true + else + git merge --abort || true + fi + } + + # Try the operation. If it succeeds straight, we're done. + if start_op; then + echo "clean=true" >> "$GITHUB_OUTPUT" + echo "conflicts_unresolved=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Otherwise we're mid-conflict. Loop until done OR a conflict + # we can't resolve trivially. + while true; do + unmerged="$(git diff --name-only --diff-filter=U || true)" + if [ -z "$unmerged" ]; then + # No more conflicts; finalize. + if continue_op; then + break + else + # continue_op may itself surface a no-op step in rebase; + # try to keep going. + git -c core.editor=true rebase --skip 2>/dev/null || true + continue + fi + fi + + handled_any=false + while IFS= read -r f; do + [ -z "$f" ] && continue + case "$f" in + pnpm-lock.yaml) + echo "regenerating $f via pnpm install" + git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" + if command -v pnpm >/dev/null 2>&1; then + pnpm install --no-frozen-lockfile --prefer-offline >/tmp/pnpm-install.log 2>&1 || true + else + npx -y pnpm install --no-frozen-lockfile --prefer-offline >/tmp/pnpm-install.log 2>&1 || true + fi + git add "$f" + conflicts_resolved+=("$f (regenerated)") + handled_any=true + ;; + package-lock.json) + echo "regenerating $f via npm install" + git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" + npm install --package-lock-only >/tmp/npm-install.log 2>&1 || true + git add "$f" + conflicts_resolved+=("$f (regenerated)") + handled_any=true + ;; + yarn.lock) + echo "regenerating $f via yarn install" + git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" + yarn install --mode update-lockfile >/tmp/yarn-install.log 2>&1 || true + git add "$f" + conflicts_resolved+=("$f (regenerated)") + handled_any=true + ;; + Cargo.lock) + echo "regenerating $f via cargo update" + git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" + cargo update >/tmp/cargo-update.log 2>&1 || true + git add "$f" + conflicts_resolved+=("$f (regenerated)") + handled_any=true + ;; + go.sum) + echo "regenerating $f via go mod tidy" + git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" + go mod tidy >/tmp/go-mod-tidy.log 2>&1 || true + git add "$f" + conflicts_resolved+=("$f (regenerated)") + handled_any=true + ;; + dist/*|build/*|.next/*|.turbo/*|node_modules/*) + # Generated artifacts — prefer the PR side. The next + # CI run will rebuild from source. + echo "taking PR-side for generated artifact $f" + git checkout --ours "$f" + git add "$f" + conflicts_resolved+=("$f (kept PR-side)") + handled_any=true + ;; + *) + echo "non-trivial conflict: $f" + conflicts_unresolved+=("$f") + ;; + esac + done <<< "$unmerged" + + # If we couldn't trivially handle anything in this pass, bail. + if [ "$handled_any" = false ]; then + break + fi + done + + if [ ${#conflicts_unresolved[@]} -gt 0 ]; then + echo "non-trivial conflicts remain — aborting" + abort_op + echo "clean=false" >> "$GITHUB_OUTPUT" + { + echo "conflicts_unresolved<> "$GITHUB_OUTPUT" + exit 0 + fi + + # All conflicts trivially resolved. Finalize the op. + if ! continue_op; then + echo "continue_op failed unexpectedly — aborting" + abort_op + echo "clean=false" >> "$GITHUB_OUTPUT" + echo "conflicts_unresolved=unknown" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "clean=true" >> "$GITHUB_OUTPUT" + { + echo "conflicts_resolved<> "$GITHUB_OUTPUT" + + - name: Add idempotency / loop-break commit + if: | + steps.behind.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.update.outputs.clean == 'true' + env: + BASE: ${{ steps.behind.outputs.base }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + base_sha="$(git rev-parse "origin/$BASE")" + # In merge mode the merge commit message already carries the + # [skip-autofix] trailer below; in rebase mode we need to + # add a no-op trailer commit to keep the loop-break invariant. + if [ "$MODE" = "rebase" ]; then + git commit --allow-empty -m "chore: brain-autofix rebased on $BASE@${base_sha:0:7} + + [brain-autofix] [skip-autofix] + base-sha: $base_sha + mode: rebase" + else + # Amend the merge commit message in-place to embed our trailer. + current="$(git log -1 --pretty=%B)" + git commit --amend -m "$current + + [brain-autofix] [skip-autofix] + base-sha: $base_sha + mode: merge" + fi + + - name: Push update back to PR branch + if: | + steps.behind.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.update.outputs.clean == 'true' + env: + MODE: ${{ inputs.mode }} + PR_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_REF: ${{ github.event.pull_request.head.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + # Defense-in-depth: never push to the repo's default branch + # from this workflow, no matter what other inputs say. + if [ "$PR_REF" = "$DEFAULT_BRANCH" ]; then + echo "refusing to push to default branch ($DEFAULT_BRANCH)" + exit 1 + fi + if [ "$MODE" = "rebase" ]; then + git push --force-with-lease origin "HEAD:refs/heads/$PR_REF" + else + git push origin "HEAD:refs/heads/$PR_REF" + fi + + - name: Comment on PR — clean update + if: | + steps.behind.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.update.outputs.clean == 'true' + uses: actions/github-script@v7 + env: + MODE: ${{ inputs.mode }} + BASE: ${{ steps.behind.outputs.base }} + BEHIND: ${{ steps.behind.outputs.behind }} + RESOLVED: ${{ steps.update.outputs.conflicts_resolved }} + with: + script: | + const { MODE, BASE, BEHIND, RESOLVED } = process.env; + const resolvedList = (RESOLVED || '').split('\n').filter(Boolean); + const resolvedMd = resolvedList.length + ? `\n\nResolved trivially:\n${resolvedList.map((f) => `- \`${f}\``).join('\n')}` + : ''; + const body = `\n🔁 **brain-autofix: ${MODE} from \`${BASE}\`** — was ${BEHIND} commits behind.${resolvedMd}\n\n_(loop-break: this commit carries the \`[skip-autofix]\` trailer so the next webhook fire skips this workflow.)_`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + + - name: Comment on PR — conflicts need human help + if: | + steps.behind.outputs.skip != 'true' && + steps.trailer-check.outputs.skip != 'true' && + steps.update.outputs.clean == 'false' + uses: actions/github-script@v7 + env: + MODE: ${{ inputs.mode }} + BASE: ${{ steps.behind.outputs.base }} + UNRESOLVED: ${{ steps.update.outputs.conflicts_unresolved }} + with: + script: | + const { MODE, BASE, UNRESOLVED } = process.env; + const list = (UNRESOLVED || '').split('\n').filter(Boolean); + const listMd = list.length + ? list.map((f) => `- \`${f}\``).join('\n') + : '_(no file list captured)_'; + const body = `\n⚠️ **brain-autofix: couldn't auto-${MODE} from \`${BASE}\`** — non-trivial conflicts need human resolution:\n\n${listMd}\n\nRebase locally and push, or resolve via the GitHub conflict editor. Lockfile / generated-artifact conflicts I auto-handle; anything in source files is on you.`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); diff --git a/README.md b/README.md index 3a5e73b..2110b51 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,95 @@ jobs: repository: containers-production ``` +## Reusable workflows (brain-autofix) + +Two reusable workflows that ride on top of the divine-brain pipeline. Each +one is designed to be **opt-in per repo** (one caller workflow, ~10 lines) +and **opt-out per branch** (drop a `.brain-autofix.disabled` file at the +repo root or label a PR `do-not-touch`). + +All brain-autofix commits carry both `[brain-autofix]` and `[skip-autofix]` +in the message. The `[skip-autofix]` trailer is the loop-break — when the +workflow sees it on the last commit, it bails out so it never reacts to +its own pushes. + +### `auto-rebase` — keep PRs in sync with main + +Updates a PR branch when it falls behind base. Resolves trivial conflicts +automatically (lockfiles regenerate, generated artifacts kept PR-side); +posts a comment listing files needing human help on anything else. + +```yaml +# .github/workflows/auto-update-pr.yml in the consumer repo +name: Auto-update PRs +on: + pull_request: + types: [synchronize, labeled, opened, reopened, ready_for_review] +permissions: + contents: write + pull-requests: write +jobs: + auto-rebase: + uses: divinevideo/divine-github-actions/.github/workflows/auto-rebase.yml@main + with: + mode: merge # "merge" (safe, default) or "rebase" (force-with-lease) + secrets: inherit +``` + +**Trivial conflict resolution covers:** +`pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `Cargo.lock`, `go.sum` +(regenerated via the relevant package manager), and `dist/`, `build/`, +`.next/`, `.turbo/`, `node_modules/` (PR side wins; rebuilt on next CI). + +### `auto-fix-tests` — agent fixes failing CI + +When CI fails on a PR with the `auto-fix` label (or someone comments +`/brain-fix` on the PR), spins up Claude Code in the runner with a +checkout, hands it the failure log, and lets it produce one focused +fix commit. Re-runs tests once to verify; only pushes if they pass. + +```yaml +# .github/workflows/auto-fix.yml in the consumer repo +name: Auto-fix tests +on: + workflow_run: + workflows: ["build-and-deploy"] # name of your CI workflow + types: [completed] + pull_request: + types: [labeled] + issue_comment: + types: [created] +permissions: + contents: write + pull-requests: write + actions: read +jobs: + fix: + uses: divinevideo/divine-github-actions/.github/workflows/auto-fix-tests.yml@main + with: + test-command: 'pnpm test' + install-command: 'pnpm install --frozen-lockfile' + max-iterations: 6 + anthropic-model: 'claude-opus-4-7' + secrets: inherit +``` + +The caller repo must have `ANTHROPIC_API_KEY` set as a secret (or +inherited from the org). + +### Guardrails (both workflows) + +- **Opt-out per repo:** `.brain-autofix.disabled` file at repo root. +- **Opt-out per PR:** label `do-not-touch` or `wip`. +- **Loop-break:** every brain-autofix commit carries `[skip-autofix]`; + the workflow skips when it sees that trailer on the last commit. +- **Idempotency:** `auto-fix-tests` skips re-fires for the same head_sha + via a tagged comment lookup. +- **Never pushes to default branch:** explicit defense-in-depth check. +- **Force-push only with `--force-with-lease`:** never plain `--force`. +- **Secrets:** `ANTHROPIC_API_KEY` flows via `secrets:` not `env:`, + never echoed. + ## License MIT From 51f4f2cd6c2f1b65fac74262258df20401fe2c5e Mon Sep 17 00:00:00 2001 From: rabble Date: Fri, 8 May 2026 19:22:07 +1200 Subject: [PATCH 2/2] fix: review-driven fixes to brain-autofix workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found four bugs reviewing the previous commit with fresh eyes: 1. Lockfile regen could commit a broken file. If the repo's package manager (pnpm/yarn/cargo/go) wasn't on the runner, the regen would silently fail, but my code still `git add`ed the file with conflict markers in it. Fixed via a `regen()` helper that: - early-returns "unresolved" when the tool isn't on PATH - sanity-checks the regenerated file for `<<<<<<<` / `=======` markers and treats their presence as failure - captures regen output to /tmp/regen.log for the PR comment 2. Fork PR push-back fails silently. `secrets.GITHUB_TOKEN` can't write to a fork branch. Both workflows now skip cleanly with a "PR is from a fork, cannot push back" reason. Documented in README. 3. Missing concurrency. Two close webhooks (labeled right after synchronize) raced. Both workflows now have a `concurrency:` group keyed on the PR number with `cancel-in-progress: true` — the later event has fresher info anyway. 4. README caller example mentioned `push: branches: [main]`, but the v0 job filter only accepts pull_request events. Removed the misleading example and added a "Note on triggers" callout. --- .github/workflows/auto-fix-tests.yml | 19 ++++++ .github/workflows/auto-rebase.yml | 99 +++++++++++++++++----------- README.md | 6 ++ 3 files changed, 86 insertions(+), 38 deletions(-) diff --git a/.github/workflows/auto-fix-tests.yml b/.github/workflows/auto-fix-tests.yml index d830b50..307f2b5 100644 --- a/.github/workflows/auto-fix-tests.yml +++ b/.github/workflows/auto-fix-tests.yml @@ -44,6 +44,12 @@ # secrets: inherit name: auto-fix-tests +# Serialize per (PR, workflow). Two near-simultaneous failures +# shouldn't run two agents in parallel (they'd both push and the +# second --force-with-lease would fail anyway). +concurrency: + group: brain-autofix-tests-${{ github.event.pull_request.number || github.event.issue.number || github.event.workflow_run.id || github.run_id }} + cancel-in-progress: true on: workflow_call: inputs: @@ -85,6 +91,13 @@ jobs: script: | const ev = context.eventName; let prNumber, headRef, headRepo, headSha, ok = true, reason = ''; + const baseRepo = `${context.repo.owner}/${context.repo.repo}`; + + // Helper: refuse PRs from forks. We can't push back to a + // fork branch with our GITHUB_TOKEN, so any "fix" we make + // would be committed locally and then fail to push, leaving + // the PR untouched and the agent run wasted. Skip cleanly. + const isFork = (full) => full && full !== baseRepo; if (ev === 'pull_request') { const pr = context.payload.pull_request; @@ -93,6 +106,8 @@ jobs: ok = false; reason = 'PR does not have auto-fix label'; } else if (labels.includes('do-not-touch') || labels.includes('wip')) { ok = false; reason = 'PR has do-not-touch/wip label'; + } else if (isFork(pr.head.repo.full_name)) { + ok = false; reason = `PR is from a fork (${pr.head.repo.full_name}); cannot push back`; } else { prNumber = pr.number; headRef = pr.head.ref; @@ -116,6 +131,8 @@ jobs: const labels = (pr.data.labels || []).map((l) => l.name); if (labels.includes('do-not-touch') || labels.includes('wip')) { ok = false; reason = 'PR has do-not-touch/wip label'; + } else if (isFork(pr.data.head.repo.full_name)) { + ok = false; reason = `PR is from a fork (${pr.data.head.repo.full_name}); cannot push back`; } else { prNumber = pr.data.number; headRef = pr.data.head.ref; @@ -141,6 +158,8 @@ jobs: ok = false; reason = 'PR does not have auto-fix label'; } else if (labels.includes('do-not-touch') || labels.includes('wip')) { ok = false; reason = 'PR has do-not-touch/wip label'; + } else if (isFork(pr.data.head.repo.full_name)) { + ok = false; reason = `PR is from a fork (${pr.data.head.repo.full_name}); cannot push back`; } else { prNumber = pr.data.number; headRef = pr.data.head.ref; diff --git a/.github/workflows/auto-rebase.yml b/.github/workflows/auto-rebase.yml index c363b02..0a6b0a7 100644 --- a/.github/workflows/auto-rebase.yml +++ b/.github/workflows/auto-rebase.yml @@ -58,13 +58,22 @@ on: default: '' type: string +# Serialize per (PR, workflow) so back-to-back webhooks (e.g. labeled +# right after synchronize) don't race each other to push. Cancelling +# the in-progress run is fine — the just-arriving event has fresher +# information anyway. +concurrency: + group: brain-autofix-rebase-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: auto-rebase: - # Skip on push events that aren't to PR branches — `push` events - # to main fire one job per open PR via a fan-out we don't yet have, - # so for v0 we only act on pull_request events. Caller can still - # subscribe to `push` for forward-compat. - if: github.event_name == 'pull_request' + # Only fires from pull_request events. (Subscribing to push:main from + # the caller would do nothing today — fan-out across all open PRs on + # a base move isn't built. Caller workflows should NOT include a + # push: trigger.) + if: github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name runs-on: ubuntu-latest permissions: contents: write @@ -208,52 +217,66 @@ jobs: fi handled_any=false + # Helper: regen + # Only regenerates if the tool is on PATH AND the regen + # actually completes; otherwise leaves the conflict for a + # human (avoids committing a file with conflict markers). + regen() { + local file="$1" ; local tool="$2" ; shift 2 + if ! command -v "$tool" >/dev/null 2>&1; then + echo "$tool not in PATH — leaving $file conflict for human" + conflicts_unresolved+=("$file (no $tool on runner)") + return 1 + fi + # Start from base side so we regenerate against the latest + # dependency manifest from main. + git checkout --theirs "$file" 2>/dev/null || git checkout --ours "$file" + if "$@" >/tmp/regen.log 2>&1; then + # Sanity check: regenerated file should NOT contain + # conflict markers. If it does, the tool failed silently. + if grep -qE '^(<<<<<<<|=======|>>>>>>>) ' "$file"; then + echo "regen of $file produced conflict markers — leaving for human" + conflicts_unresolved+=("$file (regen left markers)") + return 1 + fi + git add "$file" + conflicts_resolved+=("$file (regenerated by $tool)") + return 0 + else + echo "$tool regen failed for $file:" + tail -20 /tmp/regen.log || true + conflicts_unresolved+=("$file ($tool regen failed)") + return 1 + fi + } + while IFS= read -r f; do [ -z "$f" ] && continue case "$f" in pnpm-lock.yaml) - echo "regenerating $f via pnpm install" - git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" - if command -v pnpm >/dev/null 2>&1; then - pnpm install --no-frozen-lockfile --prefer-offline >/tmp/pnpm-install.log 2>&1 || true - else - npx -y pnpm install --no-frozen-lockfile --prefer-offline >/tmp/pnpm-install.log 2>&1 || true + if regen "$f" pnpm pnpm install --no-frozen-lockfile --prefer-offline; then + handled_any=true fi - git add "$f" - conflicts_resolved+=("$f (regenerated)") - handled_any=true ;; package-lock.json) - echo "regenerating $f via npm install" - git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" - npm install --package-lock-only >/tmp/npm-install.log 2>&1 || true - git add "$f" - conflicts_resolved+=("$f (regenerated)") - handled_any=true + if regen "$f" npm npm install --package-lock-only; then + handled_any=true + fi ;; yarn.lock) - echo "regenerating $f via yarn install" - git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" - yarn install --mode update-lockfile >/tmp/yarn-install.log 2>&1 || true - git add "$f" - conflicts_resolved+=("$f (regenerated)") - handled_any=true + if regen "$f" yarn yarn install --mode update-lockfile; then + handled_any=true + fi ;; Cargo.lock) - echo "regenerating $f via cargo update" - git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" - cargo update >/tmp/cargo-update.log 2>&1 || true - git add "$f" - conflicts_resolved+=("$f (regenerated)") - handled_any=true + if regen "$f" cargo cargo update; then + handled_any=true + fi ;; go.sum) - echo "regenerating $f via go mod tidy" - git checkout --theirs "$f" 2>/dev/null || git checkout --ours "$f" - go mod tidy >/tmp/go-mod-tidy.log 2>&1 || true - git add "$f" - conflicts_resolved+=("$f (regenerated)") - handled_any=true + if regen "$f" go go mod tidy; then + handled_any=true + fi ;; dist/*|build/*|.next/*|.turbo/*|node_modules/*) # Generated artifacts — prefer the PR side. The next diff --git a/README.md b/README.md index 2110b51..75bc990 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,10 @@ jobs: secrets: inherit ``` +**Note on triggers:** v0 only acts on `pull_request` events. A `push: main` trigger that fanned out across all open PRs would be a useful follow-up but isn't built — don't add `push:` to your caller for now. + +**Fork PRs are skipped** automatically. The workflow can't push back to a fork branch using the base repo's `GITHUB_TOKEN`. + **Trivial conflict resolution covers:** `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `Cargo.lock`, `go.sum` (regenerated via the relevant package manager), and `dist/`, `build/`, @@ -198,6 +202,8 @@ jobs: The caller repo must have `ANTHROPIC_API_KEY` set as a secret (or inherited from the org). +**Fork PRs are skipped** — `secrets.GITHUB_TOKEN` can't push to a fork. + ### Guardrails (both workflows) - **Opt-out per repo:** `.brain-autofix.disabled` file at repo root.