diff --git a/.github/workflows/merge-sync-to-experimental.yml b/.github/workflows/merge-sync-to-experimental.yml new file mode 100644 index 00000000..f5161234 --- /dev/null +++ b/.github/workflows/merge-sync-to-experimental.yml @@ -0,0 +1,94 @@ +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: + +# Shared with sync-main-to-experimental.yml: see the note there. +concurrency: + group: chore-sync-branch + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +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 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." + # It is fully merged, so a leftover branch is only a failed cleanup. + git push origin --delete chore/sync || true + exit 0 + fi + + PR_NUMBER="$(gh pr list --base experimental --head chore/sync --state open --json number --jq '.[0].number // empty')" + + # The sync PR is opened as a draft so it cannot be squashed. Take it out of + # draft before the merge lands, so GitHub applies its normal + # merged-by-reachability handling to it. + if [ -n "$PR_NUMBER" ]; then + gh pr ready "$PR_NUMBER" \ + || echo "::warning::Could not mark PR #$PR_NUMBER ready; it may close as Closed rather than Merged." + fi + + 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. Deleting a head branch also closes its PR, so wait for + # GitHub to register the merge first — otherwise the PR reads Closed, not Merged. + STATE="" + if [ -n "$PR_NUMBER" ]; then + for _ in $(seq 1 10); do + STATE="$(gh pr view "$PR_NUMBER" --json state --jq .state)" + [ "$STATE" = "MERGED" ] && break + sleep 3 + done + if [ "$STATE" != "MERGED" ]; then + echo "::warning::PR #$PR_NUMBER still reads $STATE, so chore/sync is left in place rather than closing the PR unmerged. Re-run this workflow to clean it up." + exit 0 + fi + fi + + # Non-fatal: experimental is already pushed, so a failed cleanup is not a + # failed sync. The no-op path above finishes the job on any later run. + git push origin --delete chore/sync \ + || echo "::warning::Could not delete chore/sync; re-run this workflow to clean it up." diff --git a/.github/workflows/sync-main-to-experimental.yml b/.github/workflows/sync-main-to-experimental.yml index 705c862d..11af7a42 100644 --- a/.github/workflows/sync-main-to-experimental.yml +++ b/.github/workflows/sync-main-to-experimental.yml @@ -5,6 +5,17 @@ on: branches: [ main ] workflow_dispatch: +# Shared with merge-sync-to-experimental.yml: both workflows mutate chore/sync, and +# interleaving them either rejects this push or recreates a branch that one just +# deleted. One group serializes every writer. +concurrency: + group: chore-sync-branch + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + jobs: create-pr: runs-on: ubuntu-latest @@ -12,28 +23,111 @@ 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 + + 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. Skip it when there is + # nothing to propose either, so a no-op run does not leave a branch behind + # that later runs would read as a sync in flight. + REMOTE_HEAD="$(git rev-parse --verify --quiet refs/remotes/origin/chore/sync || echo absent)" + if [ "$(git rev-parse HEAD)" = "$REMOTE_HEAD" ] \ + || git merge-base --is-ancestor HEAD origin/experimental; then + echo "Nothing to push: chore/sync is current, or experimental already contains it." + 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* + + **This PR is a draft on purpose: it is landed by a workflow, not by a button.** + + ### How to land it + + 1. Test the `@dcl/protocol` tarball from the **build-deploy** comment below in + the Explorer, and review the diff as usual. + 2. Go to **Actions -> Merge sync PR into experimental -> Run workflow**, from + `main`. + 3. That workflow marks this PR ready, merges `chore/sync` into `experimental` + as a real merge commit, and this PR closes as **Merged**. + + Further commits on `main` are merged onto this same branch while the PR is + open, so it stays current on its own. + + > [!IMPORTANT] + > Do not take this PR out of draft in order to squash it. Squash is the only + > merge this repo allows, and squashing replaces `main`'s commits with a single + > new one: `main` stops being an ancestor of `experimental`, the merge base + > freezes, and every later sync re-applies changes `experimental` already has, + > conflicting within a couple of runs. + > + > 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. + # The tree check also covers a net-zero round of main changes, where commits + # exist but there is nothing left to propose. + if git merge-base --is-ancestor HEAD origin/experimental \ + || git diff --quiet origin/experimental HEAD; then + echo "experimental already has everything chore/sync would 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..." + # --draft: squash is the only merge the repo allows, and squashing this PR + # breaks the ancestry. A draft has no merge button at all. gh pr create \ + --draft \ --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