diff --git a/.github/scripts/fork_sync_digest.py b/.github/scripts/fork_sync_digest.py
index cc8affb..2ebdb88 100644
--- a/.github/scripts/fork_sync_digest.py
+++ b/.github/scripts/fork_sync_digest.py
@@ -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:' 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.
+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]:
@@ -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:'" 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]
@@ -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(
@@ -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))
diff --git a/.github/scripts/render_digest.py b/.github/scripts/render_digest.py
index 90fe8e3..adc7d8c 100644
--- a/.github/scripts/render_digest.py
+++ b/.github/scripts/render_digest.py
@@ -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)
@@ -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"]
@@ -95,18 +105,33 @@ def render(
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).
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.
{html.escape(s)}{html.escape(r['repo'])} "
f"({html.escape(r['branch'])}): "
- f"{ahead} ahead, {behind} behindNo upstream [MIG] PRs merged in the last 24h.
") + if sync_managed_overlay: + 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.
" + ) lines.append( '' "Generated by ledoent/.github/.github/workflows/fork-sync-and-digest.yml " diff --git a/.github/scripts/tests/test_render_digest.py b/.github/scripts/tests/test_render_digest.py index aa52dc8..955a991 100644 --- a/.github/scripts/tests/test_render_digest.py +++ b/.github/scripts/tests/test_render_digest.py @@ -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={}, @@ -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(): diff --git a/.github/scripts/tests/test_sync_branch_state.py b/.github/scripts/tests/test_sync_branch_state.py index 0132bac..576cf12 100644 --- a/.github/scripts/tests/test_sync_branch_state.py +++ b/.github/scripts/tests/test_sync_branch_state.py @@ -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 @@ -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. @@ -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