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
77 changes: 63 additions & 14 deletions .github/scripts/fork_sync_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,34 @@

from _github import make_headers, request, require_token

HEADERS = make_headers(require_token(), user_agent="ledoent-fork-digest/2.0")
# Lazy-init so the module is importable without GH_TOKEN (render_digest
# imports MANAGED_COMMIT_PATTERNS from here, and runs in a step without
# 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:<b>' into <b>"
# 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.
MANAGED_COMMIT_PATTERNS = (
re.compile(
r"^chore\(ci\): (created|updated) forward-port\.yml "
r"from ledoent/\.github distributor"
),
re.compile(r"^Merge branch '[^']+' into "),
)


def _is_managed_commit(subject: str) -> bool:
return any(p.match(subject) for p in MANAGED_COMMIT_PATTERNS)


def gh(method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
Expand Down Expand Up @@ -109,32 +136,48 @@ def sync_branch(repo: str, branch: str, upstream_org: str | None = None) -> dict
"skipped": skipped,
"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 (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.
# the fork accumulate undetected.
#
# 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:<b>'" 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:
ahead, behind = _compare_with_upstream(repo, upstream_org, branch)
ahead, behind, subjects = _compare_with_upstream(
repo, upstream_org, branch
)
result["ahead_by"] = ahead
result["behind_by"] = behind
result["diverged"] = bool(ahead and ahead > 0)
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
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.
) -> tuple[int | None, int | None, list[str]]:
"""Return (ahead_by, behind_by, ahead_commit_subjects).

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.
`ahead_commit_subjects` is the first line of each commit the fork
has that upstream doesn't — used to classify the divergence as
managed (structural overlay) vs. real (someone pushed work to a
series 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]
Expand All @@ -144,8 +187,12 @@ def _compare_with_upstream(
)
status, body = gh("GET", path)
if status != 200:
return None, None
return body.get("ahead_by"), body.get("behind_by")
return None, None, []
subjects = [
(c.get("commit", {}).get("message") or "").split("\n", 1)[0]
for c in body.get("commits", [])
]
return body.get("ahead_by"), body.get("behind_by"), subjects


def list_recent_mig_prs(
Expand All @@ -172,6 +219,8 @@ def list_recent_mig_prs(


def main() -> int:
global HEADERS
HEADERS = make_headers(require_token(), user_agent="ledoent-fork-digest/2.0")
forks = load_forks()
print(f"Loaded {len(forks)} forks from .github/forks.yml", file=sys.stderr)
Path("forks-parsed.json").write_text(json.dumps(forks, indent=2))
Expand Down
51 changes: 42 additions & 9 deletions .github/scripts/render_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from datetime import datetime, timezone
from pathlib import Path

from fork_sync_digest import MANAGED_COMMIT_PATTERNS


def _load(path: str, default):
p = Path(path)
Expand All @@ -44,14 +46,22 @@ 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.
# 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.
sync_diverged = [
r for r in sync_results
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.
sync_managed_overlay = [
r for r in sync_results
if r.get("managed_overlay") 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 Down Expand Up @@ -95,18 +105,33 @@ def render(
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>"
"<p>These fork branches have non-managed commits the upstream "
"doesn't — i.e. work landed directly on a series-named branch. "
"Investigate before the next sync layers more merge commits "
"on top.</p><ul>"
)
for r in sync_diverged:
ahead = r.get("ahead_by") or 0
behind = r.get("behind_by") or 0
unmanaged = [
s for s in r.get("ahead_commits", [])
if s and not any(p.match(s) for p in MANAGED_COMMIT_PATTERNS)
]
commits_html = ""
if unmanaged:
items = "".join(
f"<li><code>{html.escape(s)}</code></li>"
for s in unmanaged[:5]
)
more = (
f"<li><i>… +{len(unmanaged) - 5} more</i></li>"
if len(unmanaged) > 5 else ""
)
commits_html = f"<ul>{items}{more}</ul>"
lines.append(
f"<li><code>{html.escape(r['repo'])}</code> "
f"({html.escape(r['branch'])}): "
f"<b>{ahead} ahead</b>, {behind} behind</li>"
f"<b>{ahead} ahead</b>, {behind} behind{commits_html}</li>"
)
lines.append("</ul>")

Expand Down Expand Up @@ -151,6 +176,14 @@ def render(
else:
lines.append("<p><i>No upstream [MIG] PRs merged in the last 24h.</i></p>")

if sync_managed_overlay:
lines.append(
'<hr><p style="color:#888;font-size:11px">'
f"<b>Managed overlay:</b> {len(sync_managed_overlay)} branches "
"are 1+ ahead of upstream only by structural commits "
"(forward-port.yml distributor + sync-produced merges). "
"Expected; not divergence.</p>"
)
lines.append(
'<hr><p style="color:#888;font-size:11px">'
"Generated by ledoent/.github/.github/workflows/fork-sync-and-digest.yml "
Expand Down
41 changes: 41 additions & 0 deletions .github/scripts/tests/test_render_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ def test_diverged_branch_warning_in_subject_and_body():
"skipped": False,
"ahead_by": 30,
"behind_by": 0,
"ahead_commits": [
"chore(ci): created forward-port.yml from ledoent/.github distributor",
"[FIX] something real",
],
"diverged": True,
"managed_overlay": False,
},
],
mig_buckets={},
Expand All @@ -133,6 +138,42 @@ def test_diverged_branch_warning_in_subject_and_body():
assert "Diverged from upstream" in body
assert "ledoent/social" in body
assert "30 ahead" in body
# Unmanaged subject is shown; managed one is suppressed.
assert "[FIX] something real" in body
assert "forward-port.yml from ledoent/.github distributor" not in body


def test_managed_overlay_is_quiet_footer_not_alarm():
# The everyday state for install_forward_port forks: 1 ahead by the
# chore commit (+ possibly a sync merge). Must NOT alarm in subject
# or body, but should show a single-line footer for transparency.
subj, body, exit_marker = render_digest.render(
sync_results=[
{
"repo": "ledoent/web",
"branch": "18.0",
"status": 200,
"message": "",
"merge_type": "none",
"skipped": False,
"ahead_by": 1,
"behind_by": 0,
"ahead_commits": [
"chore(ci): created forward-port.yml from ledoent/.github distributor",
],
"diverged": False,
"managed_overlay": True,
},
],
mig_buckets={},
distribution=[],
)
assert "diverged" not in subj.lower()
assert "fail" not in subj.lower()
assert exit_marker == "0"
assert "Diverged from upstream" not in body
assert "Managed overlay:" in body
assert "1 branches" in body


def test_html_escaping_in_failure_message():
Expand Down
87 changes: 82 additions & 5 deletions .github/scripts/tests/test_sync_branch_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,32 @@ def _patch_gh_sequence(monkeypatch, *returns):
monkeypatch.setattr(fork_sync_digest, "gh", lambda *a, **kw: next(it))


def _commits(*subjects):
return [{"commit": {"message": s}} for s in 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. 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.
# 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(
monkeypatch,
(200, {"merge_type": "merge"}),
(200, {"ahead_by": 30, "behind_by": 0}),
(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",
),
}),
)
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

Expand All @@ -84,13 +98,75 @@ 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}),
(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["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.
_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",
),
}),
)
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"] == 1


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(
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",
),
}),
)
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


def test_updated_chore_subject_is_also_managed(monkeypatch):
# When the distributor template content changes, the chore commit
# message switches from "created" to "updated". Both shapes must
# 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",
),
}),
)
r = fork_sync_digest.sync_branch("ledoent/web", "18.0", upstream_org="OCA")
assert r["managed_overlay"] is True
assert r["diverged"] is False


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.
Expand Down Expand Up @@ -120,6 +196,7 @@ def test_compare_failure_does_not_mask_successful_sync(monkeypatch):
assert r["status"] == 200
assert r["merge_type"] == "fast-forward"
assert r["diverged"] is False
assert r["managed_overlay"] is False
assert r["ahead_by"] is None


Expand Down
Loading