|
1 | | -name: Sync dev to main |
2 | 1 |
|
3 | | -# Automatically merge dev → main daily at 8 AM PST (16:00 UTC) |
4 | | -# Uses github-actions[bot] with bypass permissions to skip PR approval requirements |
5 | | -# Handles conflicts gracefully (logs warning, skips merge, doesn't fail) |
6 | | - |
7 | | -on: |
8 | | - schedule: |
9 | | - # Run daily at 8:00 AM PST (16:00 UTC) |
10 | | - - cron: '0 16 * * *' |
11 | | - |
12 | | - workflow_dispatch: # Allow manual triggering |
13 | | - |
14 | | -permissions: |
15 | | - contents: write # Push to main branch |
16 | | - issues: write # Create issues on conflict |
17 | | - actions: read # Check workflow status (optional) |
18 | | - |
19 | | -env: |
20 | | - SOURCE_BRANCH: dev |
21 | | - TARGET_BRANCH: main |
22 | | - # Teams webhook URL stored in GitHub Secrets for security |
23 | | - TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} |
24 | | - |
25 | | -jobs: |
26 | | - sync-branches: |
27 | | - runs-on: ubuntu-latest |
28 | | - |
29 | | - steps: |
30 | | - - name: Checkout repository |
31 | | - uses: actions/checkout@v4 |
32 | | - with: |
33 | | - fetch-depth: 0 # Full history for merge |
34 | | - token: ${{ secrets.GITHUB_TOKEN }} |
35 | | - |
36 | | - - name: Configure git |
37 | | - run: | |
38 | | - git config user.name "github-actions[bot]" |
39 | | - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" |
40 | | -
|
41 | | - - name: Check if dev has new commits |
42 | | - id: check-new-commits |
43 | | - run: | |
44 | | - git fetch origin ${{ env.SOURCE_BRANCH }} |
45 | | - git fetch origin ${{ env.TARGET_BRANCH }} |
46 | | -
|
47 | | - # Get commit counts |
48 | | - COMMITS_AHEAD=$(git rev-list --count origin/${{ env.TARGET_BRANCH }}..origin/${{ env.SOURCE_BRANCH }}) |
49 | | -
|
50 | | - echo "commits_ahead=$COMMITS_AHEAD" >> $GITHUB_OUTPUT |
51 | | -
|
52 | | - if [ "$COMMITS_AHEAD" -eq 0 ]; then |
53 | | - echo "ℹ️ No new commits in ${{ env.SOURCE_BRANCH }} - nothing to sync" |
54 | | - echo "has_new_commits=false" >> $GITHUB_OUTPUT |
55 | | - else |
56 | | - echo "✅ Found $COMMITS_AHEAD new commit(s) in ${{ env.SOURCE_BRANCH }}" |
57 | | - echo "has_new_commits=true" >> $GITHUB_OUTPUT |
58 | | - fi |
59 | | -
|
60 | | - - name: Wait for build workflow to complete |
61 | | - if: steps.check-new-commits.outputs.has_new_commits == 'true' |
62 | | - run: | |
63 | | - echo "⏳ Waiting for 'build-and-deploy.yml' workflow to complete on ${{ env.SOURCE_BRANCH }}..." |
64 | | -
|
65 | | - MAX_WAIT=1800 # 30 minutes max wait |
66 | | - WAIT_INTERVAL=30 # Check every 30 seconds |
67 | | - ELAPSED=0 |
68 | | -
|
69 | | - while [ $ELAPSED -lt $MAX_WAIT ]; do |
70 | | - # Get latest build workflow run on dev |
71 | | - BUILD_RUN=$(gh run list --workflow="build-and-deploy.yml" \ |
72 | | - --branch ${{ env.SOURCE_BRANCH }} --limit 1 --json status,conclusion,databaseId \ |
73 | | - --jq '.[0]' 2>/dev/null || echo '{}') |
74 | | -
|
75 | | - STATUS=$(echo "$BUILD_RUN" | jq -r '.status // "unknown"') |
76 | | - CONCLUSION=$(echo "$BUILD_RUN" | jq -r '.conclusion // "none"') |
77 | | - RUN_ID=$(echo "$BUILD_RUN" | jq -r '.databaseId // "none"') |
78 | | -
|
79 | | - echo "Build status: $STATUS | Conclusion: $CONCLUSION | Run ID: $RUN_ID" |
80 | | -
|
81 | | - # Check if build completed |
82 | | - if [ "$STATUS" = "completed" ]; then |
83 | | - if [ "$CONCLUSION" = "success" ]; then |
84 | | - echo "✅ Build workflow completed successfully!" |
85 | | - break |
86 | | - elif [ "$CONCLUSION" = "failure" ]; then |
87 | | - echo "❌ Build workflow failed on ${{ env.SOURCE_BRANCH }}" |
88 | | - echo "⚠️ Cannot sync to main - build must pass first" |
89 | | - exit 1 |
90 | | - elif [ "$CONCLUSION" = "cancelled" ]; then |
91 | | - echo "⚠️ Build workflow was cancelled" |
92 | | - echo "⚠️ Cannot sync to main - build must complete successfully" |
93 | | - exit 1 |
94 | | - else |
95 | | - echo "⚠️ Build completed with conclusion: $CONCLUSION" |
96 | | - exit 1 |
97 | | - fi |
98 | | - fi |
99 | | -
|
100 | | - # Build still in progress |
101 | | - echo "⏳ Build still running... waiting ${WAIT_INTERVAL}s (${ELAPSED}s elapsed)" |
102 | | - sleep $WAIT_INTERVAL |
103 | | - ELAPSED=$((ELAPSED + WAIT_INTERVAL)) |
104 | | - done |
105 | | -
|
106 | | - if [ $ELAPSED -ge $MAX_WAIT ]; then |
107 | | - echo "❌ Timeout: Build did not complete within ${MAX_WAIT}s" |
108 | | - exit 1 |
109 | | - fi |
110 | | - env: |
111 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
112 | | - |
113 | | - - name: Attempt merge |
114 | | - id: merge |
115 | | - if: steps.check-new-commits.outputs.has_new_commits == 'true' |
116 | | - run: | |
117 | | - echo "🔄 Attempting to merge ${{ env.SOURCE_BRANCH }} → ${{ env.TARGET_BRANCH }}..." |
118 | | -
|
119 | | - # Checkout target branch |
120 | | - git checkout ${{ env.TARGET_BRANCH }} |
121 | | -
|
122 | | - # Attempt merge (don't fail on conflict) |
123 | | - set +e |
124 | | - git merge --no-edit --no-ff origin/${{ env.SOURCE_BRANCH }} 2>&1 | tee merge_output.txt |
125 | | - MERGE_EXIT_CODE=$? |
126 | | - set -e |
127 | | -
|
128 | | - if [ $MERGE_EXIT_CODE -ne 0 ]; then |
129 | | - echo "❌ Merge conflict detected" |
130 | | - echo "conflict=true" >> $GITHUB_OUTPUT |
131 | | -
|
132 | | - # Get conflicted files |
133 | | - git status --short | grep '^UU\|^AA\|^DD' > conflicts.txt || echo "Unable to detect specific files" > conflicts.txt |
134 | | -
|
135 | | - # Abort the merge |
136 | | - git merge --abort |
137 | | -
|
138 | | - echo "⚠️ Skipping automated merge due to conflicts" |
139 | | - exit 0 # Exit success to allow graceful handling |
140 | | - else |
141 | | - echo "✅ Merge completed successfully" |
142 | | - echo "conflict=false" >> $GITHUB_OUTPUT |
143 | | - fi |
144 | | -
|
145 | | - - name: Push to main |
146 | | - if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'false' |
147 | | - run: | |
148 | | - echo "📤 Pushing merged changes to ${{ env.TARGET_BRANCH }}..." |
149 | | -
|
150 | | - # Push directly to main (bypass branch protection via github-actions[bot]) |
151 | | - git push origin ${{ env.TARGET_BRANCH }} |
152 | | -
|
153 | | - echo "✅ Successfully synced ${{ env.SOURCE_BRANCH }} → ${{ env.TARGET_BRANCH }}" |
154 | | -
|
155 | | - - name: Get merge details for notification |
156 | | - if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'false' |
157 | | - id: merge-details |
158 | | - run: | |
159 | | - # Get commit details |
160 | | - COMMIT_COUNT=${{ steps.check-new-commits.outputs.commits_ahead }} |
161 | | - COMMIT_SHA=$(git rev-parse HEAD | cut -c1-7) |
162 | | -
|
163 | | - # Get commit messages |
164 | | - git log --oneline origin/${{ env.TARGET_BRANCH }}~$COMMIT_COUNT..origin/${{ env.TARGET_BRANCH }} | head -10 > commits.txt |
165 | | -
|
166 | | - echo "commit_count=$COMMIT_COUNT" >> $GITHUB_OUTPUT |
167 | | - echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT |
168 | | -
|
169 | | - - name: Send success notification to Teams |
170 | | - if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'false' |
171 | | - run: | |
172 | | - # Get commit messages (truncate if too long) |
173 | | - COMMITS=$(cat commits.txt | head -5 | sed 's/^/ - /') |
174 | | -
|
175 | | - curl -H "Content-Type: application/json" -d '{ |
176 | | - "@type": "MessageCard", |
177 | | - "@context": "https://schema.org/extensions", |
178 | | - "summary": "Dev → Main Sync Successful", |
179 | | - "themeColor": "28a745", |
180 | | - "title": "✅ Automated Sync: dev → main", |
181 | | - "sections": [{ |
182 | | - "activityTitle": "Successfully merged '${{ steps.check-new-commits.outputs.commits_ahead }}' commits", |
183 | | - "activitySubtitle": "Repository: netwrix/docs", |
184 | | - "facts": [ |
185 | | - {"name": "Commits merged:", "value": "${{ steps.merge-details.outputs.commit_count }}"}, |
186 | | - {"name": "Latest commit:", "value": "${{ steps.merge-details.outputs.commit_sha }}"}, |
187 | | - {"name": "Triggered by:", "value": "${{ github.event_name }}"}, |
188 | | - {"name": "Workflow:", "value": "${{ github.workflow }}"} |
189 | | - ], |
190 | | - "text": "**Recent commits:**\n\n'"$(echo "$COMMITS" | sed 's/$/\\n/' | tr -d '\n')"'" |
191 | | - }], |
192 | | - "potentialAction": [{ |
193 | | - "@type": "OpenUri", |
194 | | - "name": "View Workflow Run", |
195 | | - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}] |
196 | | - }, { |
197 | | - "@type": "OpenUri", |
198 | | - "name": "View Repository", |
199 | | - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}"}] |
200 | | - }] |
201 | | - }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" |
202 | | -
|
203 | | - - name: Create GitHub issue for conflict |
204 | | - if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'true' |
205 | | - uses: actions/github-script@v7 |
206 | | - with: |
207 | | - script: | |
208 | | - const conflictedFiles = require('fs').readFileSync('conflicts.txt', 'utf8'); |
209 | | - const mergeOutput = require('fs').readFileSync('merge_output.txt', 'utf8'); |
210 | | -
|
211 | | - const issue = await github.rest.issues.create({ |
212 | | - owner: context.repo.owner, |
213 | | - repo: context.repo.repo, |
214 | | - title: '⚠️ Automated dev→main sync blocked by merge conflict', |
215 | | - labels: ['automated-sync', 'merge-conflict'], |
216 | | - body: '## Automated Sync Conflict\n\n' + |
217 | | - 'The automated sync from `dev` to `main` encountered merge conflicts and was skipped.\n\n' + |
218 | | - '**Workflow Run:** ' + context.serverUrl + '/' + context.repo.owner + '/' + context.repo.repo + '/actions/runs/' + context.runId + '\n' + |
219 | | - '**Triggered by:** ' + context.eventName + '\n' + |
220 | | - '**Date:** ' + new Date().toISOString() + '\n\n' + |
221 | | - '### Conflicted Files\n\n' + |
222 | | - '```\n' + |
223 | | - conflictedFiles + '\n' + |
224 | | - '```\n\n' + |
225 | | - '### Merge Output\n\n' + |
226 | | - '```\n' + |
227 | | - mergeOutput + '\n' + |
228 | | - '```\n\n' + |
229 | | - '### Resolution Steps\n\n' + |
230 | | - '1. Manually resolve conflicts:\n' + |
231 | | - ' ```bash\n' + |
232 | | - ' git checkout main\n' + |
233 | | - ' git pull origin main\n' + |
234 | | - ' git merge origin/dev\n' + |
235 | | - ' # Resolve conflicts in your editor\n' + |
236 | | - ' git add .\n' + |
237 | | - ' git commit\n' + |
238 | | - ' git push origin main\n' + |
239 | | - ' ```\n\n' + |
240 | | - '2. Or create a PR from dev → main and resolve conflicts there\n\n' + |
241 | | - '3. Once resolved, the next scheduled sync will proceed normally\n\n' + |
242 | | - '---\n' + |
243 | | - '*This issue was created automatically by the sync-dev-to-main workflow.*' |
244 | | - }); |
245 | | -
|
246 | | - console.log('Created issue #' + issue.data.number); |
247 | | -
|
248 | | - - name: Send conflict notification to Teams |
249 | | - if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'true' |
250 | | - run: | |
251 | | - curl -H "Content-Type: application/json" -d '{ |
252 | | - "@type": "MessageCard", |
253 | | - "@context": "https://schema.org/extensions", |
254 | | - "summary": "Dev → Main Sync Blocked by Conflict", |
255 | | - "themeColor": "ff9800", |
256 | | - "title": "⚠️ Automated Sync Blocked: Merge Conflict", |
257 | | - "sections": [{ |
258 | | - "activityTitle": "Manual intervention required", |
259 | | - "activitySubtitle": "Repository: netwrix/docs", |
260 | | - "facts": [ |
261 | | - {"name": "Source branch:", "value": "${{ env.SOURCE_BRANCH }}"}, |
262 | | - {"name": "Target branch:", "value": "${{ env.TARGET_BRANCH }}"}, |
263 | | - {"name": "Commits waiting:", "value": "${{ steps.check-new-commits.outputs.commits_ahead }}"}, |
264 | | - {"name": "Status:", "value": "Merge conflict detected"} |
265 | | - ], |
266 | | - "text": "The automated sync encountered merge conflicts. A GitHub issue has been created with details. Please resolve the conflicts manually." |
267 | | - }], |
268 | | - "potentialAction": [{ |
269 | | - "@type": "OpenUri", |
270 | | - "name": "View Workflow Run", |
271 | | - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}] |
272 | | - }, { |
273 | | - "@type": "OpenUri", |
274 | | - "name": "View Issues", |
275 | | - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}/issues?q=is:issue+is:open+label:merge-conflict"}] |
276 | | - }] |
277 | | - }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" |
278 | | -
|
279 | | - - name: Send skip notification to Teams |
280 | | - if: steps.check-new-commits.outputs.has_new_commits == 'false' |
281 | | - run: | |
282 | | - curl -H "Content-Type: application/json" -d '{ |
283 | | - "@type": "MessageCard", |
284 | | - "@context": "https://schema.org/extensions", |
285 | | - "summary": "No Changes to Sync", |
286 | | - "themeColor": "0078D4", |
287 | | - "title": "ℹ️ No Changes to Sync", |
288 | | - "sections": [{ |
289 | | - "activityTitle": "Branches are already in sync", |
290 | | - "activitySubtitle": "Repository: netwrix/docs", |
291 | | - "text": "No new commits found in dev branch. Nothing to sync." |
292 | | - }] |
293 | | - }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" |
294 | | -
|
295 | | - - name: Workflow summary |
296 | | - if: always() |
297 | | - run: | |
298 | | - echo "## Sync Dev to Main - Summary" >> $GITHUB_STEP_SUMMARY |
299 | | - echo "" >> $GITHUB_STEP_SUMMARY |
300 | | -
|
301 | | - if [ "${{ steps.check-new-commits.outputs.has_new_commits }}" == "false" ]; then |
302 | | - echo "ℹ️ **Status:** No new commits to sync" >> $GITHUB_STEP_SUMMARY |
303 | | - echo "- Source: \`${{ env.SOURCE_BRANCH }}\`" >> $GITHUB_STEP_SUMMARY |
304 | | - echo "- Target: \`${{ env.TARGET_BRANCH }}\`" >> $GITHUB_STEP_SUMMARY |
305 | | - elif [ "${{ steps.merge.outputs.conflict }}" == "true" ]; then |
306 | | - echo "⚠️ **Status:** Merge conflict detected" >> $GITHUB_STEP_SUMMARY |
307 | | - echo "- Commits waiting: ${{ steps.check-new-commits.outputs.commits_ahead }}" >> $GITHUB_STEP_SUMMARY |
308 | | - echo "- Action: GitHub issue created" >> $GITHUB_STEP_SUMMARY |
309 | | - echo "- Resolution: Manual merge required" >> $GITHUB_STEP_SUMMARY |
310 | | - else |
311 | | - echo "✅ **Status:** Successfully synced" >> $GITHUB_STEP_SUMMARY |
312 | | - echo "- Commits merged: ${{ steps.merge-details.outputs.commit_count }}" >> $GITHUB_STEP_SUMMARY |
313 | | - echo "- Latest commit: \`${{ steps.merge-details.outputs.commit_sha }}\`" >> $GITHUB_STEP_SUMMARY |
314 | | - echo "- Pushed to: \`${{ env.TARGET_BRANCH }}\`" >> $GITHUB_STEP_SUMMARY |
315 | | - fi |
316 | | -
|
317 | | - echo "" >> $GITHUB_STEP_SUMMARY |
318 | | - echo "---" >> $GITHUB_STEP_SUMMARY |
319 | | - echo "*Automated by github-actions[bot] with branch protection bypass*" >> $GITHUB_STEP_SUMMARY |
0 commit comments