Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions .github/workflows/bidirectional-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
name: Bidirectional Sync

on:
push:
branches:
- main
workflow_dispatch:
inputs: {}

permissions:
contents: write

jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Get other repo URL
id: repo
run: |
if [[ "${{ github.repository }}" == "ExaDev/claude-code-action" ]]; then
echo "target=adpeak/claude-code-action" >> "$GITHUB_OUTPUT"
echo "app_id=${{ vars.EXADEV_APP_ID }}" >> "$GITHUB_OUTPUT"
else
echo "target=ExaDev/claude-code-action" >> "$GITHUB_OUTPUT"
echo "app_id=${{ vars.ADPEAK_APP_ID }}" >> "$GITHUB_OUTPUT"
fi

- name: Check if already a sync commit
id: check
run: |
if git log --format="%s" -1 | grep -q "\[sync from"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop detection only checks the most recent commit message. If someone manually pushes multiple commits where only an earlier one has the marker, this will still trigger a sync and potentially create a loop.

A safer approach: check the entire range of new commits since the last sync, or use a more robust marker like a git note or workflow run ID.

echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping sync commit to avoid loop"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Verify required configuration
if: steps.check.outputs.skip != 'true'
run: |
if [[ -z "${{ secrets.SYNC_PRIVATE_KEY }}" ]]; then
echo "::error::SYNC_PRIVATE_KEY secret is not set. Please configure it in repo settings."
exit 1
fi
if [[ -z "${{ steps.repo.outputs.app_id }}" ]]; then
echo "::error::GitHub App ID variable not set. Configure EXADEV_APP_ID or ADPEAK_APP_ID."
exit 1
fi

- name: Generate GitHub App token for other repo
if: steps.check.outputs.skip != 'true'
uses: tibdex/github-app-token@v2
id: token
with:
app_id: ${{ steps.repo.outputs.app_id }}
private_key: ${{ secrets.SYNC_PRIVATE_KEY }}

- name: Sync to other repo
id: sync
if: steps.check.outputs.skip != 'true'
env:
TARGET_REPO: ${{ steps.repo.outputs.target }}
TARGET_TOKEN: ${{ steps.token.outputs.token }}
SOURCE_REPO: ${{ github.repository }}
run: |
# Set up remote for target repo
git remote add target "https://x-access-token:${TARGET_TOKEN}@github.com/${TARGET_REPO}.git"

# Fetch target's main to check if we're ahead
git fetch target main

# Count commits we have that target doesn't
COMMITS_BEHIND=$(git log --oneline HEAD ^target/main | wc -l | tr -d ' ')

echo "Commits we have that target doesn't: $COMMITS_BEHIND"

if [[ "$COMMITS_BEHIND" -eq 0 ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::notice::Target repo is already up to date"
exit 0
fi

# Get short SHA and timestamp for branch naming
SHA=$(git rev-parse --short HEAD)
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="sync/${SOURCE_REPO}/${TIMESTAMP}-${SHA}"

# Try to rebase our commits on top of target/main
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

if git rebase target/main 2>/dev/null; then
# Rebase successful - push directly to main
git push target HEAD:main

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a successful rebase and push, there's no commit message marker added to prevent loops. When the push to target triggers its own sync workflow, it will attempt to sync back, creating an infinite loop despite the check on line 34.

The workflow needs to either:

  1. Add a [sync from SOURCE] marker in the commit message when pushing
  2. Use a different mechanism to prevent bidirectional ping-pong

echo "skip_pr=true" >> "$GITHUB_OUTPUT"
echo "::notice::Successfully rebased and synced ${COMMITS_BEHIND} commit(s) to ${TARGET_REPO}"
else
# Rebase failed - abort and create PR
git rebase --abort
echo "skip_pr=false" >> "$GITHUB_OUTPUT"
echo "branch_name=${BRANCH_NAME}" >> "$GITHUB_OUTPUT"

# Push the conflicting branch
git push target HEAD:refs/heads/${BRANCH_NAME}

echo "::warning::Rebase conflict - created branch ${BRANCH_NAME} for manual resolution"
fi

- name: Create PR for conflict resolution
if: steps.sync.outputs.skip_pr == 'false'
env:
TARGET_TOKEN: ${{ steps.token.outputs.token }}
TARGET_REPO: ${{ steps.repo.outputs.target }}
BRANCH: ${{ steps.sync.outputs.branch_name }}
SOURCE: ${{ github.repository }}
run: |
export GH_TOKEN="${TARGET_TOKEN}"

# Get commit count for PR description
COMMITS=$(git log --oneline target/main..HEAD | wc -l | tr -d ' ')

# Create PR with conflict marker
gh pr create \
--repo "${TARGET_REPO}" \
--base main \
--head "${BRANCH}" \
--title "sync: resolve merge conflicts from ${SOURCE}" \
--body "Automatic sync from **${SOURCE}** encountered merge conflicts and requires manual resolution.

## What to do
1. Review the conflicts in the Files changed tab
2. Resolve conflicts locally:
\`\`\`bash
git fetch origin ${BRANCH}
git checkout ${BRANCH}
# Fix conflicts, then:
git add .
git rebase --continue
git push origin ${BRANCH}
\`\`\`
3. Merge the PR once resolved

---
*Created automatically by bidirectional-sync workflow*"

echo "::notice::Created PR for conflict resolution"

- name: Cleanup old sync branches
if: steps.sync.outputs.skip != 'true'

@claude claude Bot Feb 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup step condition only checks steps.sync.outputs.skip but also needs steps.check.outputs.skip because it references steps.token.outputs.token which is only generated when we don't skip early.

If we skip at line 34, the token step (line 53-59) never runs, so steps.token.outputs.token will be empty, causing the cleanup to fail.

Suggested change
if: steps.sync.outputs.skip != 'true'
- name: Cleanup old sync branches
if: steps.check.outputs.skip != 'true' && steps.sync.outputs.skip != 'true'

env:
TARGET_TOKEN: ${{ steps.token.outputs.token }}
TARGET_REPO: ${{ steps.repo.outputs.target }}
run: |
export GH_TOKEN="${TARGET_TOKEN}"
SEVEN_DAYS_AGO=$(python3 -c 'from datetime import datetime, timedelta; print((datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ"))')

# Delete sync branches older than 7 days
gh api repos/${TARGET_REPO}/git/refs/heads --paginate --jq '.[].ref' |
grep -E 'refs/heads/sync/' | while read -r ref; do
branch=${ref#refs/heads/}
# Get commit date safely
commit_date=$(gh api repos/${TARGET_REPO}/git/refs/heads/${branch} --jq '.object.sha' | xargs -I {} gh api repos/${TARGET_REPO}/git/commits/{} --jq '.author.date' 2>/dev/null || echo "")

if [[ -n "$commit_date" ]]; then
# Compare dates using Python for portability
is_old=$(python3 -c "from datetime import datetime; d = datetime.fromisoformat('$commit_date'.replace('Z', '+00:00')); print(d < datetime.fromisoformat('$SEVEN_DAYS_AGO').replace(tzinfo=d.tzinfo))")
if [[ "$is_old" == "True" ]]; then
if gh api --method DELETE repos/${TARGET_REPO}/git/refs/heads/${branch} 2>/dev/null; then
echo "Deleted old branch: $branch"
else
echo "::warning::Failed to delete branch: $branch"
fi
fi
fi
done