diff --git a/.github/scripts/fork_sync_digest.py b/.github/scripts/fork_sync_digest.py
index 3820c4e..cc8affb 100644
--- a/.github/scripts/fork_sync_digest.py
+++ b/.github/scripts/fork_sync_digest.py
@@ -89,7 +89,7 @@ def load_forks(path: str | Path = ".github/forks.yml") -> list[dict]:
return out
-def sync_branch(repo: str, branch: str) -> 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
@@ -100,14 +100,52 @@ def sync_branch(repo: str, branch: str) -> dict:
skipped = (status == 422 and "does not exist" in msg.lower()) or (
status == 404 and "branch not found" in msg.lower()
)
- return {
+ result = {
"repo": repo,
"branch": branch,
"status": status,
"message": msg,
"merge_type": body.get("merge_type"),
"skipped": skipped,
+ "ahead_by": None,
+ "behind_by": None,
+ "diverged": 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 (e.g. someone "Merge pull request"-ing a feature branch
+ # into 18.0) accumulate undetected and the daily sync keeps adding
+ # merge commits on top. Compare against upstream to surface drift.
+ if upstream_org and not skipped and status < 400:
+ ahead, behind = _compare_with_upstream(repo, upstream_org, branch)
+ result["ahead_by"] = ahead
+ result["behind_by"] = behind
+ result["diverged"] = bool(ahead and ahead > 0)
+ return result
+
+
+def _compare_with_upstream(
+ fork_repo: str, upstream_org: str, branch: str
+) -> tuple[int | None, int | None]:
+ """Return (ahead_by, behind_by) for fork branch vs upstream branch.
+
+ ahead_by > 0 means the fork has commits the upstream doesn't —
+ almost always a mistake on a series-named tracking branch.
+ Returns (None, None) on any API error so a flaky compare doesn't
+ mask a successful sync.
+ """
+ fork_owner = fork_repo.split("/", 1)[0]
+ repo_name = fork_repo.split("/", 1)[1]
+ path = (
+ f"/repos/{upstream_org}/{repo_name}/compare/"
+ f"{upstream_org}:{branch}...{fork_owner}:{branch}"
+ )
+ status, body = gh("GET", path)
+ if status != 200:
+ return None, None
+ return body.get("ahead_by"), body.get("behind_by")
def list_recent_mig_prs(
@@ -141,11 +179,13 @@ def main() -> int:
sync_results: list[dict] = []
for f in forks:
for branch in f.get("branches", []):
- res = sync_branch(f["repo"], branch)
+ res = sync_branch(f["repo"], branch, upstream_org=f.get("upstream_org"))
sync_results.append(res)
tag = "SKIP" if res["skipped"] else (
res.get("merge_type") or str(res["status"])
)
+ if res.get("diverged"):
+ tag = f"{tag} DIVERGED+{res['ahead_by']}"
print(f" {res['repo']}@{branch:>10} -> {tag}", file=sys.stderr)
Path("sync-results.json").write_text(json.dumps(sync_results, indent=2))
diff --git a/.github/scripts/render_digest.py b/.github/scripts/render_digest.py
index 5c4bb0e..90fe8e3 100644
--- a/.github/scripts/render_digest.py
+++ b/.github/scripts/render_digest.py
@@ -44,6 +44,14 @@ def render(
if r.get("merge_type") == "none" and r["status"] < 400
]
sync_skipped = [r for r in sync_results if r["skipped"]]
+ # Divergence = fork branch has commits upstream doesn't. merge-upstream
+ # returns success and produces a merge commit; the digest needs to
+ # surface this separately so it gets fixed (force-reset the branch)
+ # instead of accumulating forever.
+ sync_diverged = [
+ r for r in sync_results
+ if r.get("diverged") and not r["skipped"]
+ ]
dist_fail = [r for r in distribution if r["action"] == "failed"]
dist_created = [r for r in distribution if r["action"] == "created"]
@@ -58,7 +66,12 @@ def render(
f"{len(sync_nochange)} already current · "
f"{len(sync_skipped)} skipped (branch n/a) · "
f''
- f"{len(sync_fail)} failed
",
+ f"{len(sync_fail)} failed"
+ + (
+ f' · {len(sync_diverged)} diverged'
+ if sync_diverged else ""
+ )
+ + "",
]
if distribution:
lines.append(
@@ -79,6 +92,24 @@ def render(
)
lines.append("")
+ if sync_diverged:
+ lines.append(
+ '⚠️ Diverged from upstream
'
+ "These fork branches have commits the upstream doesn't — "
+ "merge-upstream silently produced a merge commit instead of "
+ "fast-forwarding. Reset the branch to upstream HEAD "
+ "(after backing it up).
"
+ )
+ for r in sync_diverged:
+ ahead = r.get("ahead_by") or 0
+ behind = r.get("behind_by") or 0
+ lines.append(
+ f"{html.escape(r['repo'])} "
+ f"({html.escape(r['branch'])}): "
+ f"{ahead} ahead, {behind} behind "
+ )
+ lines.append("
")
+
if dist_fail:
lines.append('⚠️ Distributor failures
')
for r in dist_fail:
@@ -132,6 +163,8 @@ def render(
subject_parts.append(f"⚠️ {len(sync_fail)} sync fail")
if dist_fail:
subject_parts.append(f"⚠️ {len(dist_fail)} dist fail")
+ if sync_diverged:
+ subject_parts.append(f"⚠️ {len(sync_diverged)} diverged")
if total_migs:
subject_parts.append(f"{total_migs} MIG")
subject = " — ".join(subject_parts)
diff --git a/.github/scripts/tests/test_render_digest.py b/.github/scripts/tests/test_render_digest.py
index a3650db..aa52dc8 100644
--- a/.github/scripts/tests/test_render_digest.py
+++ b/.github/scripts/tests/test_render_digest.py
@@ -107,6 +107,34 @@ def test_both_sync_and_dist_failures_in_subject():
assert exit_marker == "2"
+def test_diverged_branch_warning_in_subject_and_body():
+ subj, body, exit_marker = render_digest.render(
+ sync_results=[
+ _sync(200, "fast-forward"),
+ {
+ "repo": "ledoent/social",
+ "branch": "18.0",
+ "status": 200,
+ "message": "",
+ "merge_type": "merge",
+ "skipped": False,
+ "ahead_by": 30,
+ "behind_by": 0,
+ "diverged": True,
+ },
+ ],
+ mig_buckets={},
+ distribution=[],
+ )
+ assert "⚠️ 1 diverged" in subj
+ # Divergence is a warning, not a failure — exit_marker should still be 0
+ # so the workflow doesn't conflate "needs manual fix" with "scripts broke".
+ assert exit_marker == "0"
+ assert "Diverged from upstream" in body
+ assert "ledoent/social" in body
+ assert "30 ahead" in body
+
+
def test_html_escaping_in_failure_message():
# Defensive: an API error message containing < > & shouldn't break
# the rendered HTML (or open an XSS hole if anyone ever pipes the
diff --git a/.github/scripts/tests/test_sync_branch_state.py b/.github/scripts/tests/test_sync_branch_state.py
index 4f9efc9..0132bac 100644
--- a/.github/scripts/tests/test_sync_branch_state.py
+++ b/.github/scripts/tests/test_sync_branch_state.py
@@ -56,3 +56,84 @@ def test_conflict_409_is_not_skipped(monkeypatch):
r = fork_sync_digest.sync_branch("ledoent/x", "18.0")
assert r["skipped"] is False
assert r["status"] == 409
+
+
+def _patch_gh_sequence(monkeypatch, *returns):
+ """Make gh() return each tuple in turn (merge-upstream, then compare)."""
+ it = iter(returns)
+ monkeypatch.setattr(fork_sync_digest, "gh", lambda *a, **kw: next(it))
+
+
+def test_diverged_branch_is_flagged_when_upstream_org_provided(monkeypatch):
+ # merge-upstream succeeds with a merge commit; compare reports 30
+ # local commits ahead. 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(
+ monkeypatch,
+ (200, {"merge_type": "merge"}),
+ (200, {"ahead_by": 30, "behind_by": 0}),
+ )
+ r = fork_sync_digest.sync_branch("ledoent/social", "18.0", upstream_org="OCA")
+ assert r["diverged"] is True
+ assert r["ahead_by"] == 30
+ assert r["behind_by"] == 0
+
+
+def test_clean_fast_forward_is_not_diverged(monkeypatch):
+ _patch_gh_sequence(
+ monkeypatch,
+ (200, {"merge_type": "fast-forward"}),
+ (200, {"ahead_by": 0, "behind_by": 0}),
+ )
+ r = fork_sync_digest.sync_branch("ledoent/x", "18.0", upstream_org="OCA")
+ assert r["diverged"] is False
+ assert r["ahead_by"] == 0
+
+
+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
+ assert r["diverged"] is False
+ assert r["ahead_by"] is None
+
+
+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.
+ _patch_gh_sequence(
+ monkeypatch,
+ (200, {"merge_type": "fast-forward"}),
+ (500, {"message": "Internal Server Error"}),
+ )
+ r = fork_sync_digest.sync_branch("ledoent/x", "18.0", upstream_org="OCA")
+ assert r["status"] == 200
+ assert r["merge_type"] == "fast-forward"
+ assert r["diverged"] is False
+ 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)
+ r = fork_sync_digest.sync_branch("ledoent/x", "18.0", upstream_org="OCA")
+ assert len(calls) == 1
+ assert r["status"] == 403
+ assert r["diverged"] is False