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
109 changes: 109 additions & 0 deletions .github/workflows/sync-dev-from-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: Sync dev from main

# Purpose
# -------
# The workflow enforces the AGENTS.md rule "immediately fast-forward dev
# from main after a hotfix". Every hotfix that lands on main used to
# require a manual `git checkout dev && git merge --ff-only main && git
# push` step; that step was skipped often enough for dev to drift 100+
# commits behind main, silently rotting the staging environment.
#
# On every push to main this job:
# 1. Tries a fast-forward of dev to main. If it succeeds, dev is now
# identical to main and the staging deploy fires as usual (its
# workflow triggers on push to dev).
# 2. If a fast-forward isn't possible (dev has commits main doesn't),
# the job attempts a real merge commit. Clean auto-merge = pushed
# straight to dev.
# 3. Only when git itself can't resolve the merge (real conflicts) do
# we fall back to opening a "sync/main-to-dev-<sha>" branch + a PR
# against dev. A human resolves those conflicts by merging the PR.
#
# The job is intentionally lenient: pushing to dev directly is normally
# forbidden (AGENTS.md), but this bot is the one exception because it
# only carries commits that main has already accepted.

on:
push:
branches:
- main
workflow_dispatch:

concurrency:
group: sync-dev-from-main
cancel-in-progress: false

jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout full history
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: dev

- name: Configure bot identity
run: |
git config user.name "ocb-sync-bot"
git config user.email "ocb-sync-bot@users.noreply.github.com"

- name: Try fast-forward dev → main
id: ff
run: |
set -e
git fetch origin main:refs/remotes/origin/main
if git merge-base --is-ancestor origin/main HEAD; then
echo "already-in-sync=true" >> "$GITHUB_OUTPUT"
echo "dev already contains origin/main; nothing to do."
exit 0
fi
if git merge --ff-only origin/main; then
git push origin dev
echo "ff-succeeded=true" >> "$GITHUB_OUTPUT"
echo "dev fast-forwarded to $(git rev-parse --short HEAD)"
exit 0
fi
echo "ff-failed=true" >> "$GITHUB_OUTPUT"

- name: Attempt clean merge commit
if: steps.ff.outputs.ff-failed == 'true'
id: merge
run: |
set -e
if git merge --no-edit --no-ff origin/main -m "sync: main → dev (auto-merge $(git rev-parse --short origin/main))"; then
git push origin dev
echo "merge-pushed=true" >> "$GITHUB_OUTPUT"
echo "dev auto-merged main at $(git rev-parse --short HEAD)"
exit 0
fi
git merge --abort
echo "merge-failed=true" >> "$GITHUB_OUTPUT"

- name: Open sync PR on conflict
if: steps.merge.outputs.merge-failed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
SHORT=$(git rev-parse --short origin/main)
BRANCH="sync/main-to-dev-$SHORT"
git checkout -B "$BRANCH"
# Force a merge commit that stops on conflicts. We DO NOT
# push conflict markers to dev; instead we push a branch that
# a human can pull, resolve locally, and merge into dev.
git reset --hard dev
git merge --no-edit --no-ff origin/main || true
git add -A
git commit -m "sync: main → dev (CONFLICTS — resolve before merge)" || true
git push -f origin "$BRANCH"
gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base dev \
--head "$BRANCH" \
--title "sync: main → dev (auto, needs conflict resolution)" \
--body "Automated by \`.github/workflows/sync-dev-from-main.yml\`. Fast-forward + auto-merge both failed; a human needs to resolve conflicts and merge this PR into \`dev\`. Base main SHA: $SHORT." \
|| echo "PR already exists for $BRANCH; leaving it alone."
Loading