diff --git a/.github/workflows/auto-fix-tests.yml b/.github/workflows/auto-fix-tests.yml
new file mode 100644
index 0000000..307f2b5
--- /dev/null
+++ b/.github/workflows/auto-fix-tests.yml
@@ -0,0 +1,458 @@
+# 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
+# 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:
+ 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 = '';
+ 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;
+ 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 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;
+ 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 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;
+ 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 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;
+ 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\nagent 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\nagent 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..0a6b0a7
--- /dev/null
+++ b/.github/workflows/auto-rebase.yml
@@ -0,0 +1,433 @@
+# 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
+
+# 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:
+ # 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
+ 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
+ # 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)
+ if regen "$f" pnpm pnpm install --no-frozen-lockfile --prefer-offline; then
+ handled_any=true
+ fi
+ ;;
+ package-lock.json)
+ if regen "$f" npm npm install --package-lock-only; then
+ handled_any=true
+ fi
+ ;;
+ yarn.lock)
+ if regen "$f" yarn yarn install --mode update-lockfile; then
+ handled_any=true
+ fi
+ ;;
+ Cargo.lock)
+ if regen "$f" cargo cargo update; then
+ handled_any=true
+ fi
+ ;;
+ go.sum)
+ 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
+ # 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..75bc990 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,101 @@ 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
+```
+
+**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/`,
+`.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).
+
+**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.
+- **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