diff --git a/.github/scripts/fork_sync_digest.py b/.github/scripts/fork_sync_digest.py
index fca8ea3..4d14d37 100644
--- a/.github/scripts/fork_sync_digest.py
+++ b/.github/scripts/fork_sync_digest.py
@@ -39,24 +39,23 @@
# the token env). Populated by main() before any gh() call.
HEADERS: dict = {}
-# Ahead-commit subjects that the sync pipeline itself produces, so an
-# overlay of these on top of upstream is expected — not "diverged" in
-# the alarming sense. Two sources:
-# 1. distribute_forward_port.py writes forward-port.yml via the
-# Contents API, which lands as a commit on the default (series)
-# branch. The workflow file MUST live on the branch where
-# pull_request_target events fire, so this commit is structural.
-# 2. merge-upstream produces a "Merge branch 'OCA:' into "
-# commit any time the fork is non-empty (i.e. carries #1). Also
-# structural — not a sign of human error on a series branch.
-# Anything outside these patterns is a real divergence (someone pushed
-# work to a series branch directly) and stays flagged.
+# Ahead-commit subjects the sync pipeline itself produces. Used only to
+# word the digest entry ("this is the distributor's doing, not a stray
+# human push") — it never suppresses the mirror guard below.
+#
+# A "Merge branch 'OCA:' into " pattern deliberately does NOT
+# appear here. It used to, and that is precisely how ~140 merge commits
+# accumulated unseen across 14 series branches: one orphaned
+# forward-port.yml commit made fast-forward impossible, merge-upstream
+# minted a merge commit on every run, and the whitelist classified each
+# one as expected. The mechanism laundered its own damage. Series
+# branches are exact upstream mirrors; a merge commit on one is always
+# a bug, so it must always be reportable.
MANAGED_COMMIT_PATTERNS = (
re.compile(
r"^chore\(ci\): (created|updated) forward-port\.yml "
r"from ledoent/\.github distributor"
),
- re.compile(r"^Merge branch '[^']+' into "),
)
@@ -121,42 +120,36 @@ def load_forks(path: str | Path = ".github/forks.yml") -> list[dict]:
def sync_branch(repo: str, branch: str, upstream_org: str | None = None) -> dict:
- status, body = gh("POST", f"/repos/{repo}/merge-upstream", {"branch": branch})
- msg = body.get("message", "")
- # "Branch n/a" responses we expect on forks that haven't cut a 20.0
- # (or any future release) yet. Two API shapes for the same condition:
- # 422 + "does not exist" — branch isn't on the fork at all
- # 404 + "Branch not found" — branch isn't on the upstream either
- # Both are "skipped", not "failed".
- skipped = (status == 422 and "does not exist" in msg.lower()) or (
- status == 404 and "branch not found" in msg.lower()
- )
result = {
"repo": repo,
"branch": branch,
- "status": status,
- "message": msg,
- "merge_type": body.get("merge_type"),
- "skipped": skipped,
+ "status": None,
+ "message": "",
+ "merge_type": None,
+ "skipped": False,
"ahead_by": None,
"behind_by": None,
"ahead_commits": [],
"diverged": False,
"managed_overlay": False,
}
- # Post-sync divergence check. merge-upstream silently produces a
- # merge commit (instead of fast-forwarding) when the fork has local
- # commits the upstream doesn't have — returning success either way.
- # Without this check, accidental pushes to a series-named branch on
- # the fork accumulate undetected.
+
+ # Mirror guard — runs BEFORE merge-upstream, and that order is the
+ # whole point. merge-upstream cannot fast-forward a branch that is
+ # ahead of upstream, so it silently mints a merge commit instead and
+ # still returns 200. That merge commit leaves the branch ahead
+ # forever, so the next run mints another. Checking afterwards only
+ # documents the damage; checking first prevents it.
+ #
+ # A fork series branch is by definition an exact copy of upstream.
+ # If it is ahead by even one commit it is no longer a mirror, and
+ # the fix is a human deciding what to do with those commits — not a
+ # merge. So: report, and leave the branch alone.
#
- # But: forks with install_forward_port carry a managed chore commit
- # on every series branch (the distributor's forward-port.yml write),
- # and merge-upstream then layers a "Merge branch 'OCA:'" commit
- # on top each time upstream advances. Both are structural — not
- # human error. Classify them as `managed_overlay` and reserve
- # `diverged` for genuinely unexpected ahead-commits.
- if upstream_org and not skipped and status < 400:
+ # (Only forks with an upstream_org are mirrors. Non-OCA forks —
+ # sentry, odoo-cloud-platform — legitimately carry customizations on
+ # the tracked branch, and merging upstream into them is correct.)
+ if upstream_org:
ahead, behind, subjects = _compare_with_upstream(
repo, upstream_org, branch
)
@@ -165,10 +158,32 @@ def sync_branch(repo: str, branch: str, upstream_org: str | None = None) -> dict
result["ahead_commits"] = subjects
if ahead and ahead > 0:
unmanaged = [s for s in subjects if not _is_managed_commit(s)]
- if unmanaged:
- result["diverged"] = True
- else:
- result["managed_overlay"] = True
+ result["diverged"] = bool(unmanaged)
+ result["managed_overlay"] = not unmanaged
+ result["status"] = 200
+ result["message"] = (
+ f"not a mirror: {ahead} commit(s) ahead of "
+ f"{upstream_org}:{branch} — refusing to merge (that would "
+ f"add a merge commit and entrench the drift). Reset the "
+ f"branch to the upstream tip, or move the commits to a "
+ f"feature branch."
+ )
+ return result
+
+ status, body = gh("POST", f"/repos/{repo}/merge-upstream", {"branch": branch})
+ msg = body.get("message", "")
+ # "Branch n/a" responses we expect on forks that haven't cut a 20.0
+ # (or any future release) yet. Two API shapes for the same condition:
+ # 422 + "does not exist" — branch isn't on the fork at all
+ # 404 + "Branch not found" — branch isn't on the upstream either
+ # Both are "skipped", not "failed".
+ skipped = (status == 422 and "does not exist" in msg.lower()) or (
+ status == 404 and "branch not found" in msg.lower()
+ )
+ result["status"] = status
+ result["message"] = msg
+ result["merge_type"] = body.get("merge_type")
+ result["skipped"] = skipped
return result
diff --git a/.github/scripts/render_digest.py b/.github/scripts/render_digest.py
index a84b1a8..f559b0e 100644
--- a/.github/scripts/render_digest.py
+++ b/.github/scripts/render_digest.py
@@ -53,18 +53,18 @@ def render(
if r.get("merge_type") == "none" and r["status"] < 400
]
sync_skipped = [r for r in sync_only if r["skipped"]]
- # Divergence = fork branch has commits upstream doesn't, AND those
- # commits aren't part of the managed overlay (forward-port.yml
- # chore commit + sync-produced merge commits). Real divergence
- # means someone pushed work to a series branch — needs manual fix.
+ # Divergence = a series branch has commits upstream doesn't, by any
+ # hand other than the distributor's. Someone pushed work to a mirror
+ # branch. The sync refuses to merge it (a merge would only entrench
+ # the drift), so it stays broken until a human resets it.
sync_diverged = [
r for r in sync_only
if r.get("diverged") and not r["skipped"]
]
- # Managed-overlay branches are 1+ ahead but only by structural
- # commits. Counted in a small footer for transparency, never in the
- # subject line — they're the steady state for install_forward_port
- # forks and would otherwise spam the inbox every day.
+ # Ahead only by the distributor's forward-port.yml chore commit.
+ # Still not a mirror, so still not merged — but attributable to the
+ # pipeline rather than a stray push, so it gets a footer line instead
+ # of the subject-line alarm.
sync_managed_overlay = [
r for r in sync_results
if r.get("managed_overlay") and not r["skipped"]
@@ -249,9 +249,11 @@ def render(
lines.append(
'
' f"Managed overlay: {len(sync_managed_overlay)} branches " - "are 1+ ahead of upstream only by structural commits " - "(forward-port.yml distributor + sync-produced merges). " - "Expected; not divergence.
" + "are ahead of upstream by the forward-port.yml distributor " + "commit alone. Not a stray push — but not an exact mirror " + "either, so they were not merged. Reset them to the " + "upstream tip (or turn offinstall_forward_port) "
+ "to get them syncing again."
)
lines.append(
'' diff --git a/.github/scripts/tests/test_sync_branch_state.py b/.github/scripts/tests/test_sync_branch_state.py index f31ce93..1e0d7c2 100644 --- a/.github/scripts/tests/test_sync_branch_state.py +++ b/.github/scripts/tests/test_sync_branch_state.py @@ -59,92 +59,123 @@ def test_conflict_409_is_not_skipped(monkeypatch): def _patch_gh_sequence(monkeypatch, *returns): - """Make gh() return each tuple in turn (merge-upstream, then compare).""" + """Make gh() return each tuple in turn. + + On a fork with an upstream_org the compare call comes FIRST (the + mirror guard), and merge-upstream only runs if the branch is not + ahead. Order the tuples accordingly. + """ it = iter(returns) monkeypatch.setattr(fork_sync_digest, "gh", lambda *a, **kw: next(it)) +def _record_gh(monkeypatch, *returns): + """Same, but also records (method, path) of every call made.""" + calls = [] + it = iter(returns) + + def fake_gh(method, path, body=None): + calls.append((method, path)) + return next(it) + + monkeypatch.setattr(fork_sync_digest, "gh", fake_gh) + return calls + + def _commits(*subjects): return [{"commit": {"message": s}} for s in subjects] +def _compare(ahead, *subjects, behind=0): + return (200, { + "ahead_by": ahead, + "behind_by": behind, + "commits": _commits(*subjects), + }) + + def test_diverged_branch_is_flagged_when_upstream_org_provided(monkeypatch): - # merge-upstream succeeds with a merge commit; compare reports 30 - # local commits ahead, one of which isn't a managed overlay. This - # is the case that motivated the check — ledoent/social:18.0 in - # May 2026 silently accumulated drift because merge-upstream - # returns success either way. - _patch_gh_sequence( + # A stray human commit on a series branch. Must be flagged AND must + # not be merged — merging would bury it under a merge commit. + calls = _record_gh( monkeypatch, - (200, {"merge_type": "merge"}), - (200, { - "ahead_by": 30, - "behind_by": 0, - "commits": _commits( - "chore(ci): created forward-port.yml from ledoent/.github distributor", - "Merge branch 'OCA:18.0' into 18.0", - "[FIX] some real human commit that shouldn't be here", - ), - }), + _compare(30, "[FIX] some real human commit that shouldn't be here"), ) r = fork_sync_digest.sync_branch("ledoent/social", "18.0", upstream_org="OCA") assert r["diverged"] is True assert r["managed_overlay"] is False assert r["ahead_by"] == 30 - assert r["behind_by"] == 0 + assert not any(m == "POST" for m, _ in calls), "must not merge a diverged branch" + + +def test_ahead_branch_is_never_merged(monkeypatch): + # THE regression this guard exists for. merge-upstream on a branch + # that is ahead cannot fast-forward, so it mints a merge commit and + # returns 200 — leaving the branch ahead forever, so the next run + # mints another. 14 series branches accumulated ~140 of these before + # anyone noticed. The guard must refuse to call merge-upstream at all. + calls = _record_gh(monkeypatch, _compare(1, "Merge branch 'OCA:18.0' into 18.0")) + r = fork_sync_digest.sync_branch("ledoent/web", "18.0", upstream_org="OCA") + assert calls == [("GET", "/repos/OCA/web/compare/OCA:18.0...ledoent:18.0")] + assert r["merge_type"] is None + assert "refusing to merge" in r["message"] + + +def test_merge_commit_is_no_longer_whitelisted(monkeypatch): + # The old MANAGED_COMMIT_PATTERNS matched "Merge branch '...' into", + # so the tool's own merge commits were classified as expected and the + # drift stayed invisible. A merge commit on a mirror branch is always + # a bug and must now surface as divergence. + _patch_gh_sequence(monkeypatch, _compare(2, "Merge branch 'OCA:18.0' into 18.0")) + r = fork_sync_digest.sync_branch("ledoent/web", "18.0", upstream_org="OCA") + assert r["diverged"] is True + assert r["managed_overlay"] is False def test_clean_fast_forward_is_not_diverged(monkeypatch): _patch_gh_sequence( monkeypatch, + _compare(0), (200, {"merge_type": "fast-forward"}), - (200, {"ahead_by": 0, "behind_by": 0, "commits": []}), ) r = fork_sync_digest.sync_branch("ledoent/x", "18.0", upstream_org="OCA") assert r["diverged"] is False assert r["managed_overlay"] is False + assert r["merge_type"] == "fast-forward" assert r["ahead_by"] == 0 -def test_only_distributor_chore_is_managed_not_diverged(monkeypatch): - # The steady state for install_forward_port forks: 1 ahead by the - # chore commit alone. Must NOT trip the diverged alarm. +def test_behind_only_branch_still_fast_forwards(monkeypatch): + # Stale but pristine (a strict ancestor of upstream) — the common + # case. Must still sync. _patch_gh_sequence( monkeypatch, - (200, {"merge_type": "none"}), - (200, { - "ahead_by": 1, - "behind_by": 0, - "commits": _commits( - "chore(ci): created forward-port.yml from ledoent/.github distributor", - ), - }), + _compare(0, behind=4), + (200, {"merge_type": "fast-forward"}), ) - r = fork_sync_digest.sync_branch("ledoent/web", "18.0", upstream_org="OCA") + r = fork_sync_digest.sync_branch("ledoent/OpenUpgrade", "19.0", upstream_org="OCA") + assert r["merge_type"] == "fast-forward" assert r["diverged"] is False - assert r["managed_overlay"] is True - assert r["ahead_by"] == 1 + assert r["behind_by"] == 4 -def test_chore_plus_sync_merge_is_managed_not_diverged(monkeypatch): - # After an upstream advance, the chore + a "Merge branch 'OCA:...'" - # land together. Still structural, still not divergence. - _patch_gh_sequence( +def test_only_distributor_chore_is_managed_not_diverged(monkeypatch): + # The distributor's forward-port.yml commit. Named as managed so the + # digest can word it accurately ("the distributor did this, not a + # human") — but it still leaves the branch a non-mirror, so it is + # still not merged. + calls = _record_gh( monkeypatch, - (200, {"merge_type": "merge"}), - (200, { - "ahead_by": 2, - "behind_by": 0, - "commits": _commits( - "chore(ci): created forward-port.yml from ledoent/.github distributor", - "Merge branch 'OCA:18.0' into 18.0", - ), - }), + _compare( + 1, + "chore(ci): created forward-port.yml from ledoent/.github distributor", + ), ) r = fork_sync_digest.sync_branch("ledoent/web", "18.0", upstream_org="OCA") assert r["diverged"] is False assert r["managed_overlay"] is True - assert r["ahead_by"] == 2 + assert r["ahead_by"] == 1 + assert not any(m == "POST" for m, _ in calls) def test_updated_chore_subject_is_also_managed(monkeypatch): @@ -153,14 +184,10 @@ def test_updated_chore_subject_is_also_managed(monkeypatch): # be recognised. _patch_gh_sequence( monkeypatch, - (200, {"merge_type": "none"}), - (200, { - "ahead_by": 1, - "behind_by": 0, - "commits": _commits( - "chore(ci): updated forward-port.yml from ledoent/.github distributor", - ), - }), + _compare( + 1, + "chore(ci): updated forward-port.yml from ledoent/.github distributor", + ), ) r = fork_sync_digest.sync_branch("ledoent/web", "18.0", upstream_org="OCA") assert r["managed_overlay"] is True @@ -168,29 +195,25 @@ def test_updated_chore_subject_is_also_managed(monkeypatch): def test_no_upstream_org_skips_divergence_check(monkeypatch): - # Non-OCA forks (upstream_org: null) get no compare call. Verified by - # patching gh to fail loudly if called more than once. - calls = [] - - def fake_gh(method, path, body=None): - calls.append((method, path)) - return 200, {"merge_type": "fast-forward"} - - monkeypatch.setattr(fork_sync_digest, "gh", fake_gh) - r = fork_sync_digest.sync_branch("ledoent/x", "master") - assert len(calls) == 1 + # Non-OCA forks (upstream_org: null) are NOT mirrors — sentry/master + # carries real customizations — so they get no guard and no compare + # call. merge-upstream is the correct primitive there. + calls = _record_gh(monkeypatch, (200, {"merge_type": "merge"})) + r = fork_sync_digest.sync_branch("ledoent/sentry", "master") + assert calls == [("POST", "/repos/ledoent/sentry/merge-upstream")] assert r["diverged"] is False assert r["ahead_by"] is None + assert r["merge_type"] == "merge" -def test_compare_failure_does_not_mask_successful_sync(monkeypatch): - # If the compare endpoint flakes, the sync result still records the - # merge-upstream success. ahead_by stays None so the digest knows we - # don't actually know — better than a false "clean" classification. +def test_compare_failure_does_not_block_the_sync(monkeypatch): + # If the compare endpoint flakes we cannot prove the branch is ahead. + # Fall through to merge-upstream rather than stall the sync, and + # leave ahead_by None so the digest knows we don't actually know. _patch_gh_sequence( monkeypatch, - (200, {"merge_type": "fast-forward"}), (500, {"message": "Internal Server Error"}), + (200, {"merge_type": "fast-forward"}), ) r = fork_sync_digest.sync_branch("ledoent/x", "18.0", upstream_org="OCA") assert r["status"] == 200 @@ -200,19 +223,17 @@ def test_compare_failure_does_not_mask_successful_sync(monkeypatch): assert r["ahead_by"] is None -def test_failed_sync_skips_compare(monkeypatch): - # No point burning a compare call if merge-upstream itself failed — - # the failure already triggers a digest alarm. - calls = [] - - def fake_gh(method, path, body=None): - calls.append((method, path)) - return 403, {"message": "Resource not accessible by personal access token"} - - monkeypatch.setattr(fork_sync_digest, "gh", fake_gh) +def test_failed_sync_is_still_reported(monkeypatch): + # The PAT-scope error class. Compare says clean, merge-upstream then + # fails — the failure must survive into the result. + _patch_gh_sequence( + monkeypatch, + _compare(0), + (403, {"message": "Resource not accessible by personal access token"}), + ) r = fork_sync_digest.sync_branch("ledoent/x", "18.0", upstream_org="OCA") - assert len(calls) == 1 assert r["status"] == 403 + assert r["skipped"] is False assert r["diverged"] is False