Skip to content

Commit 798ae44

Browse files
samuelho-devclaude
andcommitted
feat: add sync-main-to-dev reusable workflow
Keeps a target branch (e.g. dev) in sync with a source branch (e.g. main) using a three-tier strategy: fast-forward, merge commit, or PR on conflict. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7dd8a89 commit 798ae44

3 files changed

Lines changed: 364 additions & 3 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ Production-grade, vetted GitHub Actions workflows for Kubernetes GitOps infrastr
5656
| [`gitops-update-manifests.yml`](.github/workflows/gitops-update-manifests.yml) | Update Kubernetes manifests (image tags, Helm values) | ✅ Ready |
5757
| [`argocd-sync.yml`](.github/workflows/argocd-sync.yml) | ArgoCD application sync with health checks | ✅ Ready |
5858

59+
### Git Workflows
60+
61+
| Workflow | Description | Status |
62+
|----------|-------------|--------|
63+
| [`sync-main-to-dev.yml`](.github/workflows/sync-main-to-dev.yml) | Sync source branch to target branch (ff → merge → PR) | ✅ Ready |
64+
5965
### Composite Actions
6066

6167
| Action | Description | Status |

docs/USAGE.md

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ Complete guide for using git-flow reusable workflows in your projects.
1010
4. [Kubernetes Workflows](#kubernetes-workflows)
1111
5. [Infrastructure Workflows](#infrastructure-workflows)
1212
6. [GitOps Workflows](#gitops-workflows)
13-
7. [Composite Actions](#composite-actions)
14-
8. [Best Practices](#best-practices)
15-
9. [Troubleshooting](#troubleshooting)
13+
7. [Git Workflows](#git-workflows)
14+
8. [Composite Actions](#composite-actions)
15+
9. [Best Practices](#best-practices)
16+
10. [Troubleshooting](#troubleshooting)
1617

1718
---
1819

@@ -937,6 +938,112 @@ jobs:
937938

938939
---
939940

941+
## Git Workflows
942+
943+
### `sync-main-to-dev.yml`
944+
945+
Keep a target branch (e.g. `dev`) in sync with a source branch (e.g. `main`). The workflow tries the cleanest merge strategy first and escalates only when needed:
946+
947+
1. **Fast-forward** — no merge commit noise
948+
2. **Merge commit** — when target has diverged
949+
3. **Open a PR** — when there are merge conflicts that require human resolution
950+
951+
#### Inputs
952+
953+
| Input | Type | Default | Description |
954+
|-------|------|---------|-------------|
955+
| `source-branch` | string | `main` | Branch to sync from |
956+
| `target-branch` | string | `dev` | Branch to sync to |
957+
| `create-pr-on-conflict` | boolean | `true` | Open a PR if merge conflicts prevent automatic sync |
958+
| `pr-labels` | string | `automated,sync` | Comma-separated labels for conflict PRs |
959+
960+
#### Secrets
961+
962+
| Secret | Required | Description |
963+
|--------|----------|-------------|
964+
| `token` | No | GitHub token with repo write access (falls back to `GITHUB_TOKEN`) |
965+
966+
#### Outputs
967+
968+
| Output | Description |
969+
|--------|-------------|
970+
| `result` | Sync result: `fast-forward`, `merged`, `pr-created`, `up-to-date`, or `failed` |
971+
| `pr-number` | PR number if a conflict PR was created |
972+
973+
#### Examples
974+
975+
**Basic — sync main to dev on every push:**
976+
```yaml
977+
name: Sync main to dev
978+
979+
on:
980+
push:
981+
branches: [main]
982+
983+
jobs:
984+
sync:
985+
uses: samuelho-dev/git-flow/.github/workflows/sync-main-to-dev.yml@v1
986+
permissions:
987+
contents: write
988+
pull-requests: write
989+
```
990+
991+
**Custom branches:**
992+
```yaml
993+
jobs:
994+
sync:
995+
uses: samuelho-dev/git-flow/.github/workflows/sync-main-to-dev.yml@v1
996+
with:
997+
source-branch: release
998+
target-branch: develop
999+
permissions:
1000+
contents: write
1001+
pull-requests: write
1002+
```
1003+
1004+
**With a PAT for protected branches:**
1005+
```yaml
1006+
jobs:
1007+
sync:
1008+
uses: samuelho-dev/git-flow/.github/workflows/sync-main-to-dev.yml@v1
1009+
permissions:
1010+
contents: write
1011+
pull-requests: write
1012+
secrets:
1013+
token: ${{ secrets.SYNC_PAT }}
1014+
```
1015+
1016+
**Disable PR creation on conflict:**
1017+
```yaml
1018+
jobs:
1019+
sync:
1020+
uses: samuelho-dev/git-flow/.github/workflows/sync-main-to-dev.yml@v1
1021+
with:
1022+
create-pr-on-conflict: false
1023+
permissions:
1024+
contents: write
1025+
pull-requests: write
1026+
```
1027+
1028+
**Act on the result in a downstream job:**
1029+
```yaml
1030+
jobs:
1031+
sync:
1032+
uses: samuelho-dev/git-flow/.github/workflows/sync-main-to-dev.yml@v1
1033+
permissions:
1034+
contents: write
1035+
pull-requests: write
1036+
1037+
notify:
1038+
needs: sync
1039+
if: needs.sync.outputs.result == 'pr-created'
1040+
runs-on: ubuntu-latest
1041+
steps:
1042+
- run: echo "Conflict PR #${{ needs.sync.outputs.pr-number }} needs review"
1043+
```
1044+
1045+
---
1046+
9401047
## Composite Actions
9411048

9421049
### `setup-node-pnpm`

0 commit comments

Comments
 (0)