decentraland / decentraland/unity-explorer

[TECH DEBT] CI/Branch protection | Enforce merge-commit syncs via ruleset with bot bypass (if squash recurs)

Open
#9,566 1 comment 0 reactions 0 assignees View on GitHub
3-low tech debt
Dominant language
C#
Stars
23
Forks
17
Avg merge
2d 16h
Merged PRs (30d)
101

Description

### πŸ“Š **Priority Level:**
Low β€” apply only if an **admin-approved** sync PR gets squash-merged again despite the admin-approval gate from #9491. (The gate already blocks the non-admin squash pattern β€” see the incident history below.)

### πŸ“ **Area/Component:**
CI / branch protection — `dev` branch, `chore/sync` (main→dev) PR flow. Workflows: `auto-sync-main-to-dev.yml`, `enforce-group-approvals.yml`.

### πŸ“ **Description:**
The main→dev sync PR must be merged **as a merge commit** to keep every `main` commit an ancestor of `dev` (`ahead_by == 0` on `compare/dev...main`). Squashing it rewrites the synced commits into new SHAs, `main` and `dev` diverge silently, and the damage surfaces later as release-merge conflicts (#9479 → the six conflicts in #9490).

The current mitigation (#9491) is procedural: the `enforce-approvals` required check demands an admin approval on `chore/sync` PRs, and the PR body instructs "merge as a merge commit, never squash". This issue records the mechanical enforcement design, pre-analyzed and ready to apply if the residual failure mode recurs.

**Squash history (all 118 `chore/sync` PRs audited):** six squashes all-time β€” #2507 (2024-10-21), #2531 (2024-10-22), #2769 (2024-11-12), then a cluster of three in seven weeks: **#9014 (2026-06-19), #9060 (2026-06-30), #9479 (2026-07-27)**. After #2769 there were ~19 months clean (~75 sync PRs, zero squashes), so "the manual procedure worked for years while admins were aware of it" (#9433, #9458, #9504, #9523 all merged correctly by hand) is genuinely true of that stretch β€” but the current regime is degrading, not stable.

**What the #9491 admin-approval gate covers, per recent incident:**

| PR | self-approved + merged by | permission | gate verdict |
|---|---|---|---|
| #9014 | davidejensen | write | ❌ red β€” merge blocked |
| #9060 | NickKhalow | write | ❌ red β€” merge blocked |
| #9479 | mikhail-dcl | admin | βœ… green β€” not blocked |

A non-admin's approval doesn't satisfy the gate, and a non-admin can't merge past a red required check (`restrictions: none`, `enforce_admins: false` β€” only admins bypass), so the gate would have **blocked #9014 and #9060**. It does **not** stop #9479's shape: the gate forces an admin *into the loop*, it doesn't make Squash unreachable β€” once any admin approves, all 55 write-access collaborators can merge, and non-admins still see only the Squash button. So the residual this issue exists to close is narrower than "a squash recurs": it is **"an admin approves, then anyone squashes it."**

**Why two of the three squashes went unnoticed:** a squash's divergence is self-healing at the next merge-commit sync β€” merging `main` into `dev` makes `main`'s tip (and its whole history, including the pre-squash originals) an ancestor of `dev` again (`ahead_by: 0` holds right now despite six historical squashes). A squash is therefore only damaging if a `release β†’ main` merge lands inside the window before the next correct sync. #9014 and #9060 were healed by #9103 (2026-07-01) with no release merge in the window and went unnoticed; #9479's #9490 release merge landed **inside** the window β†’ six conflicts. That sharpens the case for the `ahead_by == 0` tripwire below: its value is catching divergence *during* the self-healing window, when repair is one merge instead of a conflicted release.

### πŸ” **Current State:**
- `dev` uses **classic branch protection** with `required_linear_history: true` and `enforce_admins: false`. Rebase-merge is disabled repo-wide.
- Consequence: non-admins only ever see the **Squash** button on PRs to `dev`; merge commits are possible only via the blanket admin exemption β€” which also exempts admins from required checks and reviews (all-or-nothing).
- The sync-PR merge method is therefore a human choice made under that pressure, guarded only by the admin-approval gate and PR-body instructions from #9491.
- An auto-merge bot was prototyped in #9491 and reverted: `ORG_ACCESS_TOKEN` belongs to `decentraland-bot` (permission: `write`), which cannot create merge commits under classic protection, and `gh pr merge --merge --admin` requires an admin-owned token β€” an over-powered credential (a stolen admin token bypasses **all** of `dev`'s protection, not just linear history).

### πŸ’‘ **Proposed Solution:**
Move the linear-history rule to a **repository ruleset**, which supports per-actor bypass β€” granting exactly the one exemption classic protection cannot express:

1. Create ruleset `linear-history-dev` targeting `dev`, containing only *Require linear history*, **Active**. Bypass list:
- Team `sync-automation` (sole member: `decentraland-bot`) β€” bypass mode **Pull requests only** (rulesets do not accept individual users as bypass actors, hence the one-member team).
- **Repository admin** role β€” bypass mode **Always** (preserves the manual merge path and the emergency repair: `git checkout dev && git merge main && git push`).
2. Verify the ruleset is active and targeting `dev`, **then** untick *Require linear history* in classic protection (rules stack most-restrictive, so there is no unprotected window; rollback is re-ticking the checkbox). All other classic protection stays untouched.
3. Re-land the auto-merge workflow from #9491 (`auto-merge-sync-pr.yml`, reverted there β€” recoverable from that branch's history): on an admin approval of a `chore/sync` PR, it verifies the approver's permission, waits for required checks (`gh pr checks --required --watch`), and merges with `gh pr merge --merge` (no `--admin` needed once the ruleset grants the bypass). Merge method hardcoded β€” squash unreachable.

Resulting trust model: the bot can add merge commits to `dev` **only via a PR merge**, still gated by required checks and the admin-approval required check. A stolen `ORG_ACCESS_TOKEN` cannot direct-push to `dev`, cannot skip CI, and cannot manufacture the human admin approval β€” it can only complete a sync an admin already approved.

Complementary (independent, ~10 lines): a divergence tripwire on `push: [dev]` asserting `ahead_by == 0` on `compare/dev...main`, alerting on transition (deduped via a tracking issue / Slack). Catches every divergence cause (squash, manual admin merges, direct pushes to `main`), not just the one the bot prevents.

### βš–οΈ **Impact Assessment:**
#### Performance Impact:
None β€” CI/settings only.

#### Maintainability Impact:
Positive for the sync flow (removes a known human trap; ruleset bypasses are audit-logged, unlike silent classic admin exemptions). Cost: one more moving part in repo governance β€” a ruleset alongside classic protection, a one-member team, and a bot with standing (narrow) merge power. This is exactly the complication we chose **not** to take on preemptively in #9491.

#### Risk of Refactoring:
Low with the ordered rollout (ruleset first, verify, then untick). Residual: verify on the first real sync PR that the bot's PR-only bypass satisfies the merge API; any workflow with repo `write` can wield `ORG_ACCESS_TOKEN`, but its post-change capability is bounded as described above.

### πŸ“ˆ **Effort Estimate:**
S β€” ~10 minutes of admin configuration + restoring the reverted workflow file (minus `--admin`).

### πŸ”— **Dependencies:**
- #9491 β€” the admin-approval gate and instructions (the "get started" strategy); the reverted `auto-merge-sync-pr.yml` lives in that branch's history.
- #9490 / #9479 / #9014 / #9060 β€” the incidents motivating all of this.
- Requires a repo admin (settings + team creation); no new tokens or secrets.

### πŸ–₯️ **Additional Notes:**
Trigger condition: another **admin-approved** squashed sync (the gate already blocks the non-admin pattern of #9014/#9060) β€” detected by `gh api repos/decentraland/unity-explorer/compare/dev...main --jq .ahead_by` returning non-zero with no open `chore/sync` PR. Immediate remediation (before applying anything here): an admin runs `git checkout dev && git pull && git merge main && git push` β€” conflict-free if done promptly, since the squash already applied identical content.

Contributor guide

Open the contributing guide

Research direction

Review auto-merge-sync-pr.yml in the reverted #9491 history alongside auto-sync-main-to-dev.yml and enforce-group-approvals.yml. Start by checking the current dev protection and the compare/dev...main ahead_by value with gh api, then configure and verify the ruleset before restoring the workflow. Done means admin-approved sync PRs merge only as merge commits through the bot, with required checks preserved and divergence reporting in place.

Written by the indexing model from the issue text.

Assessment

Tech stack
git, github-actions
Domain
ci-cd, devops
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.