fix: sync-main-to-experimental action correct base branch - #479
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
d80fdb7
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review (round 3): fix: sync-main-to-experimental action correct base branch
Head: d80fdb7 ("tackled review feedback")
CI: All checks passing ✅ (check_and_build, Validate compatibility, Deployment Notification)
ADR-6: ✅
Changes since last review (a364e95 → d80fdb7)
All three P2 findings from my prior review are addressed:
| P2 finding | Status |
|---|---|
| Merge workflow no-op path skips cleanup | ✅ Fixed — git push origin --delete chore/sync || true added to the --is-ancestor exit path |
Redundant git config --replace-all in both workflows |
✅ Fixed — removed from both files |
| Empty-diff PR lingers after net-zero main changes | ✅ Fixed — PR-create guard now combines --is-ancestor with git diff --quiet origin/experimental HEAD |
Verification of new logic
Merge workflow cleanup (L46-48):
if git merge-base --is-ancestor origin/chore/sync origin/experimental; then
echo "experimental already contains chore/sync; nothing to merge."
git push origin --delete chore/sync || true
exit 0
fiCorrect. The || true handles the case where the branch is already gone. Recovers from partial failures (prior run pushed experimental but failed on branch delete).
Sync workflow tree check (L94-98):
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
fiCorrect. First condition catches commit-graph containment. Second condition catches tree-identical-but-different-history (the net-zero revert scenario). || means either skips PR creation/update. The push step still runs independently, so the remote chore/sync branch is always up to date.
git config --replace-all removal: Both workflows now rely on actions/checkout@v4 with fetch-depth: 0 to set the wildcard refspec, followed by git fetch --no-tags --force --prune origin to refresh refs. Correct — the explicit config was redundant.
Verdict: APPROVE ✅
No P0 or P1 issues. All prior P2s resolved. CI green. The workflow logic is sound:
- Push gate (HEAD vs remote chore/sync) ensures the remote branch is always current
- PR-create gate (commit-graph + tree-diff) avoids both empty PRs and
gh pr createfailures - Merge workflow cleans up stale branches on both the happy path and the already-merged path
- Conflict recovery instructions are comprehensive
- Concurrency groups,
--force-with-lease, andset -euo pipefailprovide proper safety
Reviewed by Jarvis 🤖 · Requested by Pravus (<@UDJQDQC0Z>) via Slack
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review (round 4): fix: sync-main-to-experimental action correct base branch
Head: d80fdb7 ("tackled review feedback")
CI: All checks passing ✅ (check_and_build, Validate compatibility, Deployment Notification)
ADR-6: Title (fix: …) and branch (fix/sync-main-to-experimental-action) follow conventions ✅
Security: No issues found — minimal permissions, no injection vectors, no secrets exposure ✅
Changes since last review
No new commits since d80fdb7. Re-reviewing at Gabriel's request with fresh parallel sub-agent analysis (shell scripting correctness + architecture/edge-case review).
Core design assessment
The two-workflow design is sound:
- Sync workflow (
pushtomain/workflow_dispatch) → buildschore/syncasexperimental + main, opens a reviewable PR, triggers CI - Merge workflow (
workflow_dispatchonly) → landschore/syncintoexperimentalas a real--no-ffmerge commit, bypassing the squash-only PR button
This correctly solves the squash-only constraint: allow_merge_commit=false governs the GitHub UI button, not git push. Since experimental is unprotected, the merge workflow can push a two-parent merge commit directly.
Verified edge cases
| Scenario | Behavior | OK? |
|---|---|---|
| First run, no existing PR | Branches from experimental, merges main, pushes, creates PR |
✅ |
Existing chore/sync (sync in flight) |
Reuses branch via BASE=origin/chore/sync, preserves manual conflict resolutions |
✅ |
chore/sync exists but PR was closed |
rev-parse finds branch → reuse; PR step finds no open PR → creates new one |
✅ |
Nothing to sync (main ⊆ experimental) |
--is-ancestor + git diff --quiet → skips PR step cleanly |
✅ |
| Merge conflict | merge --abort || true + ::error:: with recovery steps + exit 1 |
✅ |
Human pushes to chore/sync during sync run |
--force-with-lease rejects; human work preserved |
✅ |
| Merge workflow re-run after partial failure | --is-ancestor detects already-merged, cleans up branch, exits 0 |
✅ |
Two rapid pushes to main |
Concurrency group queues; latest run fetches all changes | ✅ |
| Squash button used accidentally | PR body warns prominently; recovery instructions included | ✅ (process) |
Shell scripting correctness
set -euo pipefailinteraction with conditionals is correct throughout — allgit merge-base,git diff --quiet,git rev-parse --quietcalls are guarded byifor||${PR_NUMBER:+ (#$PR_NUMBER)}is safe underset -u(:+does not triggernounset)PR_BODYwith backticks in YAML|block scalar is safe — double-quoted expansion does not re-evaluate for command substitutionjq '.[0].number // empty'correctly produces empty string when no PR existsgit fetch --forceon fetch (not push) correctly handles non-fast-forward remote-tracking ref updates
Remaining findings (all P2 — non-blocking)
[P2] Missing || true on final branch deletion in merge workflow
merge-sync-to-experimental.yml last line: git push origin --delete chore/sync — if this fails after the critical git push origin experimental already succeeded, the step reports red even though the merge landed. The --is-ancestor early-exit path in the same file correctly uses || true. Adding it here too would match that pattern and avoid confusing operators on transient failures. Self-heals on re-run.
[P2] Cross-workflow race (sync + merge running concurrently)
The two workflows use separate concurrency groups and can overlap. If the merge workflow deletes chore/sync while the sync workflow is mid-run, the sync's --force-with-lease push fails. Self-heals on next push to main. Since the merge workflow is manual (workflow_dispatch), this is an operator-avoidable scenario.
[P2] Stale chore/sync branch on no-op sync
The push step runs before the PR step evaluates whether there's anything to propose. On a no-op (main already in experimental), chore/sync is pushed but no PR is created. Next sync treats it as "still in flight". Works correctly but wastes a reuse cycle. Moving the ancestry check before the push would avoid the stale branch.
[P2] Squash button not programmatically blocked (process concern)
Nothing prevents clicking the squash button, which breaks merge-base ancestry permanently. The PR body includes a prominent warning and recovery instructions — this is the best the code can do. The real fix is a repo-level config change (enable merge commits for experimental or add a branch ruleset). Not a defect in this diff.
Verdict: APPROVE ✅
No P0 or P1 issues found. The fix is well-designed and has been thoroughly iterated over four review rounds. All prior P1s (from charly-bg's review) and P2s (from my round 3 review) are resolved in d80fdb7. The P2 items above are improvements worth considering but do not block merge. CI is green, the design is sound, and the edge cases are handled correctly.
Reviewed by Jarvis 🤖 · Requested by Gabriel Díaz (<@U03MGHMAJL8>) via Slack
|
Re-checked at P2 — the push isn't gated on having anything to propose, so the first run creates a phantom branch. if [ "$(git rev-parse HEAD)" = "$REMOTE_HEAD" ] || git merge-base --is-ancestor HEAD origin/experimental; thenP2 — the two workflows can interleave. Both mutate P3, minor: deleting the head branch also closes a PR, so if that delete is processed before GitHub's reachability check the sync PR reads Closed rather than Merged; One larger point: 68 new lines, the PR-body warning and the |
Tested the scenario locally with Claude and and got the opposite: It rejects when the run had fetched the branch. The silent-recreate you describe happens only when the sync run started with no chore/sync at all — no tracking ref, so the push is a create with no lease to violate. Both variants are real, both are bad, and both fixed at efb4ae6 |
fix: sync
mainintoexperimentalas a real mergeProblem
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 tomainexperimentaland a differing tree, so a net-zero round ofmainchanges cannot leave an empty PR behindmerge-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 — including on its already-merged no-op path, so a run whose cleanup failed can be re-run to finish the job.Known residual (accepted)
If
mainadds and then reverts a change while a sync PR is already open, that PR stays open showing 0 files changed until the next real commit tomainrefills it, and merging it in that state records an empty merge commit. The branch is still pushed and the PR stops being commented on, so nothing goes stale or wrong — it is only noise, and it clears itself. Closing the PR automatically was considered and rejected: it would churn a PR that is about to become valid again, and landing those commits keeps the two histories linked.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