diff --git a/.github/workflows/merge-sync-to-experimental.yml b/.github/workflows/merge-sync-to-experimental.yml new file mode 100644 index 00000000..cbfdd1c8 --- /dev/null +++ b/.github/workflows/merge-sync-to-experimental.yml @@ -0,0 +1,67 @@ +name: Merge sync PR into experimental + +# The repo allows only squash merges, and squashing the sync PR would drop main's +# commits and freeze the merge base. This lands it as a real merge commit instead; +# GitHub closes the sync PR as merged once its commits are reachable from experimental. +on: + workflow_dispatch: + +concurrency: + group: merge-sync-to-experimental + cancel-in-progress: false + +permissions: + contents: write + pull-requests: read + +jobs: + merge: + runs-on: ubuntu-latest + + steps: + - name: Check out the code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Merge chore/sync into experimental + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + git config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" + git fetch --no-tags --force --prune origin + + if ! git rev-parse --verify --quiet refs/remotes/origin/chore/sync >/dev/null; then + echo "::error::chore/sync does not exist on the remote; there is no sync to land." + exit 1 + fi + + if git merge-base --is-ancestor origin/chore/sync origin/experimental; then + echo "experimental already contains chore/sync; nothing to merge." + exit 0 + fi + + PR_NUMBER="$(gh pr list --base experimental --head chore/sync --state open --json number --jq '.[0].number // empty')" + + git checkout -B experimental origin/experimental + # --no-ff so the sync always shows up as one labelled merge commit, the way the + # PR button would have recorded it. + if ! git merge --no-ff -m "chore: sync main to experimental${PR_NUMBER:+ (#$PR_NUMBER)}" origin/chore/sync; then + git merge --abort || true + echo "::error::Merging chore/sync into experimental failed. Resolve it on chore/sync, push, and re-run this workflow." + exit 1 + fi + + git push origin experimental + + # A PR closed by a push is not covered by delete_branch_on_merge, so remove the + # now fully-merged branch here: the sync job treats an existing chore/sync as a + # sync still in flight. + git push origin --delete chore/sync diff --git a/.github/workflows/sync-main-to-experimental.yml b/.github/workflows/sync-main-to-experimental.yml index 705c862d..b7b16e22 100644 --- a/.github/workflows/sync-main-to-experimental.yml +++ b/.github/workflows/sync-main-to-experimental.yml @@ -5,6 +5,15 @@ on: branches: [ main ] workflow_dispatch: +# Two pushes to main must not race on pushing chore/sync. +concurrency: + group: sync-main-to-experimental + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + jobs: create-pr: runs-on: ubuntu-latest @@ -12,28 +21,96 @@ jobs: steps: - name: Check out the code uses: actions/checkout@v4 + with: + # full history, otherwise the merges below have no common ancestor + fetch-depth: 0 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Create or update sync branch run: | - git fetch origin experimental - git checkout -B chore/sync - git push --force --set-upstream origin chore/sync + set -euo pipefail + + # The wildcard is what the bare fetch below and --force-with-lease resolve + # branch names through. + git config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" + git fetch --no-tags --force --prune origin + + # An existing chore/sync is a sync still in flight: keep it, so commits + # arriving on main accumulate on it and a manual conflict resolution pushed + # to it survives. The branch is deleted when its PR merges, so the next sync + # starts from experimental again. + BASE=origin/experimental + if git rev-parse --verify --quiet refs/remotes/origin/chore/sync >/dev/null; then + BASE=origin/chore/sync + fi + git checkout -B chore/sync "$BASE" + + # chore/sync must be experimental + main: anything that only exists on + # experimental has to survive the sync, or the package built from the branch + # ships main's protos alone. + for REF in origin/experimental origin/main; do + if ! git merge -m "chore: merge $REF into chore/sync" "$REF"; then + git merge --abort || true + echo "::error::Merging $REF into chore/sync failed. To recover: branch chore/sync off experimental (or check out the existing one), merge main into it, resolve, push it, and open a PR against experimental. Later runs merge on top of that resolution." + exit 1 + fi + done + + # Push whenever the recomputed branch differs from the remote, so a stale + # chore/sync can never stay the head of an open PR. + REMOTE_HEAD="$(git rev-parse --verify --quiet refs/remotes/origin/chore/sync || echo absent)" + if [ "$(git rev-parse HEAD)" = "$REMOTE_HEAD" ]; then + echo "chore/sync on the remote is already up to date." + else + git push --force-with-lease origin chore/sync + fi - name: Create or update PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Backticks here are data, not shell source: they reach gh through the + # variable and are never re-evaluated. + PR_BODY: | + :crown: *An automated PR to keep experimental in sync with main* + + > [!IMPORTANT] + > **Do not merge this PR with the Squash button.** + > + > Squashing replaces `main`'s commits with a single new one, so `main` stops + > being an ancestor of `experimental` and the merge base freezes. Every later + > sync then re-applies changes `experimental` already has, and starts + > conflicting within a couple of runs. + > + > Land it by running the **Merge sync PR into experimental** workflow + > (Actions -> Merge sync PR into experimental -> Run workflow). It pushes a + > real merge commit, and this PR closes as merged. + > + > If it does get squashed, restore the ancestry with + > `git merge -s ours origin/main` on `experimental`. run: | - PR_NUMBER=$(gh pr list --base experimental --head chore/sync --state open --json number --jq '.[0].number') + set -euo pipefail + + # gh pr create fails with "No commits between ..." when nothing is ahead. + if git merge-base --is-ancestor HEAD origin/experimental; then + echo "experimental already contains chore/sync; nothing to propose." + exit 0 + fi + + PR_NUMBER="$(gh pr list --base experimental --head chore/sync --state open --json number --jq '.[0].number // empty')" if [ -n "$PR_NUMBER" ]; then echo "PR #$PR_NUMBER already exists, adding a comment..." - gh pr comment $PR_NUMBER --body "🔄 Updated with latest changes from **main** on $(date -u '+%Y-%m-%d %H:%M UTC')" + gh pr comment "$PR_NUMBER" --body "🔄 Updated with latest changes from **main** on $(date -u '+%Y-%m-%d %H:%M UTC')" else echo "Creating new PR..." gh pr create \ --base experimental \ --head chore/sync \ --title "chore: sync main to experimental" \ - --body ":crown: *An automated PR to keep experimental in sync with main*" \ + --body "$PR_BODY" \ --label "auto-pr" fi