Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions .github/scripts/fork_sync_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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))

Expand Down
35 changes: 34 additions & 1 deletion .github/scripts/render_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -58,7 +66,12 @@ def render(
f"{len(sync_nochange)} already current · "
f"{len(sync_skipped)} skipped (branch n/a) · "
f'<span style="color:{"#c00" if sync_fail else "#0a0"}">'
f"{len(sync_fail)} failed</span></p>",
f"{len(sync_fail)} failed</span>"
+ (
f' · <span style="color:#c80">{len(sync_diverged)} diverged</span>'
if sync_diverged else ""
)
+ "</p>",
]
if distribution:
lines.append(
Expand All @@ -79,6 +92,24 @@ def render(
)
lines.append("</ul>")

if sync_diverged:
lines.append(
'<h3 style="color:#c80">⚠️ Diverged from upstream</h3>'
"<p>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).</p><ul>"
)
for r in sync_diverged:
ahead = r.get("ahead_by") or 0
behind = r.get("behind_by") or 0
lines.append(
f"<li><code>{html.escape(r['repo'])}</code> "
f"({html.escape(r['branch'])}): "
f"<b>{ahead} ahead</b>, {behind} behind</li>"
)
lines.append("</ul>")

if dist_fail:
lines.append('<h3 style="color:#c00">⚠️ Distributor failures</h3><ul>')
for r in dist_fail:
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions .github/scripts/tests/test_render_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions .github/scripts/tests/test_sync_branch_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading