From 1ee5f5b6c7c88a21a64fb8dfac5c8e9025af73a5 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 13 May 2026 12:28:57 -0500 Subject: [PATCH 1/5] feat(auto-rebase): trigger Claude agentic rebase on merge conflict When auto-rebase-reusable.yml encounters a conflict it cannot resolve via the merge strategy, it already posts a sentinel comment (). This commit wires that sentinel to a new claude-rebase job that fires when github-actions[bot] posts that comment on a PR, giving Claude a chance to rebase the branch and resolve conflicts before escalating to a human. Changes: - auto-rebase-reusable.yml: update conflict comment to mention Claude will attempt an automatic rebase; switch manual instructions from merge to rebase (git rebase --continue / --force-with-lease) - claude-code-reusable.yml: add claude-rebase job triggered by the sentinel; Claude checks out the branch, rebases onto the base branch, resolves conflicts using best-effort judgment (prefer main's side for superseded pins, keep PR's side for novel changes), pushes, and posts a summary; aborts cleanly if application-code conflicts require human judgment Closes #279 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/auto-rebase-reusable.yml | 28 +++++--- .github/workflows/claude-code-reusable.yml | 82 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/.github/workflows/auto-rebase-reusable.yml b/.github/workflows/auto-rebase-reusable.yml index 66d77baa5..a1e4ee99b 100644 --- a/.github/workflows/auto-rebase-reusable.yml +++ b/.github/workflows/auto-rebase-reusable.yml @@ -22,8 +22,11 @@ # Failure modes handled gracefully: # - without `workflows` permission (403): posts an idempotent comment asking # the author to rebase manually (sentinel: ) -# - merge conflict (422): posts an idempotent comment asking the author to -# resolve conflicts (sentinel: ) +# - merge conflict (422): posts an idempotent comment with sentinel +# , which triggers the claude-rebase job in +# claude-code-reusable.yml to attempt an agentic rebase with conflict +# resolution. If Claude's rebase also fails, it posts a summary and a +# human must intervene. # # No secrets required — uses github.token only. No auto-merge logic. name: Auto-rebase non-Dependabot PRs (Reusable) @@ -99,25 +102,28 @@ jobs: gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BLOCKED_BODY" fi elif echo "$UPDATE_OUTPUT" | grep -qi "merge conflict"; then - # Merge conflict — ask the author to resolve it. - # gh api surfaces the GitHub API error JSON: {"message":"merge conflict",...} + # Merge conflict — the merge strategy can't resolve it automatically. + # Post an idempotent sentinel comment that triggers the claude-rebase + # job in claude-code-reusable.yml to attempt an agentic rebase. # - # Idempotent: skip if sentinel comment already exists. + # Idempotent: skip if sentinel comment already exists (prevents + # re-triggering Claude on every subsequent auto-rebase run). SENTINEL="" ALREADY_POSTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL\"))] | length") if [[ "$ALREADY_POSTED" -gt 0 ]]; then echo " Skipping — conflict comment already posted" else - echo " Posting conflict resolution request" + echo " Posting conflict comment (triggers claude-rebase job)" CONFLICT_BODY="" - CONFLICT_BODY+=$'\n'"**Auto-rebase failed — merge conflict** — this branch has conflicts" - CONFLICT_BODY+=" with \`$BASE_BRANCH\` that must be resolved manually." - CONFLICT_BODY+=$'\n\n'"Please resolve the conflicts and push:" + CONFLICT_BODY+=$'\n'"**Auto-rebase failed — merge conflict** — this branch conflicts" + CONFLICT_BODY+=" with \`$BASE_BRANCH\` and cannot be updated via the merge strategy." + CONFLICT_BODY+=$'\n\n'"Claude will attempt an agentic rebase with conflict resolution." + CONFLICT_BODY+=" If that also fails, manual intervention is needed:" CONFLICT_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin" - CONFLICT_BODY+=$'\n'"git merge origin/$BASE_BRANCH" + CONFLICT_BODY+=$'\n'"git rebase origin/$BASE_BRANCH" CONFLICT_BODY+=$'\n'"# resolve conflicts, then:" - CONFLICT_BODY+=$'\n'"git add ."$'\n'"git commit"$'\n'"git push"$'\n'"\`\`\`" + CONFLICT_BODY+=$'\n'"git add ."$'\n'"git rebase --continue"$'\n'"git push --force-with-lease"$'\n'"\`\`\`" gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$CONFLICT_BODY" fi else diff --git a/.github/workflows/claude-code-reusable.yml b/.github/workflows/claude-code-reusable.yml index fb9b25625..30b9c8007 100644 --- a/.github/workflows/claude-code-reusable.yml +++ b/.github/workflows/claude-code-reusable.yml @@ -346,3 +346,85 @@ jobs: - If the comment requires human judgment, leave a reply explaining what you need. 4. Check CI status. If CI fails, read the logs, fix the issues, and push again. Repeat until CI passes. 5. When CI is green, all actionable review comments are resolved, and the PR is ready, read the CODEOWNERS file and leave a comment tagging the relevant code owners to review and merge. + + # Automation mode: agentic rebase — triggered when auto-rebase-reusable.yml posts + # a conflict sentinel comment () on a PR. + # Checks out the branch, rebases onto the base branch, resolves conflicts using + # best-effort judgment, and pushes. Posts a summary comment on completion. + claude-rebase: + if: >- + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + github.event.comment.user.login == 'github-actions[bot]' && + contains(github.event.comment.body, '') + concurrency: + group: claude-rebase-${{ github.event.issue.number }} + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + id-token: write + pull-requests: write + issues: write + actions: read + checks: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} + - name: Run Claude Code + uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} + # yamllint disable rule:line-length + claude_args: | + --allowedTools "Bash(git*:*),Bash(gh pr checkout:*),Bash(gh pr view:*),Bash(gh pr comment:*),Bash(gh api:*),Edit,Write" + # yamllint enable rule:line-length + # yamllint disable rule:line-length + prompt: | + PR #${{ github.event.issue.number }} has a merge conflict that the auto-rebase workflow could not resolve using the fast-forward merge strategy. You've been assigned to resolve it via a rebase. + + ## Your task + + ### 1. Check out the PR branch + ``` + gh pr checkout ${{ github.event.issue.number }} + ``` + + ### 2. Rebase onto the base branch + ``` + git fetch origin + BASE=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName) + git rebase origin/$BASE + ``` + + ### 3. Resolve conflicts intelligently + For each conflicted file, use judgment: + - **Workflow YAML / action pins**: if main already pins the same action to a newer SHA, accept main's version (`git checkout --ours ` — during a rebase, `--ours` is main's side). If the PR's pin is the newer or correct one, keep it (`git checkout --theirs `). + - **`.gitignore`, config files**: merge both sides — preserve all unique entries from both. + - **Application code**: carefully merge both sides. If the conflict is too complex to resolve safely, abort (see below). + - After resolving each file: `git add `, then `git rebase --continue`. + + ### 4. Push on success + ``` + git push --force-with-lease + ``` + + ### 5. Post a summary comment on PR #${{ github.event.issue.number }} + Summarize: + - Which files had conflicts and how each was resolved + - The rebase outcome (success or failure) + - If any commits were dropped because they were fully superseded by main (0 unique commits remaining), note that the PR may be a candidate for closure + + ## If rebase cannot be completed safely + If conflicts involve complex application code that requires human judgment: + 1. `git rebase --abort` + 2. Post a comment on PR #${{ github.event.issue.number }} explaining: + - Which specific files conflict + - Why the conflict needs human resolution + - The exact conflict markers so the author can find them quickly + # yamllint enable rule:line-length From 75afe833d1c4176b24c951875a950376a772a511 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 13 May 2026 12:52:54 -0500 Subject: [PATCH 2/5] =?UTF-8?q?fix(auto-rebase):=20address=20review=20feed?= =?UTF-8?q?back=20=E2=80=94=20correct=20trigger,=20idempotency,=20git=20id?= =?UTF-8?q?entity,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all issues raised by Codex and Copilot on PR #280: P1 — GITHUB_TOKEN events don't trigger issue_comment workflows: Switch from comment-based trigger to repository_dispatch (one of only two event types GITHUB_TOKEN IS permitted to trigger workflow runs for). auto-rebase-reusable.yml fires `gh api .../dispatches` with event_type claude-rebase; claude.yml adds `repository_dispatch: types: [claude-rebase]`; claude-code-reusable.yml claude-rebase job triggers on repository_dispatch. P2 — Idempotency sentinel too broad (one-shot per PR): Replace `` with a SHA-keyed sentinel `` where base_sha is the first 8 chars of the base branch HEAD. A new merge to the base branch changes the SHA, resetting the gate and giving Claude another attempt. P2 — Missing git identity before rebase --continue: Add `git config user.name/user.email` to the claude-rebase prompt before any rebase operations. Copilot — git rebase --continue called per-file instead of per-commit: Clarify in the prompt that all conflicted files for the current commit must be staged first, then `git rebase --continue` run once per commit. Testability: Extract conflict-handling logic from inline YAML to scripts/auto-rebase.sh. Add 12-test BATS suite covering both handle_permissions_block and handle_conflict (sentinel presence, SHA-keyed idempotency, dispatch payload fields, comment body content, fallback instructions). Add .github/workflows/auto-rebase-tests.yml to run ShellCheck + bats on every PR touching the auto-rebase files. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/auto-rebase-reusable.yml | 69 ++----- .github/workflows/auto-rebase-tests.yml | 55 ++++++ .github/workflows/claude-code-reusable.yml | 51 ++++-- .github/workflows/claude.yml | 2 + scripts/auto-rebase.sh | 81 +++++++++ standards/workflows/claude.yml | 2 + test/workflows/auto-rebase/auto-rebase.bats | 172 ++++++++++++++++++ test/workflows/auto-rebase/helpers/setup.bash | 110 +++++++++++ 8 files changed, 472 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/auto-rebase-tests.yml create mode 100644 scripts/auto-rebase.sh create mode 100644 test/workflows/auto-rebase/auto-rebase.bats create mode 100644 test/workflows/auto-rebase/helpers/setup.bash diff --git a/.github/workflows/auto-rebase-reusable.yml b/.github/workflows/auto-rebase-reusable.yml index a1e4ee99b..28a0da9ce 100644 --- a/.github/workflows/auto-rebase-reusable.yml +++ b/.github/workflows/auto-rebase-reusable.yml @@ -22,13 +22,15 @@ # Failure modes handled gracefully: # - without `workflows` permission (403): posts an idempotent comment asking # the author to rebase manually (sentinel: ) -# - merge conflict (422): posts an idempotent comment with sentinel -# , which triggers the claude-rebase job in -# claude-code-reusable.yml to attempt an agentic rebase with conflict -# resolution. If Claude's rebase also fails, it posts a summary and a -# human must intervene. +# - merge conflict (422): posts a SHA-keyed sentinel comment and fires a +# repository_dispatch event (type: claude-rebase). The sentinel is keyed to +# the base-branch HEAD SHA, so each new merge to the base branch resets the +# gate and allows Claude another attempt on the next auto-rebase run. +# Note: repository_dispatch is one of only two event types that GITHUB_TOKEN +# is permitted to trigger workflow runs for (the other being workflow_dispatch). # # No secrets required — uses github.token only. No auto-merge logic. +# Conflict-handling logic lives in scripts/auto-rebase.sh for testability. name: Auto-rebase non-Dependabot PRs (Reusable) on: @@ -38,14 +40,21 @@ jobs: auto-rebase: runs-on: ubuntu-latest permissions: - contents: write # needed for update-branch (may touch .github/workflows/) + contents: write # needed for update-branch and repository_dispatch pull-requests: write # needed to post comments on PRs steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 - name: Update behind non-Dependabot PRs env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | + # shellcheck source=scripts/auto-rebase.sh + source scripts/auto-rebase.sh + # Find open non-Dependabot PRs from the same repo (exclude forks) PRS=$(gh api "repos/$REPO/pulls?state=open&per_page=100" \ --jq '.[] | select(.user.login != "dependabot[bot]") | select(.head.repo != null) | select(.head.repo.full_name == .base.repo.full_name) | "\(.number) \(.head.ref)"') @@ -79,53 +88,9 @@ jobs: echo " Update failed: $UPDATE_OUTPUT" if echo "$UPDATE_OUTPUT" | grep -qF "without \`workflows\` permission"; then - # GitHub blocks update-branch when merging the base would add - # .github/workflows/ changes and the token lacks the 'workflows' - # permission. Ask the author to rebase manually. - # - # Idempotent: skip if sentinel comment already exists. - SENTINEL="" - ALREADY_POSTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ - --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL\"))] | length") - if [[ "$ALREADY_POSTED" -gt 0 ]]; then - echo " Skipping — blocked comment already posted" - else - echo " Posting manual-rebase request (workflows permission missing)" - BLOCKED_BODY="" - BLOCKED_BODY+=$'\n'"**Auto-rebase blocked** — the base branch contains \`.github/workflows/\` changes" - BLOCKED_BODY+=" that require the \`workflows\` permission to merge into this branch," - BLOCKED_BODY+=" but the auto-rebase workflow's token does not have that permission." - BLOCKED_BODY+=$'\n\n'"Please rebase this branch manually:" - BLOCKED_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin" - BLOCKED_BODY+=$'\n'"git rebase origin/$BASE_BRANCH" - BLOCKED_BODY+=$'\n'"git push --force-with-lease"$'\n'"\`\`\`" - gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BLOCKED_BODY" - fi + handle_permissions_block "$PR_NUMBER" "$BASE_BRANCH" elif echo "$UPDATE_OUTPUT" | grep -qi "merge conflict"; then - # Merge conflict — the merge strategy can't resolve it automatically. - # Post an idempotent sentinel comment that triggers the claude-rebase - # job in claude-code-reusable.yml to attempt an agentic rebase. - # - # Idempotent: skip if sentinel comment already exists (prevents - # re-triggering Claude on every subsequent auto-rebase run). - SENTINEL="" - ALREADY_POSTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ - --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL\"))] | length") - if [[ "$ALREADY_POSTED" -gt 0 ]]; then - echo " Skipping — conflict comment already posted" - else - echo " Posting conflict comment (triggers claude-rebase job)" - CONFLICT_BODY="" - CONFLICT_BODY+=$'\n'"**Auto-rebase failed — merge conflict** — this branch conflicts" - CONFLICT_BODY+=" with \`$BASE_BRANCH\` and cannot be updated via the merge strategy." - CONFLICT_BODY+=$'\n\n'"Claude will attempt an agentic rebase with conflict resolution." - CONFLICT_BODY+=" If that also fails, manual intervention is needed:" - CONFLICT_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin" - CONFLICT_BODY+=$'\n'"git rebase origin/$BASE_BRANCH" - CONFLICT_BODY+=$'\n'"# resolve conflicts, then:" - CONFLICT_BODY+=$'\n'"git add ."$'\n'"git rebase --continue"$'\n'"git push --force-with-lease"$'\n'"\`\`\`" - gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$CONFLICT_BODY" - fi + handle_conflict "$PR_NUMBER" "$HEAD_REF" "$BASE_BRANCH" else echo " Warning: failed to update PR #$PR_NUMBER — $UPDATE_OUTPUT" fi diff --git a/.github/workflows/auto-rebase-tests.yml b/.github/workflows/auto-rebase-tests.yml new file mode 100644 index 000000000..2f4f0c2e9 --- /dev/null +++ b/.github/workflows/auto-rebase-tests.yml @@ -0,0 +1,55 @@ +# Quality gates for the auto-rebase workflow and its supporting scripts. +# +# Triggered on any PR that touches: +# - .github/workflows/auto-rebase*.yml +# - scripts/auto-rebase.sh +# - test/workflows/auto-rebase/** +# +# Gates (all must pass before merge): +# 1. shellcheck — static analysis of scripts/auto-rebase.sh +# 2. bats — unit test suite for conflict-handling logic + +name: Auto-Rebase Tests + +on: + pull_request: + paths: + - '.github/workflows/auto-rebase*.yml' + - 'scripts/auto-rebase.sh' + - 'test/workflows/auto-rebase/**' + push: + branches: + - main + paths: + - '.github/workflows/auto-rebase*.yml' + - 'scripts/auto-rebase.sh' + - 'test/workflows/auto-rebase/**' + +permissions: + contents: read + +concurrency: + group: auto-rebase-tests-${{ github.ref }}-${{ github.sha }} + cancel-in-progress: true + +jobs: + test: + name: ShellCheck and bats + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Install bats and shellcheck + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends bats shellcheck + + - name: ShellCheck + run: shellcheck --severity=warning scripts/auto-rebase.sh + + - name: Run bats suite + run: bats --print-output-on-failure test/workflows/auto-rebase/ diff --git a/.github/workflows/claude-code-reusable.yml b/.github/workflows/claude-code-reusable.yml index 30b9c8007..93bf675e2 100644 --- a/.github/workflows/claude-code-reusable.yml +++ b/.github/workflows/claude-code-reusable.yml @@ -347,18 +347,22 @@ jobs: 4. Check CI status. If CI fails, read the logs, fix the issues, and push again. Repeat until CI passes. 5. When CI is green, all actionable review comments are resolved, and the PR is ready, read the CODEOWNERS file and leave a comment tagging the relevant code owners to review and merge. - # Automation mode: agentic rebase — triggered when auto-rebase-reusable.yml posts - # a conflict sentinel comment () on a PR. + # Automation mode: agentic rebase — triggered by a repository_dispatch event + # (type: claude-rebase) fired by auto-rebase-reusable.yml when a conflict + # cannot be resolved via the merge strategy. # Checks out the branch, rebases onto the base branch, resolves conflicts using # best-effort judgment, and pushes. Posts a summary comment on completion. + # + # Note: repository_dispatch is one of only two event types that GITHUB_TOKEN + # is permitted to trigger new workflow runs for. The caller (auto-rebase.yml + # in each repo) must include `repository_dispatch: types: [claude-rebase]` + # in its `on:` block for this job to receive the event. claude-rebase: if: >- - github.event_name == 'issue_comment' && - github.event.issue.pull_request != null && - github.event.comment.user.login == 'github-actions[bot]' && - contains(github.event.comment.body, '') + github.event_name == 'repository_dispatch' && + github.event.action == 'claude-rebase' concurrency: - group: claude-rebase-${{ github.event.issue.number }} + group: claude-rebase-${{ github.event.client_payload.pr_number }} cancel-in-progress: true runs-on: ubuntu-latest timeout-minutes: 30 @@ -386,35 +390,46 @@ jobs: # yamllint enable rule:line-length # yamllint disable rule:line-length prompt: | - PR #${{ github.event.issue.number }} has a merge conflict that the auto-rebase workflow could not resolve using the fast-forward merge strategy. You've been assigned to resolve it via a rebase. + PR #${{ github.event.client_payload.pr_number }} has a merge conflict that the auto-rebase workflow could not resolve using the fast-forward merge strategy. You've been assigned to resolve it via a rebase. ## Your task - ### 1. Check out the PR branch + ### 1. Configure git identity and check out the PR branch ``` - gh pr checkout ${{ github.event.issue.number }} + git config user.name "claude[bot]" + git config user.email "claude[bot]@users.noreply.github.com" + gh pr checkout ${{ github.event.client_payload.pr_number }} ``` ### 2. Rebase onto the base branch ``` git fetch origin - BASE=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName) + BASE=$(gh pr view ${{ github.event.client_payload.pr_number }} --json baseRefName -q .baseRefName) git rebase origin/$BASE ``` ### 3. Resolve conflicts intelligently - For each conflicted file, use judgment: - - **Workflow YAML / action pins**: if main already pins the same action to a newer SHA, accept main's version (`git checkout --ours ` — during a rebase, `--ours` is main's side). If the PR's pin is the newer or correct one, keep it (`git checkout --theirs `). - - **`.gitignore`, config files**: merge both sides — preserve all unique entries from both. - - **Application code**: carefully merge both sides. If the conflict is too complex to resolve safely, abort (see below). - - After resolving each file: `git add `, then `git rebase --continue`. + During a rebase, conflicts are resolved commit-by-commit. For each stopped commit: + + a. For every conflicted file in this commit, resolve it using judgment: + - **Workflow YAML / action pins**: if main already pins the same action to a newer SHA, accept main's version (`git checkout --ours ` — during a rebase, `--ours` is the target branch i.e. main). If the PR's pin is newer or correct, keep it (`git checkout --theirs `). + - **`.gitignore`, config files**: merge both sides — preserve all unique entries from both. + - **Application code**: carefully merge both sides. If too complex to resolve safely, abort (see below). + + b. After **all** conflicted files for this commit are resolved, stage them all at once, then continue: + ``` + git add ... # stage every resolved file for this commit + git rebase --continue # move to the next commit (run once per commit, not per file) + ``` + + c. Repeat steps a–b for each subsequent commit that has conflicts. ### 4. Push on success ``` git push --force-with-lease ``` - ### 5. Post a summary comment on PR #${{ github.event.issue.number }} + ### 5. Post a summary comment on PR #${{ github.event.client_payload.pr_number }} Summarize: - Which files had conflicts and how each was resolved - The rebase outcome (success or failure) @@ -423,7 +438,7 @@ jobs: ## If rebase cannot be completed safely If conflicts involve complex application code that requires human judgment: 1. `git rebase --abort` - 2. Post a comment on PR #${{ github.event.issue.number }} explaining: + 2. Post a comment on PR #${{ github.event.client_payload.pr_number }} explaining: - Which specific files conflict - Why the conflict needs human resolution - The exact conflict markers so the author can find them quickly diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 8f7c686d3..a5250d42a 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -15,6 +15,8 @@ on: types: [labeled] check_run: types: [completed] + repository_dispatch: + types: [claude-rebase] permissions: {} diff --git a/scripts/auto-rebase.sh b/scripts/auto-rebase.sh new file mode 100644 index 000000000..1769d4bba --- /dev/null +++ b/scripts/auto-rebase.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# auto-rebase.sh — conflict-handling helpers for the auto-rebase workflow. +# Called from .github/workflows/auto-rebase-reusable.yml. +# +# All functions read REPO from the environment and accept explicit arguments +# for the values that vary per PR, making them unit-testable. +# +# Required env: GH_TOKEN, REPO +set -euo pipefail + +# Post the "blocked by workflows permission" comment (idempotent). +# Returns 0 whether it posts or skips. +handle_permissions_block() { + local pr_number="$1" + local base_branch="$2" + + local sentinel="" + local already_posted + already_posted=$(gh pr view "$pr_number" --repo "$REPO" \ + --json comments --jq "[.comments[] | select(.body | contains(\"$sentinel\"))] | length") + if [[ "$already_posted" -gt 0 ]]; then + echo " Skipping — blocked comment already posted" + return 0 + fi + + echo " Posting manual-rebase request (workflows permission missing)" + local body="$sentinel" + body+=$'\n'"**Auto-rebase blocked** — the base branch contains \`.github/workflows/\` changes" + body+=" that require the \`workflows\` permission to merge into this branch," + body+=" but the auto-rebase workflow's token does not have that permission." + body+=$'\n\n'"Please rebase this branch manually:" + body+=$'\n'"\`\`\`"$'\n'"git fetch origin" + body+=$'\n'"git rebase origin/$base_branch" + body+=$'\n'"git push --force-with-lease"$'\n'"\`\`\`" + gh pr comment "$pr_number" --repo "$REPO" --body "$body" +} + +# Post a SHA-keyed conflict sentinel comment and fire a repository_dispatch +# event to trigger the claude-rebase job. Idempotent: skips if a comment with +# this exact sentinel (tied to the current base-branch HEAD SHA) already exists, +# so a new merge to the base branch resets the gate and allows Claude another attempt. +handle_conflict() { + local pr_number="$1" + local head_ref="$2" + local base_branch="$3" + + # First 8 chars of the base branch HEAD SHA — changes with every merge. + local base_sha + base_sha=$(gh api "repos/$REPO/branches/$base_branch" --jq '.commit.sha' | cut -c1-8) + + # Sentinel is SHA-keyed so a new main commit resets idempotency for that PR. + local sentinel="" + local already_posted + already_posted=$(gh pr view "$pr_number" --repo "$REPO" \ + --json comments --jq "[.comments[] | select(.body | contains(\"$sentinel\"))] | length") + if [[ "$already_posted" -gt 0 ]]; then + echo " Skipping — conflict for $base_branch@$base_sha already dispatched" + return 0 + fi + + echo " Posting conflict comment and dispatching claude-rebase for $base_branch@$base_sha" + local body="$sentinel" + body+=$'\n'"**Auto-rebase failed — merge conflict** — this branch conflicts" + body+=" with \`$base_branch\` and cannot be updated via the merge strategy." + body+=$'\n\n'"Claude has been dispatched to attempt an agentic rebase with conflict resolution." + body+=" If Claude's rebase also fails, resolve manually:" + body+=$'\n'"\`\`\`"$'\n'"git fetch origin" + body+=$'\n'"git rebase origin/$base_branch" + body+=$'\n'"# resolve conflicts per file, then for each commit:" + body+=$'\n'"git add "$'\n'"git rebase --continue"$'\n'"git push --force-with-lease"$'\n'"\`\`\`" + gh pr comment "$pr_number" --repo "$REPO" --body "$body" + + # repository_dispatch is one of two event types that GITHUB_TOKEN IS allowed + # to trigger new workflow runs for (the other being workflow_dispatch). + gh api "repos/$REPO/dispatches" \ + -X POST \ + -f event_type=claude-rebase \ + -F "client_payload[pr_number]=$pr_number" \ + -F "client_payload[head_ref]=$head_ref" \ + -F "client_payload[base_branch]=$base_branch" +} diff --git a/standards/workflows/claude.yml b/standards/workflows/claude.yml index 5e1f48f27..d4b2f9673 100644 --- a/standards/workflows/claude.yml +++ b/standards/workflows/claude.yml @@ -52,6 +52,8 @@ on: types: [labeled] check_run: types: [completed] + repository_dispatch: + types: [claude-rebase] permissions: {} diff --git a/test/workflows/auto-rebase/auto-rebase.bats b/test/workflows/auto-rebase/auto-rebase.bats new file mode 100644 index 000000000..01f1ac422 --- /dev/null +++ b/test/workflows/auto-rebase/auto-rebase.bats @@ -0,0 +1,172 @@ +#!/usr/bin/env bats +# Unit tests for scripts/auto-rebase.sh +# +# Tests the two public functions: +# handle_permissions_block — posts a "blocked" comment idempotently +# handle_conflict — posts a SHA-keyed sentinel comment and fires +# repository_dispatch idempotently + +load 'helpers/setup' + +setup() { + ar_make_tmpdir + export REPO="petry-projects/test-repo" + export GH_TOKEN="fake-token" +} + +teardown() { + ar_cleanup_tmpdir +} + +# ─── handle_permissions_block ─────────────────────────────────────────────── + +@test "handle_permissions_block: posts blocked comment when no sentinel exists" { + ar_install_gh_stub "0" # gh pr view → count=0 (sentinel absent) + source "$AR_SCRIPT" + + run handle_permissions_block "42" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_called "pr comment" + ar_assert_gh_called "auto-rebase-blocked" +} + +@test "handle_permissions_block: skips comment when sentinel already present" { + ar_install_gh_stub "1" # gh pr view → count=1 (sentinel present) + source "$AR_SCRIPT" + + run handle_permissions_block "42" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_not_called "pr comment" + [[ "$output" == *"already posted"* ]] +} + +@test "handle_permissions_block: comment body contains git rebase instructions" { + ar_install_gh_stub "0" + source "$AR_SCRIPT" + + handle_permissions_block "7" "main" + + ar_assert_gh_called "git rebase origin" +} + +# ─── handle_conflict ──────────────────────────────────────────────────────── + +# Helper: set up multi-response stub for handle_conflict. +# The stub returns pre-jq-processed values (as real gh would after applying --jq). +# Calls in order: +# 1. gh api repos/.../branches/BASE --jq .commit.sha → raw SHA string +# 2. gh pr view ... --jq "[...] | length" → sentinel count +# 3. gh pr comment (if not skipped) → (empty) +# 4. gh api .../dispatches → (empty) +_setup_conflict_stub() { + local sha="$1" # raw SHA string (as gh --jq '.commit.sha' would return) + local sentinel_count="$2" # "0" or "1" + AR_GH_RESPONSES=("$sha" "$sentinel_count" "" "") + ar_install_multi_gh_stub +} + +@test "handle_conflict: posts comment and dispatches when sentinel absent" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + run handle_conflict "99" "feat/my-branch" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_called "pr comment" + ar_assert_gh_called "dispatches" + ar_assert_gh_called "claude-rebase" +} + +@test "handle_conflict: sentinel contains first-8 chars of base SHA" { + _setup_conflict_stub "deadbeef12345678" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "main" + + # The sentinel must appear in the comment call + ar_assert_gh_called "auto-rebase-conflict:deadbeef" +} + +@test "handle_conflict: skips when SHA-keyed sentinel already present" { + _setup_conflict_stub "abc12345def67890" "1" + source "$AR_SCRIPT" + + run handle_conflict "99" "feat/my-branch" "main" + + [ "$status" -eq 0 ] + ar_assert_gh_not_called "pr comment" + ar_assert_gh_not_called "dispatches" + [[ "$output" == *"already dispatched"* ]] +} + +@test "handle_conflict: dispatches with pr_number in payload" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "123" "feat/branch" "main" + + ar_assert_gh_called "pr_number" + ar_assert_gh_called "123" +} + +@test "handle_conflict: dispatches with head_ref in payload" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/my-feature" "main" + + ar_assert_gh_called "head_ref" + ar_assert_gh_called "feat/my-feature" +} + +@test "handle_conflict: dispatches with base_branch in payload" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "develop" + + ar_assert_gh_called "base_branch" + ar_assert_gh_called "develop" +} + +@test "handle_conflict: different SHA → different sentinel, allows new dispatch" { + # Simulate two sequential runs: different base SHA each time → both dispatch + _setup_conflict_stub "aaaaaaa1bbbbbbbb" "0" + source "$AR_SCRIPT" + handle_conflict "7" "feat/x" "main" + local first_count + first_count=$(ar_gh_call_count "dispatches") + + # Second call with a different SHA — reset tmp dir so logs are clean + ar_cleanup_tmpdir + ar_make_tmpdir + _setup_conflict_stub "bbbbbbb1cccccccc" "0" + handle_conflict "7" "feat/x" "main" + local second_count + second_count=$(ar_gh_call_count "dispatches") + + [ "$first_count" -eq 1 ] + [ "$second_count" -eq 1 ] +} + +@test "handle_conflict: comment body contains manual fallback instructions" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "main" + + ar_assert_gh_called "git rebase" + ar_assert_gh_called "force-with-lease" +} + +@test "handle_conflict: event_type is claude-rebase" { + _setup_conflict_stub "abc12345def67890" "0" + source "$AR_SCRIPT" + + handle_conflict "5" "feat/x" "main" + + ar_assert_gh_called "event_type" + ar_assert_gh_called "claude-rebase" +} diff --git a/test/workflows/auto-rebase/helpers/setup.bash b/test/workflows/auto-rebase/helpers/setup.bash new file mode 100644 index 000000000..fb7cd42b1 --- /dev/null +++ b/test/workflows/auto-rebase/helpers/setup.bash @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Common test helpers for auto-rebase bats suites. + +AR_REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd)" +export AR_REPO_ROOT + +AR_SCRIPT="${AR_REPO_ROOT}/scripts/auto-rebase.sh" +export AR_SCRIPT + +ar_make_tmpdir() { + AR_TMP="$(mktemp -d)" + export AR_TMP +} + +ar_cleanup_tmpdir() { + if [ -n "${AR_TMP:-}" ] && [ -d "${AR_TMP}" ]; then + rm -rf "${AR_TMP}" + fi +} + +# Install a recording gh stub on PATH. +# Args: +# $1 — value to echo on stdout for every call (default: empty) +# $2 — exit code for every call (default: 0) +# The stub logs each invocation to $AR_GH_LOG (one line per call, argv tab-separated). +ar_install_gh_stub() { + local stdout="${1:-}" + local exit_code="${2:-0}" + local stub_dir="${AR_TMP}/bin" + mkdir -p "$stub_dir" + AR_GH_LOG="${AR_TMP}/gh.log" + export AR_GH_LOG + # Write the fixed stdout/exit values into files so the stub can read them at + # runtime without relying on env vars that may not survive the function call. + echo -n "$stdout" > "${AR_TMP}/gh-stdout" + echo -n "$exit_code" > "${AR_TMP}/gh-exit" + # Expand AR_TMP now (double-quote around heredoc delimiter) so the stub + # path is baked in at install time. + # shellcheck disable=SC2086 + cat >"$stub_dir/gh" <> "${AR_TMP}/gh.log" +printf '\n' >> "${AR_TMP}/gh.log" +cat "${AR_TMP}/gh-stdout" +exit \$(cat "${AR_TMP}/gh-exit") +STUB + chmod +x "$stub_dir/gh" + PATH="${stub_dir}:${PATH}" + export PATH +} + +# Install a multi-response gh stub driven by AR_GH_RESPONSES (bash array). +# Each element is echoed as the response for successive gh invocations. +ar_install_multi_gh_stub() { + local stub_dir="${AR_TMP}/bin" + mkdir -p "$stub_dir" + AR_GH_LOG="${AR_TMP}/gh.log" + export AR_GH_LOG + local counter_file="${AR_TMP}/.gh-counter" + local responses_file="${AR_TMP}/gh-responses" + echo "0" > "$counter_file" + # One response per line (elements must not contain literal newlines). + printf '%s\n' "${AR_GH_RESPONSES[@]}" > "$responses_file" + # shellcheck disable=SC2086 + cat >"$stub_dir/gh" <> "${AR_TMP}/gh.log" +printf '\n' >> "${AR_TMP}/gh.log" +n=\$(cat "${counter_file}") +sed -n "\$((n+1))p" "${responses_file}" +echo \$((n+1)) > "${counter_file}" +exit 0 +STUB + chmod +x "$stub_dir/gh" + PATH="${stub_dir}:${PATH}" + export PATH +} + +# Count how many times gh was called with a substring match on the full argv line. +ar_gh_call_count() { + local pattern="$1" + local count + # grep -c prints 0 even on no match; exit code 1 means no match (not error). + count=$(grep -c "$pattern" "${AR_GH_LOG:-/dev/null}" 2>/dev/null) || count=0 + echo "$count" +} + +# Assert gh was called at least once with the given pattern. +ar_assert_gh_called() { + local pattern="$1" + local count + count=$(ar_gh_call_count "$pattern") + if [[ "$count" -eq 0 ]]; then + echo "Expected gh to be called with pattern '$pattern' but it was not." >&2 + echo "Actual calls:" >&2 + cat "${AR_GH_LOG:-/dev/null}" >&2 + return 1 + fi +} + +# Assert gh was NOT called with the given pattern. +ar_assert_gh_not_called() { + local pattern="$1" + local count + count=$(ar_gh_call_count "$pattern") + if [[ "$count" -gt 0 ]]; then + echo "Expected gh NOT to be called with pattern '$pattern' but it was ($count times)." >&2 + return 1 + fi +} From a8b49e94fa16e8afd4422cedb8eec4cbfd365090 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Wed, 13 May 2026 17:13:03 -0500 Subject: [PATCH 3/5] fix(auto-rebase): address P1/P2 from second Codex review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — script not found when reusable is called from other repos: The checkout in auto-rebase-reusable.yml checked out the CALLER's repository, so scripts/auto-rebase.sh was only present when the .github repo called itself. Fix: always checkout petry-projects/.github (the canonical home of the scripts) regardless of which repo invokes the reusable. P2 — sentinel posted before dispatch, blocking retries on dispatch failure: Reorder handle_conflict to fire repository_dispatch first, post the SHA-keyed sentinel comment only after dispatch succeeds. If dispatch fails the comment is never written, so the next auto-rebase run for the same base SHA can retry cleanly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/auto-rebase-reusable.yml | 4 +++- scripts/auto-rebase.sh | 25 +++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/auto-rebase-reusable.yml b/.github/workflows/auto-rebase-reusable.yml index 28a0da9ce..5ad949f10 100644 --- a/.github/workflows/auto-rebase-reusable.yml +++ b/.github/workflows/auto-rebase-reusable.yml @@ -43,15 +43,17 @@ jobs: contents: write # needed for update-branch and repository_dispatch pull-requests: write # needed to post comments on PRs steps: - - name: Checkout repository + - name: Checkout petry-projects/.github (for scripts) uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + repository: petry-projects/.github fetch-depth: 1 - name: Update behind non-Dependabot PRs env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | + # Always sourced from petry-projects/.github regardless of caller repo. # shellcheck source=scripts/auto-rebase.sh source scripts/auto-rebase.sh diff --git a/scripts/auto-rebase.sh b/scripts/auto-rebase.sh index 1769d4bba..eb3872ac4 100644 --- a/scripts/auto-rebase.sh +++ b/scripts/auto-rebase.sh @@ -58,7 +58,21 @@ handle_conflict() { return 0 fi - echo " Posting conflict comment and dispatching claude-rebase for $base_branch@$base_sha" + echo " Dispatching claude-rebase for $base_branch@$base_sha and posting conflict comment" + + # Dispatch FIRST: if this fails the sentinel comment is never posted, + # so the next auto-rebase run for this base SHA can retry. + # repository_dispatch is one of two event types GITHUB_TOKEN IS allowed to + # trigger new workflow runs for (the other being workflow_dispatch). + gh api "repos/$REPO/dispatches" \ + -X POST \ + -f event_type=claude-rebase \ + -F "client_payload[pr_number]=$pr_number" \ + -F "client_payload[head_ref]=$head_ref" \ + -F "client_payload[base_branch]=$base_branch" + + # Post sentinel only after successful dispatch so the idempotency guard + # never permanently blocks retries when a prior dispatch failed. local body="$sentinel" body+=$'\n'"**Auto-rebase failed — merge conflict** — this branch conflicts" body+=" with \`$base_branch\` and cannot be updated via the merge strategy." @@ -69,13 +83,4 @@ handle_conflict() { body+=$'\n'"# resolve conflicts per file, then for each commit:" body+=$'\n'"git add "$'\n'"git rebase --continue"$'\n'"git push --force-with-lease"$'\n'"\`\`\`" gh pr comment "$pr_number" --repo "$REPO" --body "$body" - - # repository_dispatch is one of two event types that GITHUB_TOKEN IS allowed - # to trigger new workflow runs for (the other being workflow_dispatch). - gh api "repos/$REPO/dispatches" \ - -X POST \ - -f event_type=claude-rebase \ - -F "client_payload[pr_number]=$pr_number" \ - -F "client_payload[head_ref]=$head_ref" \ - -F "client_payload[base_branch]=$base_branch" } From a1db44f317e23ed9cc6de4cb0959175987f0723a Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Thu, 14 May 2026 06:37:19 -0500 Subject: [PATCH 4/5] ci: retrigger checks after auto-rebase onto updated main From 836f5466c3ffebeae85858f0269a40f93adbb565 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Thu, 14 May 2026 15:33:47 -0500 Subject: [PATCH 5/5] revert(claude.yml): remove repository_dispatch trigger from PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude.yml trigger change (adding repository_dispatch: [claude-rebase]) causes the expected OIDC validation failure on any PR that modifies claude.yml — the Anthropic token endpoint requires the file to be byte-for-byte identical to the default branch. The claude-code-reusable.yml job (claude-rebase) receives the event correctly as long as the CALLING repo's claude.yml declares the trigger. That update to each repo's thin stub is a follow-up compliance sweep, not a requirement for the reusable to work from .github itself. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/claude.yml | 2 -- standards/workflows/claude.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index a5250d42a..8f7c686d3 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -15,8 +15,6 @@ on: types: [labeled] check_run: types: [completed] - repository_dispatch: - types: [claude-rebase] permissions: {} diff --git a/standards/workflows/claude.yml b/standards/workflows/claude.yml index d4b2f9673..5e1f48f27 100644 --- a/standards/workflows/claude.yml +++ b/standards/workflows/claude.yml @@ -52,8 +52,6 @@ on: types: [labeled] check_run: types: [completed] - repository_dispatch: - types: [claude-rebase] permissions: {}