fix: sync-main-to-experimental action correct base branch - #479
fix: sync-main-to-experimental action correct base branch#479pravusjif wants to merge 4 commits into
Conversation
Test this pull request
|
decentraland-bot
left a comment
There was a problem hiding this comment.
Review: fix: sync-main-to-experimental action correct base branch
Files changed: 1 (+67 −5) — .github/workflows/sync-main-to-experimental.yml
CI: All checks passing ✅
ADR-6: PR title (fix: …) and branch (fix/sync-main-to-experimental-action) follow semantic conventions ✅
Problem & Fix Assessment
The old workflow was fundamentally broken: git checkout -B chore/sync without specifying a start point created chore/sync at main's HEAD, discarding all experimental-only content. The new approach correctly builds chore/sync as experimental + main — experimental-only protos survive the sync.
Analysis
✅ Architecture & Merge Strategy
- Correct base: branching from
origin/experimental(or reusingorigin/chore/syncwhen a PR is already open) ensures experimental-only content is preserved. - Uniform merge loop: the
for REF in origin/experimental origin/mainloop handles both fresh and reuse paths cleanly. When starting fromorigin/experimental, the first merge is a no-op ("Already up to date") — correct and harmless. - Conflict handling:
merge --abort || true+::error::+exit 1fails cleanly instead of pushing broken state. - No-op detection:
git diff --quiet origin/experimental HEADcorrectly skips the push and PR creation when experimental already contains everything in main.
✅ Race Condition Prevention
- Concurrency group (
cancel-in-progress: false) ensures two pushes tomainqueue instead of racing on the push tochore/sync. --force-with-leaseinstead of--forceguards against overwriting manual conflict resolutions pushed between the fetch and the push. Significant safety improvement over the old--force.
✅ Edge Cases Verified
| Scenario | Behavior |
|---|---|
| First run, no existing PR | Branches from experimental, merges main, creates PR |
Existing PR open, chore/sync exists |
Reuses branch, preserves manual conflict resolution |
PR was closed but chore/sync still exists |
PR_NUMBER is empty → falls back to origin/experimental (fresh start) |
| Nothing to sync | Prints message, sets changed=false, skips push + PR |
| Merge conflict | Aborts merge, emits ::error::, exits 1 |
Manual push to chore/sync during run |
--force-with-lease rejects the push (correct — protects manual work) |
workflow_dispatch trigger |
Works identically to push trigger |
✅ Security
- No secrets exposure: only
GITHUB_TOKEN(automatically provisioned). - Minimal permissions:
contents: write(push),pull-requests: write(PR create/comment),issues: write(needed bygh pr comment/--labelwhich internally hit the Issues API). All justified. - No injection vectors: no untrusted user input flows into shell commands. The workflow triggers are
push(tomain) andworkflow_dispatch— both trusted. - No hardcoded credentials or sensitive data in logs.
✅ Shell Scripting Quality
set -euo pipefailon all run blocks.- Variables properly quoted (
"$PR_NUMBER","$BASE"). // emptyin jq correctly produces empty string (notnull) when no PR exists.git rev-parse --verify --quietsafely checks branch existence without error output.- Step outputs via
$GITHUB_OUTPUT(modern GitHub Actions pattern, not deprecatedset-output).
ℹ️ Why the git identity is required
The old workflow never created commits — it only did checkout -B and push. The new workflow creates merge commits (git merge --no-edit -m "...") and git requires an author identity for that. The github-actions[bot] user with email 41898282+github-actions[bot]@users.noreply.github.com is the standard GitHub Actions bot identity (41898282 is the bot's numeric user ID). This is the correct and conventional approach.
✅ Consumer Impact
This change modifies an internal CI workflow only. No public API surfaces, exported packages, or schemas are affected. No downstream consumer impact.
Verdict: APPROVE ✅
No P0 or P1 issues found. The fix is well-designed, handles edge cases properly, and includes multiple safety improvements over the original (--force-with-lease, concurrency group, conflict detection, no-op skip). The PR description is thorough and accurately explains both the problem and the solution.
Reviewed by Jarvis 🤖 · Requested by Gabriel Díaz (<@U03MGHMAJL8>) via Slack
|
✅ Approved by Claude, approved by Codex — fixes the sync-main-to-experimental workflow to build Checked: merge logic for both fresh-branch and existing-PR paths converges to the correct experimental+main tree; Cross-model review by Jarvis 🤖 · head 5ece7fe · Claude + Codex · Requested by Charly (<@U0747ARK5TM>) via Slack |
|
The diagnosis is right and I confirmed the two git facts it rests on: Three things to settle before merging. P1 — after a conflict the workflow can never open the PR
Either push the conflicted state to a side ref, or spell out all four recovery steps in the annotation. P1 — the no-op gate suppresses the push, not just the create (L71-75)
The check exists to dodge P1 (precondition, not a defect in this diff) — the repo is squash-only as of today
Real merge commits are what keep P3 — non-blocking
Checked and cleared
SimplicityOver-built by roughly ten lines, and the excess is where the bugs cluster — the reuse-existing-branch machinery accounts for most of the findings above. Keying "rebuild vs. keep" on whether the remote branch already contains |
a364e95
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review: fix: sync-main-to-experimental action correct base branch
Files changed: 2 — .github/workflows/sync-main-to-experimental.yml (+83 −6), .github/workflows/merge-sync-to-experimental.yml (new, +67)
CI: Pending (check_and_build, Validate compatibility — both in progress)
ADR-6: PR title (fix: …) and branch (fix/sync-main-to-experimental-action) follow semantic conventions ✅
Head: a364e95 (includes "mini refactor based on PR feedback")
What changed since the last review (5ece7fe → a364e95)
The latest commit addresses the two code-level P1s from @charly-bg's review:
| charly-bg finding | Status |
|---|---|
| P1 — conflict can never open the PR (step-output gating + incomplete error message) | ✅ Fixed — step-output gating removed; PR step always runs; ::error:: now includes full recovery instructions |
P1 — no-op gate suppresses push, not just PR create (git diff --quiet gated both) |
✅ Fixed — push gate now compares HEAD vs origin/chore/sync; PR-create gate uses merge-base --is-ancestor HEAD origin/experimental |
| P1 — repo is squash-only (precondition) | ℹ️ Precondition, not a code defect — PR body now includes explicit squash warning + recovery instructions |
Additional cleanup: issues: write removed, --no-edit removed, --set-upstream removed, --force-with-lease claim dropped, branch-reuse decoupled from PR existence (just checks branch existence).
Analysis of current state
✅ Core merge logic
The fundamental fix is correct: branching from origin/experimental (or reusing origin/chore/sync) and merging origin/main into it preserves experimental-only content. The for-loop approach handles both fresh and reuse paths uniformly.
✅ Concurrency & race protection
concurrencygroups withcancel-in-progress: falseserialize pushes tomaincorrectly.--force-with-leasecorrectly rejects the push if a human pushed tochore/syncbetween fetch and push (verified: the tracking ref from the fetch is the lease comparand).
✅ Push & PR gate separation
- Push decision:
HEADvsREMOTE_HEAD— pushes whenever the recomputed branch differs from the remote. This fixes charly's scenario (main reverts → stale remote stays). ✅ - PR-create decision:
merge-base --is-ancestor HEAD origin/experimental— skips PR creation when experimental already contains everything. ✅
✅ Security
- No injection vectors: All dynamic values are either hardcoded strings or integers from
ghAPI. No untrusted input flows into shell commands. - Permissions minimal:
contents: write+pull-requests: write(sync),contents: write+pull-requests: read(merge). All justified. - Triggers safe:
pushonmain(requires repo write access) andworkflow_dispatch(same). Nopull_request_targetor other external-triggerable events. - No secrets exposure.
✅ Conflict handling
merge --abort || true + expanded ::error:: with four recovery steps + exit 1. Clean, nothing pushed on conflict. The recovery instructions correctly cover both the "branch exists" and "branch doesn't exist" cases.
Remaining findings (all P2)
[P2] Merge workflow no-op path skips cleanup
merge-sync-to-experimental.yml: when --is-ancestor origin/chore/sync origin/experimental is true (already merged), the workflow exits 0 without deleting chore/sync or affecting the PR. This is reachable after a partial failure (experimental push succeeds, branch delete fails in a prior run) or an external merge. Re-running the merge workflow always hits the same no-op exit.
Self-heals on next meaningful push to main (sync workflow recomputes and pushes). Manual fix is trivial (git push origin --delete chore/sync). Consider adding git push origin --delete chore/sync || true to the no-op path for robustness.
[P2] Redundant git config --replace-all in both workflows
With fetch-depth: 0, actions/checkout@v4 already sets remote.origin.fetch to the wildcard refspec via getRefSpecForAllHistory. The git config --replace-all line is dead code (as charly noted). The git fetch that follows is useful (refreshes refs), but the config line can be removed. Harmless, but noise.
[P2] Empty-diff PR lingers after net-zero main changes
If main adds then reverts a change while a sync PR is open, chore/sync accumulates merge commits not in experimental's graph even though the tree is identical. --is-ancestor (commit-graph-based) returns false, so the PR stays open with 0 files changed. Merging it creates an empty merge commit on experimental — harmless but noisy. A supplementary git diff --quiet origin/experimental HEAD check could catch this and skip PR maintenance.
Verdict: APPROVE ✅
No P0 or P1 issues. The latest commit substantively addresses both code-level P1s from the prior review. The push/PR gate separation is correct, conflict recovery is well-documented, and the two-workflow design (sync creates the branch, merge lands it as a real merge commit) is sound for working around the squash-only constraint. The P2 items above are worth addressing but do not block merge.
Note: charly's third P1 (squash-only repo setting) is a precondition — worth confirming merge commits are re-enabled or that the team is committed to using the merge workflow exclusively, but not a defect in this diff.
Reviewed by Jarvis 🤖 · Requested by Pravus (<@UDJQDQC0Z>) via Slack
|
|
||
| if git merge-base --is-ancestor origin/chore/sync origin/experimental; then | ||
| echo "experimental already contains chore/sync; nothing to merge." | ||
| exit 0 |
There was a problem hiding this comment.
[P2] When --is-ancestor is true (chore/sync already in experimental), the workflow exits without deleting the stale chore/sync branch. Consider:
| exit 0 | |
| if git merge-base --is-ancestor origin/chore/sync origin/experimental; then | |
| echo "experimental already contains chore/sync; cleaning up." | |
| git push origin --delete chore/sync || true | |
| exit 0 |
This handles the partial-failure recovery case (prior run pushed experimental but failed on branch delete).
| git push --force --set-upstream origin chore/sync | ||
| set -euo pipefail | ||
|
|
||
| # The wildcard is what the bare fetch below and --force-with-lease resolve |
There was a problem hiding this comment.
[P2] With fetch-depth: 0, actions/checkout@v4 already sets remote.origin.fetch to the wildcard refspec. This git config line is redundant (as noted by @charly-bg). The git fetch below is still useful for refreshing refs, but this config line can be removed.
| set -euo pipefail | ||
|
|
||
| git config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" | ||
| git fetch --no-tags --force --prune origin |
There was a problem hiding this comment.
[P2] Same as the sync workflow — git config --replace-all is redundant with fetch-depth: 0.
Problem
The sync job reset
chore/synctomain's HEAD instead of merging intoexperimental, so the branch — and the@dcl/protocolpackage built from it — carried none ofexperimental's content. Regenerating in the Explorer against that package deleted experimental-only schema, e.g.public enum AvatarEmoteMaskinAvatarShape.gen.cs.Squashing the sync PR breaks it a second way:
mainstops being an ancestor ofexperimental, the merge base freezes, and later syncs conflict on contentexperimentalalready has.Changes
sync-main-to-experimental.yml— buildschore/syncasexperimental+mainfetch-depth: 0and a git identity, so the merges work at allexperimental, or reuses an in-flightchore/syncso a manual conflict resolution survives later pushes tomainexperimentalmerge-sync-to-experimental.yml— new, manual (workflow_dispatch), lands the sync as a merge commitallow_merge_commit=falsemeans the PR button can only squash. That setting governs the button, notgit push, andexperimentalis unprotected — so the workflow mergeschore/syncintoexperimentalwith--no-ff, pushes, and deletes the branch. The repo setting staysfalse; no more flipping it on and off to land these.Full cycle
main.chore/syncand opens the sync PR intoexperimental.build-deploypublishes the tarball and comments the install URL;validate-compatibilityruns.main→ they are merged onto the samechore/sync, so the open PR just accumulates them.experimental, then the workflow deleteschore/sync.mainstarts a fresh cycle fromexperimental.Step 6's deletion matters: the sync workflow reads "
chore/syncexists" as "a sync is still in flight". GitHub'sdelete_branch_on_mergeonly fires for button merges, not for a PR closed by a push, so the workflow does it explicitly.If it gets squashed anyway
Nothing blocks the button. Restore the ancestry with a no-content merge that records
mainas a parent:git checkout experimental git merge -s ours origin/main -m "chore: record main ancestry" git push origin experimental