|
| 1 | +name: 'Sync Branch Forward' |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_call: |
| 5 | + inputs: |
| 6 | + source-branch: |
| 7 | + description: 'Branch to sync from' |
| 8 | + required: false |
| 9 | + type: string |
| 10 | + default: 'main' |
| 11 | + target-branch: |
| 12 | + description: 'Branch to sync to' |
| 13 | + required: false |
| 14 | + type: string |
| 15 | + default: 'dev' |
| 16 | + create-pr-on-conflict: |
| 17 | + description: 'Open a PR if merge conflicts prevent automatic sync' |
| 18 | + required: false |
| 19 | + type: boolean |
| 20 | + default: true |
| 21 | + pr-labels: |
| 22 | + description: 'Comma-separated labels for conflict PRs' |
| 23 | + required: false |
| 24 | + type: string |
| 25 | + default: 'automated,sync' |
| 26 | + secrets: |
| 27 | + token: |
| 28 | + description: 'GitHub token with repo write access (falls back to GITHUB_TOKEN)' |
| 29 | + required: false |
| 30 | + outputs: |
| 31 | + result: |
| 32 | + description: 'Sync result: fast-forward, merged, pr-created, up-to-date, or failed' |
| 33 | + value: ${{ jobs.sync.outputs.result }} |
| 34 | + pr-number: |
| 35 | + description: 'PR number if a conflict PR was created' |
| 36 | + value: ${{ jobs.sync.outputs.pr-number }} |
| 37 | + |
| 38 | +permissions: |
| 39 | + contents: write |
| 40 | + pull-requests: write |
| 41 | + |
| 42 | +jobs: |
| 43 | + sync: |
| 44 | + name: Sync ${{ inputs.source-branch }} to ${{ inputs.target-branch }} |
| 45 | + runs-on: ubuntu-latest |
| 46 | + timeout-minutes: 10 |
| 47 | + outputs: |
| 48 | + result: ${{ steps.result.outputs.result }} |
| 49 | + pr-number: ${{ steps.conflict-pr.outputs.pr-number }} |
| 50 | + |
| 51 | + steps: |
| 52 | + - name: Checkout repository |
| 53 | + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 |
| 54 | + with: |
| 55 | + fetch-depth: 0 |
| 56 | + token: ${{ secrets.token || github.token }} |
| 57 | + |
| 58 | + - name: Configure git |
| 59 | + shell: bash |
| 60 | + run: | |
| 61 | + git config user.name "github-actions[bot]" |
| 62 | + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
| 63 | +
|
| 64 | + - name: Check target branch exists |
| 65 | + id: check-target |
| 66 | + shell: bash |
| 67 | + run: | |
| 68 | + if git ls-remote --exit-code --heads origin "${{ inputs.target-branch }}" > /dev/null 2>&1; then |
| 69 | + echo "exists=true" >> "$GITHUB_OUTPUT" |
| 70 | + else |
| 71 | + echo "exists=false" >> "$GITHUB_OUTPUT" |
| 72 | + echo "::notice::Target branch '${{ inputs.target-branch }}' does not exist — it will be created from '${{ inputs.source-branch }}'." |
| 73 | + fi |
| 74 | +
|
| 75 | + - name: Create target branch from source |
| 76 | + if: steps.check-target.outputs.exists == 'false' |
| 77 | + shell: bash |
| 78 | + run: | |
| 79 | + git checkout -b "${{ inputs.target-branch }}" "origin/${{ inputs.source-branch }}" |
| 80 | + git push origin "${{ inputs.target-branch }}" |
| 81 | + echo "::notice::Created '${{ inputs.target-branch }}' from '${{ inputs.source-branch }}'." |
| 82 | +
|
| 83 | + - name: Compare branches |
| 84 | + if: steps.check-target.outputs.exists == 'true' |
| 85 | + id: compare |
| 86 | + shell: bash |
| 87 | + run: | |
| 88 | + BEHIND=$(git rev-list --count "origin/${{ inputs.target-branch }}..origin/${{ inputs.source-branch }}") |
| 89 | + AHEAD=$(git rev-list --count "origin/${{ inputs.source-branch }}..origin/${{ inputs.target-branch }}") |
| 90 | + echo "behind=$BEHIND" >> "$GITHUB_OUTPUT" |
| 91 | + echo "ahead=$AHEAD" >> "$GITHUB_OUTPUT" |
| 92 | + echo "Source is $BEHIND commit(s) ahead of target; target is $AHEAD commit(s) ahead of source." |
| 93 | +
|
| 94 | + - name: Exit early if up-to-date |
| 95 | + if: steps.check-target.outputs.exists == 'true' && steps.compare.outputs.behind == '0' |
| 96 | + id: up-to-date |
| 97 | + shell: bash |
| 98 | + run: | |
| 99 | + echo "::notice::${{ inputs.target-branch }} is already up-to-date with ${{ inputs.source-branch }}." |
| 100 | +
|
| 101 | + - name: Attempt fast-forward merge |
| 102 | + if: steps.check-target.outputs.exists == 'true' && steps.compare.outputs.behind != '0' |
| 103 | + id: fast-forward |
| 104 | + shell: bash |
| 105 | + run: | |
| 106 | + git checkout "${{ inputs.target-branch }}" |
| 107 | + if git merge --ff-only "origin/${{ inputs.source-branch }}"; then |
| 108 | + echo "success=true" >> "$GITHUB_OUTPUT" |
| 109 | + else |
| 110 | + echo "success=false" >> "$GITHUB_OUTPUT" |
| 111 | + fi |
| 112 | +
|
| 113 | + - name: Attempt merge commit |
| 114 | + if: steps.fast-forward.outputs.success == 'false' |
| 115 | + id: merge-commit |
| 116 | + shell: bash |
| 117 | + run: | |
| 118 | + # Reset working tree from failed ff attempt |
| 119 | + git reset --hard "origin/${{ inputs.target-branch }}" |
| 120 | + if git merge --no-edit "origin/${{ inputs.source-branch }}"; then |
| 121 | + echo "success=true" >> "$GITHUB_OUTPUT" |
| 122 | + else |
| 123 | + echo "success=false" >> "$GITHUB_OUTPUT" |
| 124 | + git merge --abort |
| 125 | + fi |
| 126 | +
|
| 127 | + - name: Push merged changes |
| 128 | + if: steps.fast-forward.outputs.success == 'true' || steps.merge-commit.outputs.success == 'true' |
| 129 | + shell: bash |
| 130 | + run: | |
| 131 | + git push origin "${{ inputs.target-branch }}" |
| 132 | +
|
| 133 | + - name: Open conflict PR |
| 134 | + if: steps.merge-commit.outputs.success == 'false' && inputs.create-pr-on-conflict |
| 135 | + id: conflict-pr |
| 136 | + shell: bash |
| 137 | + env: |
| 138 | + GH_TOKEN: ${{ secrets.token || github.token }} |
| 139 | + run: | |
| 140 | + SYNC_BRANCH="sync/${{ inputs.source-branch }}-to-${{ inputs.target-branch }}" |
| 141 | +
|
| 142 | + # Clean up stale sync branch if it exists |
| 143 | + git branch -D "$SYNC_BRANCH" 2>/dev/null || true |
| 144 | + git push origin --delete "$SYNC_BRANCH" 2>/dev/null || true |
| 145 | +
|
| 146 | + # Create sync branch from source |
| 147 | + git checkout -b "$SYNC_BRANCH" "origin/${{ inputs.source-branch }}" |
| 148 | + git push origin "$SYNC_BRANCH" |
| 149 | +
|
| 150 | + # Build label args as an array |
| 151 | + LABEL_ARGS=() |
| 152 | + IFS=',' read -ra LABELS <<< "${{ inputs.pr-labels }}" |
| 153 | + for label in "${LABELS[@]}"; do |
| 154 | + trimmed="${label## }" |
| 155 | + trimmed="${trimmed%% }" |
| 156 | + if [ -n "$trimmed" ]; then |
| 157 | + LABEL_ARGS+=(--label "$trimmed") |
| 158 | + fi |
| 159 | + done |
| 160 | +
|
| 161 | + PR_BODY="## Automatic sync failed — merge conflicts detected |
| 162 | +
|
| 163 | + This PR was created because \`${{ inputs.source-branch }}\` could not be automatically merged into \`${{ inputs.target-branch }}\`. |
| 164 | +
|
| 165 | + **Please resolve the conflicts and merge this PR to complete the sync.** |
| 166 | +
|
| 167 | + --- |
| 168 | + *Created by [sync-main-to-dev](https://github.com/samuelho-dev/git-flow) workflow*" |
| 169 | + # Strip leading whitespace added by YAML indentation |
| 170 | + PR_BODY="${PR_BODY// /}" |
| 171 | +
|
| 172 | + PR_URL=$(gh pr create \ |
| 173 | + --base "${{ inputs.target-branch }}" \ |
| 174 | + --head "$SYNC_BRANCH" \ |
| 175 | + --title "sync: merge ${{ inputs.source-branch }} into ${{ inputs.target-branch }}" \ |
| 176 | + --body "$PR_BODY" \ |
| 177 | + "${LABEL_ARGS[@]}" 2>&1) || true |
| 178 | +
|
| 179 | + PR_NUMBER=$(echo "$PR_URL" | grep -oP '/pull/\K\d+' || echo "") |
| 180 | + echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT" |
| 181 | + echo "pr-url=$PR_URL" >> "$GITHUB_OUTPUT" |
| 182 | +
|
| 183 | + if [ -n "$PR_NUMBER" ]; then |
| 184 | + echo "::notice::Opened conflict PR #$PR_NUMBER: $PR_URL" |
| 185 | + else |
| 186 | + echo "::warning::Failed to create conflict PR. Output: $PR_URL" |
| 187 | + fi |
| 188 | +
|
| 189 | + - name: Determine result |
| 190 | + id: result |
| 191 | + if: always() |
| 192 | + shell: bash |
| 193 | + run: | |
| 194 | + if [ "${{ steps.check-target.outputs.exists }}" = "false" ]; then |
| 195 | + echo "result=fast-forward" >> "$GITHUB_OUTPUT" |
| 196 | + elif [ "${{ steps.compare.outputs.behind }}" = "0" ]; then |
| 197 | + echo "result=up-to-date" >> "$GITHUB_OUTPUT" |
| 198 | + elif [ "${{ steps.fast-forward.outputs.success }}" = "true" ]; then |
| 199 | + echo "result=fast-forward" >> "$GITHUB_OUTPUT" |
| 200 | + elif [ "${{ steps.merge-commit.outputs.success }}" = "true" ]; then |
| 201 | + echo "result=merged" >> "$GITHUB_OUTPUT" |
| 202 | + elif [ -n "${{ steps.conflict-pr.outputs.pr-number }}" ]; then |
| 203 | + echo "result=pr-created" >> "$GITHUB_OUTPUT" |
| 204 | + else |
| 205 | + echo "result=failed" >> "$GITHUB_OUTPUT" |
| 206 | + fi |
| 207 | +
|
| 208 | + - name: Generate step summary |
| 209 | + if: always() |
| 210 | + shell: bash |
| 211 | + run: | |
| 212 | + RESULT="${{ steps.result.outputs.result }}" |
| 213 | + { |
| 214 | + echo "## Sync ${{ inputs.source-branch }} → ${{ inputs.target-branch }}" |
| 215 | + echo "" |
| 216 | + } >> "$GITHUB_STEP_SUMMARY" |
| 217 | +
|
| 218 | + case "$RESULT" in |
| 219 | + up-to-date) |
| 220 | + echo "**Result:** Already up-to-date" >> "$GITHUB_STEP_SUMMARY" |
| 221 | + ;; |
| 222 | + fast-forward) |
| 223 | + echo "**Result:** Fast-forward merge" >> "$GITHUB_STEP_SUMMARY" |
| 224 | + ;; |
| 225 | + merged) |
| 226 | + echo "**Result:** Merge commit created" >> "$GITHUB_STEP_SUMMARY" |
| 227 | + ;; |
| 228 | + pr-created) |
| 229 | + { |
| 230 | + echo "**Result:** Conflict PR created" |
| 231 | + echo "" |
| 232 | + echo "**PR:** #${{ steps.conflict-pr.outputs.pr-number }}" |
| 233 | + } >> "$GITHUB_STEP_SUMMARY" |
| 234 | + ;; |
| 235 | + failed) |
| 236 | + echo "**Result:** Sync failed" >> "$GITHUB_STEP_SUMMARY" |
| 237 | + ;; |
| 238 | + esac |
| 239 | +
|
| 240 | + if [ "${{ steps.compare.outputs.behind }}" != "" ] && [ "${{ steps.compare.outputs.behind }}" != "0" ]; then |
| 241 | + { |
| 242 | + echo "" |
| 243 | + echo "| Metric | Value |" |
| 244 | + echo "|--------|-------|" |
| 245 | + echo "| Commits to sync | ${{ steps.compare.outputs.behind }} |" |
| 246 | + echo "| Target ahead by | ${{ steps.compare.outputs.ahead }} |" |
| 247 | + } >> "$GITHUB_STEP_SUMMARY" |
| 248 | + fi |
0 commit comments