diff --git a/.agents/skills/release-notes/SKILL.md b/.agents/skills/release-notes/SKILL.md index 5997d6b7e29..09cc4f4290c 100644 --- a/.agents/skills/release-notes/SKILL.md +++ b/.agents/skills/release-notes/SKILL.md @@ -16,7 +16,8 @@ description: > NOTE: Website release notes are normally updated automatically by the `update-release-notes` agentic workflow when code lands on main, release branches, - or tags are pushed. This skill is for manual regeneration or corrections only. + or tags are pushed. That workflow drives this skill in `--all` mode; the same + skill is also used manually for regeneration or corrections. --- # Release Notes Skill @@ -31,6 +32,10 @@ correcting, or bulk-processing release notes. ### Step 1 — Determine versions +> **Running unattended (e.g. from the `update-release-notes` workflow)?** There is +> no user to ask — skip straight to the `--all` invocation in +> [Step 2](#step-2--run-the-script), which regenerates every branch automatically. + Ask the user which version(s) to generate, or infer from context: - A specific version: `3.119.2` - A branch: `release/4.147.0-preview.1` or `main` @@ -39,13 +44,27 @@ Ask the user which version(s) to generate, or infer from context: ### Step 2 — Run the script -Run the script to collect raw PR data and write it to the version file: +Run the script **from the repository root** to collect raw PR data and write it to +the version file: ```bash python3 .agents/skills/release-notes/scripts/generate-release-notes.py --branch release/4.147.0-preview.1 python3 .agents/skills/release-notes/scripts/generate-release-notes.py --branch main ``` +To regenerate **every** branch in one idempotent pass, use `--all`: + +```bash +python3 .agents/skills/release-notes/scripts/generate-release-notes.py --all +``` + +`--all` loops over `main` plus every `release/*` branch and regenerates each +version's raw data, but **only rewrites files that actually changed** (same PR +count AND same diff range ⇒ skipped, so the AI never re-polishes an unchanged +page). Superseded versions are still generated — they keep their own page; the +supersede marker only excludes them from being a diff *baseline*. The "Files to +polish" output therefore lists only genuinely-changed pages. + This writes raw PR data to `documentation/docfx/releases/{version}.md` or `documentation/docfx/releases/{version}-unreleased.md` depending on the branch type, and regenerates TOC/index. All data comes from git history — no API calls or tokens needed. @@ -98,38 +117,20 @@ Determine the version's status from the HTML comment block in the file (`status: ### Skipped / superseded minors (preview-only versions) Occasionally a minor ships previews but is **skipped** before going stable — e.g. `4.147` -was previewed but abandoned in favour of `4.148`. This is handled **automatically** — no -configuration is normally required: - -1. **Automatic cumulative base.** When choosing the diff base for a new minor (and for - `main`), the script picks the most recent prior version that actually shipped a **stable - git tag**, skipping any minor that only had previews. So `4.148`'s notes roll up *all* - the skipped `4.147` work instead of showing a tiny delta. The same skip applies to - **point releases**: a stable patch bases on the most recent previous patch that shipped - stable, so e.g. `3.119.4` rolls up the preview-only `3.119.3` (the `3.119.3` page still - keeps its own notes — duplication across the two pages is expected and fine). - -2. **Automatic supersede label (two-way).** A version that has only preview tags is flagged as - *superseded* once a **newer version is known**. A newer version is "known" from a later - `release/*` branch or `v*` tag, **or from `main` itself**: if `main`'s in-development - `SKIASHARP_VERSION` is newer and the preview-only version's minor line was never branched - for servicing (no `release/X.Y.x`), `main`'s version supersedes it. So with `main` on - `4.148.0` and only `release/4.147.0-preview.*` branches (no `release/4.147.x`), `4.147.0` - is flagged *superseded by 4.148.0* immediately — no `4.148` branch or tag required. The - page is forced to `preview` status and rendered with a *"Preview only · Superseded by - 4.148.0 · Never released as stable"* header. The label links to the successor's published - page (`{ver}.md`) when it exists, otherwise to its in-development page - (`{ver}-unreleased.md`). Until a newer version is known, the version is just a normal preview. - - The relationship is rendered **both ways**: the successor page (computed by the inverse - `detect_supersedes`) carries a `supersedes:` data-block line and a - *"Supersedes [X.Y.Z] · Rolls up preview-only work …"* note, so e.g. `4.148.0` points back - to `4.147.0`, `3.119.4` to `3.119.3`, and `3.119.0` to `3.118.0` — automatically, without - the AI having to infer it from the diff base. - -> Detection is purely tag/branch/`main`-version based, so no configuration file is needed. (If a -> manual override is ever required it can be reintroduced later; for now the automatic -> behaviour is the only path.) +was previewed but abandoned in favour of `4.148`. **The script handles all of this for you** +and records the outcome in the file's data-block; you only render what's there: + +- The diff base is already chosen and baked into the `from..to` range, so a skipped line is + rolled up automatically (e.g. `4.148`'s data already covers all the `4.147` work). You never + pick a base yourself — just summarise the PRs in the file. +- A `superseded:` line means the version was a preview that never shipped stable. Render the + script-generated *"Preview only · Superseded by …"* header (kept verbatim). +- A `supersedes:` line is the two-way back-link on the successor page. Render the + script-generated *"Supersedes …"* note (kept verbatim). + +> You never compute supersession or base selection — just render whatever markers the file +> contains. The mechanics (and the optional `scripts/versions.json` overrides) are script +> internals. When polishing a superseded page, keep the script-generated *"Preview only · Superseded by …"* header and add a one-line note in Highlights that the work rolled up into the successor. @@ -205,3 +206,7 @@ Follow these rules: When regenerating multiple versions, process them in parallel — each version is independent. Fetch all raw data in one script call, then launch one agent per version to write the polished page. + +> This applies to **interactive/manual** use where sub-agents are available. When running +> unattended (e.g. the automated workflow), just polish the listed files **sequentially** in +> the one agent — do not try to spawn sub-agents. diff --git a/.agents/skills/release-notes/scripts/generate-release-notes.py b/.agents/skills/release-notes/scripts/generate-release-notes.py index 3c1c54a3946..3f1f8c807c8 100644 --- a/.agents/skills/release-notes/scripts/generate-release-notes.py +++ b/.agents/skills/release-notes/scripts/generate-release-notes.py @@ -10,6 +10,9 @@ python3 generate-release-notes.py --branch release/3.119.x python3 generate-release-notes.py --branch release/4.147.0-preview.1 + # Process ALL branches (main + all release/*), skip unchanged files + python3 generate-release-notes.py --all + # Regenerate TOC.yml and index.md from files on disk + create upcoming version file python3 generate-release-notes.py --update-toc @@ -18,12 +21,20 @@ range, PR count) followed by the raw PR list. AI then rewrites this file with polished content. TOC and index are regenerated automatically. +The --all command iterates every branch and only writes files whose PR count or +diff range has changed (idempotent). Use this for automated workflows. + +Reads scripts/versions.json (if present) for comparison overrides and +supersession markers. versions.json is the single source of truth: only the +versions listed there get a non-default baseline or a superseded marker. + Requirements: git, Python 3.7+ """ from __future__ import annotations import argparse +import json import re import subprocess import sys @@ -45,8 +56,90 @@ # Noreply email pattern: {id}+{username}@users.noreply.github.com _NOREPLY_RE = re.compile(r"^\d+\+(.+)@users\.noreply\.github\.com$") +# Versions config (loaded lazily from scripts/versions.json) +_VERSIONS_CONFIG = None # type: Optional[list[dict]] + +VERSIONS_JSON_PATH = Path("scripts/versions.json") + + +def load_versions_config(): + # type: () -> list[dict] + """Load scripts/versions.json override config (cached).""" + global _VERSIONS_CONFIG + if _VERSIONS_CONFIG is not None: + return _VERSIONS_CONFIG + if VERSIONS_JSON_PATH.exists(): + with open(VERSIONS_JSON_PATH) as f: + data = json.load(f) + _VERSIONS_CONFIG = data.get("versions", []) + else: + _VERSIONS_CONFIG = [] + return _VERSIONS_CONFIG + + +def _versions_config_lookup(version): + # type: (str) -> Optional[dict] + """Find an entry in versions.json matching a base version (X.Y.Z).""" + config = load_versions_config() + for entry in config: + if entry.get("version") == version: + return entry + return None + + +def _is_content_unchanged(output_path, new_prs_count, new_diff_range, + new_status=None, new_superseded_by=None, + new_supersedes=None): + # type: (Path, int, str, Optional[str], Optional[str], Optional[list[str]]) -> bool + """Check whether the existing file already encodes identical raw data. -# ── Helpers ────────────────────────────────────────────────────────── + Compares the fields the script controls: PR count, diff range, and the + supersession metadata (status + the ``superseded:`` / ``supersedes:`` + markers). The supersession fields must be part of this check because + toggling a version's supersession in versions.json can change ONLY the page + banner without touching the diff range — when a preview line is newly marked + superseded its OWN diff/PR set is unchanged (supersession only affects it as + a baseline for LATER versions), but its page must still gain a "Superseded + by" banner. Returns True only when every tracked field matches, so those + metadata-only changes still force a rewrite. + + Reads the metadata from the HTML comment block at the top of the file. + """ + if not output_path.exists(): + return False + content = output_path.read_text() + # Extract prs count + m_prs = re.search(r"^\s*prs:\s*(\d+)", content, re.MULTILINE) + # Extract diff range + m_diff = re.search(r"^\s*diff:\s*(\S+)", content, re.MULTILINE) + if not m_prs or not m_diff: + return False + if int(m_prs.group(1)) != new_prs_count or m_diff.group(1) != new_diff_range: + return False + + # status: only compare when the caller supplies the new value. + if new_status is not None: + m_status = re.search(r"^\s*status:\s*(\S+)", content, re.MULTILINE) + existing_status = m_status.group(1) if m_status else None + if existing_status != new_status: + return False + + # superseded: (...) — the marker carries the successor version as + # its first token; absent line means "not superseded". + m_sup_by = re.search(r"^\s*superseded:\s*(\S+)", content, re.MULTILINE) + existing_superseded_by = m_sup_by.group(1) if m_sup_by else None + if (new_superseded_by or None) != existing_superseded_by: + return False + + # supersedes: , (...) — compare the set of rolled-up versions. + m_supersedes = re.search(r"^\s*supersedes:\s*([^(\n]+)", content, re.MULTILINE) + existing_supersedes = ( + [s.strip() for s in m_supersedes.group(1).split(",") if s.strip()] + if m_supersedes else []) + if sorted(new_supersedes or []) != sorted(existing_supersedes): + return False + + return True def _removeprefix(s, prefix): @@ -64,12 +157,6 @@ def run(args, check=True): return result.stdout.strip() -def extract_base_version(tag): - # type: (str) -> str - """v3.119.2-preview.2.3 -> 3.119.2""" - return tag.lstrip("v").split("-")[0] - - def minor_group(version): # type: (str) -> str """3.119.2 -> 3.119""" @@ -135,89 +222,41 @@ def _version_has_stable_tag(version): return False -def _all_known_base_versions(all_branches): - # type: (list[str]) -> set[str] - """Collect every base version (X.Y.Z) known from release branches and tags.""" - versions = set() - for b in all_branches: - if b.endswith(".x"): - continue - versions.add(version_from_branch(b)) - tags = run(["git", "tag", "-l", "v*"], check=False) - for tag in tags.splitlines(): - tag = tag.strip() - if tag.startswith("v"): - versions.add(extract_base_version(tag)) - return versions - - -def _main_upcoming_version(): - # type: () -> Optional[str] - """Main's in-development version (origin/main, falling back to local).""" - return get_version_from_remote_branch("main") or get_upcoming_version() - - -def detect_superseded_by(version, all_branches): - # type: (str, list[str]) -> Optional[str] - """Auto-detect whether a preview-only version was skipped for a later one. - - A version is "superseded" when it never shipped a stable tag AND a strictly - later version exists. Returns the smallest later base version, preferring - one that actually shipped stable. This covers a skipped minor - (4.147.0 -> 4.148.0), an abandoned patch preview (3.119.3 -> 3.119.4), and - a minor skipped on main itself: when main's in-development version is newer - and the checked version's minor line was never branched for servicing - (no release/X.Y.x), main's version supersedes it — e.g. main on 4.148.0 - with only release/4.147.0-preview.* and no release/4.147.x. - Returns None while the version is still the latest line (just a preview). +def resolve_superseded_by(version): + # type: (str) -> Optional[str] + """Return the version that supersedes ``version``, or None. + + versions.json is the SINGLE source of truth: a version is superseded only + when its config entry sets ``status: superseded``, and the successor is the + configured ``superseded_by``. There is no auto-detection, so the superseded + set is identical to Cake's ``IsVersionSuperseded`` (which likewise treats + only an explicit ``status: superseded`` entry as superseded). Every other + version — listed with only ``compare_to`` or not listed at all — is a real + release and is never reported as superseded. To skip a version everywhere, + add it to versions.json. """ - if _version_has_stable_tag(version): - return None - vkey = version_key(version) - later = {v for v in _all_known_base_versions(all_branches) - if version_key(v) > vkey} - - # Main's in-development version supersedes a preview-only version when that - # version's minor line was never branched for servicing (it was skipped). - main_version = _main_upcoming_version() - if main_version and version_key(main_version) > vkey: - servicing = "release/{}.x".format(minor_group(version)) - if servicing not in all_branches: - later.add(main_version) - - if not later: - return None - stable_later = [v for v in later if _version_has_stable_tag(v)] - pool = sorted(stable_later or later, key=version_key) - return pool[0] - - -def resolve_superseded_by(version, all_branches): - # type: (str, list[str]) -> Optional[str] - """Whether a version was superseded (auto-detected).""" - return detect_superseded_by(version, all_branches) + entry = _versions_config_lookup(version) + if entry and entry.get("status") == "superseded": + return entry.get("superseded_by") + return None -def detect_supersedes(version, all_branches): - # type: (str, list[str]) -> list[str] - """Inverse of detect_superseded_by: the versions this release rolls up. +def detect_supersedes(version): + # type: (str) -> list[str] + """Return the versions that ``version`` rolls up (the inverse link). - Returns every earlier base version whose auto-detected successor is exactly - `version` — i.e. the preview-only / skipped versions that were superseded by - this one and whose work is rolled up cumulatively. This is the back-link that - makes the supersede relationship two-way: the superseded page already points + Reads versions.json directly: every entry marked ``status: superseded`` + whose ``superseded_by`` is exactly ``version``. This is the back-link that + makes the supersede relationship two-way — the superseded page points forward ("Superseded by X"), and this lets the successor page point back ("Supersedes Y"). Covers a skipped minor (4.148.0 supersedes 4.147.0) and an abandoned patch preview (3.119.4 supersedes 3.119.3). Returns [] when this version supersedes nothing. """ - vkey = version_key(version) - rolled = [] - for cand in _all_known_base_versions(all_branches): - if cand == version or version_key(cand) >= vkey: - continue - if resolve_superseded_by(cand, all_branches) == version: - rolled.append(cand) + rolled = [entry["version"] for entry in load_versions_config() + if entry.get("status") == "superseded" + and entry.get("superseded_by") == version + and entry.get("version")] rolled.sort(key=version_key) return rolled @@ -226,11 +265,20 @@ def _is_valid_stable_base(branch): # type: (str) -> bool """True if a release branch may serve as a cumulative "previous stable" base. - A branch is rejected if its version never shipped a stable tag. This makes - cross-minor rollups reach back to the last version that actually released as - stable, so preview-only/skipped minors are excluded automatically. + A branch is rejected when: + 1. versions.json explicitly marks its version as superseded (the config is + the authoritative override — e.g. 4.147 → never a base for 4.148), or + 2. its version never shipped a stable git tag (the automatic fallback for + versions not listed in the config). + + This makes cross-minor rollups reach back to the last version that actually + released as stable, so preview-only/skipped minors are excluded. """ - return _version_has_stable_tag(version_from_branch(branch)) + version = version_from_branch(branch) + entry = _versions_config_lookup(version) + if entry and entry.get("status") == "superseded": + return False + return _version_has_stable_tag(version) def _login_from_email(email): @@ -306,7 +354,7 @@ def compute_pr_effort(pr): # type: (dict) -> dict """Compute commit count and unique working days from git refs. - Uses refs/pull/N/merge to get base..head range for commit counting. + Uses refs/pull/N/head to get the base..head range for commit counting. If the PR body references a companion mono/skia PR, fetches that PR's commits too (filtered to SkiaSharp contributor authors only). """ @@ -425,36 +473,60 @@ def list_remote_release_branches(): return branches +# Prerelease stage ranks. Lower sorts earlier; every prerelease ranks below a +# stable release of the same patch (which uses STABLE_STAGE). Unknown labels +# rank just below stable so a recognised-but-unmapped prerelease still sorts as +# a prerelease, never as stable. +_PRERELEASE_STAGE = {"alpha": 0, "beta": 1, "preview": 2, "rc": 3} +_UNKNOWN_STAGE = 8 +_STABLE_STAGE = 9 + + def release_branch_sort_key(branch): # type: (str) -> tuple - """Compute semver sort key for a release branch. + """Compute a semver-ish sort key for a release branch (ascending). + + Order within a minor: + release/X.Y.x -> base/servicing, sorts before all patches + release/X.Y.Z[.W]-
.    -> prereleases: alpha < beta < preview < rc
+      release/X.Y.Z[.W]              -> stable, sorts after every prerelease
 
-    Sorting order:
-      release/X.Y.x             -> (X, Y, -1, 0, 0)  -- base, sorts first
-      release/X.Y.Z-preview.N   -> (X, Y,  Z, 0, N)
-      release/X.Y.Z             -> (X, Y,  Z, 1, 0)  -- stable after previews
+    Robust to the shapes that actually occur in this repo: dotted and undotted
+    prerelease labels (``preview.3``, ``preview28``, ``rc.1``, ``rc.147``),
+    multi-segment versions (``1.68.1.1``) and unrecognised labels. Anything
+    unparseable sorts to the very front (it can never be selected as "latest").
+
+    The tuple is (major, minor, patch, subpatch, stage, prenum); keep it the
+    same length everywhere so keys are always mutually comparable.
     """
-    m = re.match(r"release/(\d+)\.(\d+)\.(x|\d+(?:-preview\.\d+)?)$", branch)
-    if not m:
-        return (0, 0, 0, 0, 0)
+    name = _removeprefix(branch, "release/")
+    core, _, label = name.partition("-")  # label == "" when there is no '-'
+    parts = core.split(".")
+    if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit():
+        return (-1, 0, 0, 0, 0, 0)
 
-    major = int(m.group(1))
-    minor_num = int(m.group(2))
-    rest = m.group(3)
+    major = int(parts[0])
+    minor_num = int(parts[1])
+    third = parts[2] if len(parts) > 2 else "0"
 
-    if rest == "x":
-        return (major, minor_num, -1, 0, 0)
+    if third == "x":
+        # Servicing base sorts before every concrete patch of the same minor.
+        return (major, minor_num, -1, 0, 0, 0)
+    if not third.isdigit():
+        return (-1, 0, 0, 0, 0, 0)
 
-    m2 = re.match(r"(\d+)(?:-preview\.(\d+))?$", rest)
-    if not m2:
-        return (major, minor_num, 0, 0, 0)
+    patch = int(third)
+    subpatch = int(parts[3]) if len(parts) > 3 and parts[3].isdigit() else 0
 
-    patch = int(m2.group(1))
-    preview = m2.group(2)
+    if not label:
+        return (major, minor_num, patch, subpatch, _STABLE_STAGE, 0)
 
-    if preview is not None:
-        return (major, minor_num, patch, 0, int(preview))
-    return (major, minor_num, patch, 1, 0)
+    name_m = re.match(r"[A-Za-z]+", label)
+    stage_name = name_m.group(0).lower() if name_m else ""
+    nums = re.findall(r"\d+", label)
+    prenum = int(nums[0]) if nums else 0
+    stage = _PRERELEASE_STAGE.get(stage_name, _UNKNOWN_STAGE)
+    return (major, minor_num, patch, subpatch, stage, prenum)
 
 
 def version_from_branch(branch):
@@ -474,11 +546,13 @@ def version_from_branch(branch):
     return ver.split("-")[0]
 
 
-def find_previous_stable_base(all_branches, major, minor_num, patch):
-    # type: (list[str], int, int, int) -> Optional[str]
+def find_previous_stable_base(all_branches, major, minor_num, patch, subpatch=0):
+    # type: (list[str], int, int, int, int) -> Optional[str]
     """Find the previous stable release branch to use as the cumulative diff base.
 
-    For version X.Y.Z:
+    For version X.Y.Z[.W]:
+    0. If W > 0 (a 4-segment build such as 1.68.1.1): use the previous build in
+       the same patch line, walking down to the plain X.Y.Z release.
     1. If Z > 0: use the most recent previous patch that shipped stable,
        skipping preview-only / superseded patches (cumulative rollup).
     2. If Z == 0: look for the latest branch from a previous minor/major that
@@ -489,6 +563,26 @@ def find_previous_stable_base(all_branches, major, minor_num, patch):
     """
     minor = "{}.{}".format(major, minor_num)
 
+    # Case 0: W > 0 — a 4-segment build (e.g. 1.68.1.1). The cumulative base is
+    # the previous build in the same patch line (1.68.1.1 -> 1.68.1), so the 4th
+    # segment is never dropped (which would wrongly base on 1.68.0). Falls
+    # through to the patch-based search when the X.Y.Z line cannot be resolved.
+    if subpatch > 0:
+        patch_base = "{}.{}".format(minor, patch)
+        for sp in range(subpatch - 1, 0, -1):
+            cand = "release/{}.{}".format(patch_base, sp)
+            if cand in all_branches:
+                return cand
+        plain = "release/{}".format(patch_base)
+        if plain in all_branches:
+            return plain
+        prev_previews = [b for b in all_branches
+                         if b.startswith("release/{}-preview.".format(patch_base))]
+        if prev_previews:
+            prev_previews.sort(key=release_branch_sort_key)
+            return prev_previews[-1]
+        # X.Y.Z line not found as a branch — fall through to the patch search.
+
     # Case 1: Z > 0 — the cumulative base is the most recent PREVIOUS patch
     # that actually shipped as stable. Preview-only / superseded patches are
     # skipped so the next stable rolls up their work (e.g. 3.119.4 bases on
@@ -519,7 +613,7 @@ def find_previous_stable_base(all_branches, major, minor_num, patch):
     all_versioned.sort(key=release_branch_sort_key)
 
     # Find the latest branch that sorts before our minor
-    target_key = (major, minor_num, -2, 0, 0)  # before even .x
+    target_key = (major, minor_num, -2, 0, 0, 0)  # before even .x
     candidates = [b for b in all_versioned
                   if release_branch_sort_key(b) < target_key]
     if candidates:
@@ -536,6 +630,40 @@ def find_previous_stable_base(all_branches, major, minor_num, patch):
     return None
 
 
+def _resolve_compare_to(compare_to, to_ref, version, all_branches):
+    # type: (str, str, str, list[str]) -> Optional[Tuple[str, str, str]]
+    """Resolve an explicit versions.json ``compare_to`` baseline to a diff range.
+
+    Tries, in order:
+      1. the latest release branch whose base version equals ``compare_to`` (the
+         version plus its previews), matched exactly so e.g. "3.116.1" never also
+         matches release/3.116.10;
+      2. the exact ``release/`` branch;
+      3. a ``v`` git tag (baselines that exist only as a tag, never
+         as a release/* branch).
+
+    Returns (from_ref, ``to_ref``, ``version``) or None when the baseline cannot
+    be resolved, so the caller can fall through to automatic base detection.
+    """
+    compare_branches = [b for b in all_branches
+                        if not b.endswith(".x")
+                        and version_from_branch(b) == compare_to]
+    if compare_branches:
+        compare_branches.sort(key=release_branch_sort_key)
+        return ("origin/{}".format(compare_branches[-1]), to_ref, version)
+
+    exact_branch = "release/{}".format(compare_to)
+    if exact_branch in all_branches:
+        return ("origin/{}".format(exact_branch), to_ref, version)
+
+    tag_sha = run(["git", "rev-parse", "v{}".format(compare_to)],
+                  check=False).strip()
+    if tag_sha:
+        return (tag_sha, to_ref, version)
+
+    return None
+
+
 def determine_diff_range(branch):
     # type: (str) -> Tuple[str, str, str]
     """Determine the git diff range for a branch.
@@ -556,6 +684,15 @@ def determine_diff_range(branch):
                 "scripts/azure-templates-variables.yml")
         minor = minor_group(version)
 
+        # Check versions.json for an explicit compare_to override.
+        config_entry = _versions_config_lookup(version)
+        if config_entry and config_entry.get("compare_to"):
+            resolved = _resolve_compare_to(
+                config_entry["compare_to"], "origin/main", version, all_branches)
+            if resolved:
+                return resolved
+            # Override could not be resolved — fall through to auto-detection.
+
         # Find latest release branch for the same minor. These are the active
         # line for the upcoming version, so the latest one is always the base
         # (main shows work beyond the most recent preview cut).
@@ -623,18 +760,30 @@ def determine_diff_range(branch):
                         "origin/{}".format(branch), "origin/main"])
         return base_sha, "origin/{}".format(branch), version
 
-    # ── versioned branch (release/X.Y.Z or release/X.Y.Z-preview.N) ─
-    m_ver = re.match(r"release/(\d+)\.(\d+)\.(\d+)", branch)
+    # ── versioned branch (release/X.Y.Z[.W] or release/X.Y.Z-preview.N) ─
+    m_ver = re.match(r"release/(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?", branch)
     if not m_ver:
         raise RuntimeError("Cannot parse branch: {}".format(branch))
 
     major = int(m_ver.group(1))
     minor_num = int(m_ver.group(2))
     patch = int(m_ver.group(3))
+    subpatch = int(m_ver.group(4)) if m_ver.group(4) else 0
     version = version_from_branch(branch)  # strips preview suffix
 
+    # Check versions.json for an explicit compare_to override.
+    config_entry = _versions_config_lookup(version)
+    if config_entry and config_entry.get("compare_to"):
+        resolved = _resolve_compare_to(
+            config_entry["compare_to"], "origin/{}".format(branch), version,
+            all_branches)
+        if resolved:
+            return resolved
+        # Override could not be resolved — fall through to auto-detection.
+
     # Find the cumulative base: previous stable (or previous minor for Z==0)
-    base = find_previous_stable_base(all_branches, major, minor_num, patch)
+    base = find_previous_stable_base(all_branches, major, minor_num, patch,
+                                     subpatch)
 
     if base:
         return ("origin/{}".format(base),
@@ -1025,43 +1174,21 @@ def cmd_update_toc():
     print("Updated {}".format(RELEASES_DIR / "index.md"))
 
 
-def cmd_branch(branch):
-    # type: (str) -> None
-    """Diff a branch against its predecessor and write raw data to the version file."""
-    branch = _removeprefix(branch, "origin/")
+def _compute_page_status(branch, version):
+    # type: (str, str) -> Tuple[str, Optional[str], list[str]]
+    """Compute (status, superseded_by, supersedes) for a branch's page.
 
-    print("Fetching remote branches...")
-    try:
-        # Unshallow if needed (CI runners use shallow clones)
-        run(["git", "fetch", "origin", "--unshallow", "--quiet"], check=False)
-        # Fetch all release branches and main explicitly
-        run(["git", "fetch", "origin",
-             "refs/heads/release/*:refs/remotes/origin/release/*",
-             "refs/heads/main:refs/remotes/origin/main",
-             "--quiet"], check=True)
-    except subprocess.CalledProcessError:
-        print("ERROR: git fetch failed. Cannot determine branch diff range.")
-        sys.exit(1)
-
-    # Verify release branches are visible
-    all_branches = list_remote_release_branches()
-    if not all_branches:
-        print("ERROR: No release branches found after fetch.")
-        sys.exit(1)
-
-    from_ref, to_ref, version = determine_diff_range(branch)
-
-    # Human-readable display (strip origin/ prefix, shorten SHAs)
-    from_display = _removeprefix(from_ref, "origin/")
-    to_display = _removeprefix(to_ref, "origin/")
-    if re.match(r"^[0-9a-f]{7,}$", from_display):
-        from_display = from_display[:12]
-
-    # Determine release status: unreleased / preview / stable
-    is_main = (branch == "main")
-    is_servicing = branch.endswith(".x")
+    ``status`` is "unreleased" for main and servicing (.x) branches. For a
+    versioned branch it is derived from git tags — "stable" when a stable tag
+    exists, otherwise "preview" when only prereleases exist — and forced to
+    "preview" when versions.json marks the version superseded. The
+    ``superseded_by`` / ``supersedes`` links are the two-way supersession
+    relationship, read straight from versions.json (config-authoritative).
+    """
+    is_unreleased = (branch == "main") or branch.endswith(".x")
     status = "unreleased"
-    if not is_main and not is_servicing:
+    superseded_by = None
+    if not is_unreleased:
         tags = run(["git", "tag", "-l", "v{}*".format(version)], check=False)
         has_stable = False
         has_preview = False
@@ -1077,44 +1204,91 @@ def cmd_branch(branch):
             status = "stable"
         elif has_preview:
             status = "preview"
-
-    # Supersede marker: a minor that never ships stable (e.g. 4.147.0 skipped
-    # for 4.148.0). Auto-detected once a newer minor line appears. Forces
-    # "preview" status and a "superseded" label so the page makes clear it
-    # never released as stable.
-    superseded_by = None
-    if not is_main and not is_servicing:
-        superseded_by = resolve_superseded_by(version, all_branches)
+        # A version marked superseded in versions.json never shipped stable —
+        # force "preview" and surface the "superseded by" banner.
+        superseded_by = resolve_superseded_by(version)
         if superseded_by:
             status = "preview"
+    supersedes = detect_supersedes(version)
+    return status, superseded_by, supersedes
 
-    # Inverse relationship (two-way link): the preview-only versions that this
-    # release supersedes / rolls up. Applies to any successor — main, a
-    # servicing .x line, or a stable release branch.
-    supersedes = detect_supersedes(version, all_branches)
 
-    print("Branch: {}".format(branch))
-    print("Version: {}".format(version))
-    print("Status: {}".format(status))
-    if superseded_by:
-        print("Superseded by: {}".format(superseded_by))
-    if supersedes:
-        print("Supersedes: {}".format(", ".join(supersedes)))
-    print("Diff: {}..{}".format(from_display, to_display))
+def _page_filename(branch, version):
+    # type: (str, str) -> str
+    """Output file for a branch.
 
-    prs = get_prs_from_diff(from_ref, to_ref)
-    print("Found {} PR(s)".format(len(prs)))
+    main and servicing (.x) branches render the in-development page
+    ``{version}-unreleased.md``; a versioned branch renders the released page
+    ``{version}.md``.
+    """
+    if branch == "main" or branch.endswith(".x"):
+        return "{}-unreleased.md".format(version)
+    return "{}.md".format(version)
 
-    # Determine output file:
-    # - "main" branches (main, release/X.Y.x) → {version}-unreleased.md (unreleased)
-    # - versioned branches (release/X.Y.Z*) → {version}.md (released)
-    is_main_branch = (branch == "main")
-    is_servicing = bool(re.match(r"release/\d+\.\d+\.x$", branch))
 
-    if is_main_branch or is_servicing:
-        filename = "{}-unreleased.md".format(version)
-    else:
-        filename = "{}.md".format(version)
+def _canonical_branches_by_version(all_branches):
+    # type: (list[str]) -> dict[str, str]
+    """Map each base version to its canonical (highest-sorting) versioned branch.
+
+    Servicing (.x) branches are excluded. The canonical branch is the one that
+    renders a version's ``{version}.md`` page — the stable branch if it shipped,
+    otherwise the latest rc/preview — so every page is produced by exactly one
+    branch and parallel previews never overwrite each other.
+    """
+    canonical = {}  # type: dict[str, str]
+    for b in all_branches:
+        if b.endswith(".x"):
+            continue
+        ver = version_from_branch(b)
+        cur = canonical.get(ver)
+        if cur is None or release_branch_sort_key(b) > release_branch_sort_key(cur):
+            canonical[ver] = b
+    return canonical
+
+
+def _write_page(branch, all_branches, verbose=False):
+    # type: (str, list[str], bool) -> Optional[str]
+    """Generate one release page from a branch. Returns its path, or None.
+
+    Single code path shared by ``--branch`` and ``--all``: resolves the diff
+    range, status and supersession links, then writes
+    ``documentation/docfx/releases/{file}`` unless the existing file already
+    encodes identical raw data (idempotent). Returns the written path, or None
+    when the page was skipped (unchanged) or the diff range could not be
+    determined.
+    """
+    try:
+        from_ref, to_ref, version = determine_diff_range(branch)
+    except (RuntimeError, subprocess.CalledProcessError) as e:
+        print("  WARNING: Could not determine diff range for {}: {}".format(
+            branch, e), file=sys.stderr)
+        return None
+
+    from_display = _removeprefix(from_ref, "origin/")
+    to_display = _removeprefix(to_ref, "origin/")
+    if re.match(r"^[0-9a-f]{7,}$", from_display):
+        from_display = from_display[:12]
+    diff_range_str = "{}..{}".format(from_display, to_display)
+
+    status, superseded_by, supersedes = _compute_page_status(branch, version)
+
+    if verbose:
+        print("Branch: {}".format(branch))
+        print("Version: {}".format(version))
+        print("Status: {}".format(status))
+        if superseded_by:
+            print("Superseded by: {}".format(superseded_by))
+        if supersedes:
+            print("Supersedes: {}".format(", ".join(supersedes)))
+
+    prs = get_prs_from_diff(from_ref, to_ref)
+    print("  Found {} PR(s), diff: {}".format(len(prs), diff_range_str))
+
+    output_path = RELEASES_DIR / _page_filename(branch, version)
+    if _is_content_unchanged(output_path, len(prs), diff_range_str,
+                             status, superseded_by, supersedes):
+        print("  Skipping {} (unchanged)".format(output_path))
+        return None
 
     metadata = {
         "branch": branch,
@@ -1127,26 +1301,63 @@ def cmd_branch(branch):
         metadata["superseded_by"] = superseded_by
     if supersedes:
         metadata["supersedes"] = supersedes
-    content = format_pr_list(prs, metadata)
 
-    output_path = RELEASES_DIR / filename
     RELEASES_DIR.mkdir(parents=True, exist_ok=True)
-    output_path.write_text(content)
-    print("Wrote {}".format(output_path))
+    output_path.write_text(format_pr_list(prs, metadata))
+    print("  Wrote {} ({} PRs)".format(output_path, len(prs)))
+    return str(output_path)
 
-    files_to_polish = [str(output_path)]
 
-    # When a versioned branch is pushed, also regenerate the unreleased
-    # file(s) — the diff range for main or the .x branch may have changed
-    # because a new release branch now exists.
-    if not is_main_branch and not is_servicing:
-        extra_files = _regen_unreleased(branch)
-        files_to_polish.extend(extra_files)
+def cmd_branch(branch):
+    # type: (str) -> None
+    """Diff a branch against its predecessor and write raw data to the version file."""
+    branch = _removeprefix(branch, "origin/")
+
+    print("Fetching remote branches...")
+    try:
+        # Unshallow if needed (CI runners use shallow clones)
+        run(["git", "fetch", "origin", "--unshallow", "--quiet"], check=False)
+        # Fetch all release branches and main explicitly
+        run(["git", "fetch", "origin",
+             "refs/heads/release/*:refs/remotes/origin/release/*",
+             "refs/heads/main:refs/remotes/origin/main",
+             "--quiet"], check=True)
+    except subprocess.CalledProcessError:
+        print("ERROR: git fetch failed. Cannot determine branch diff range.")
+        sys.exit(1)
+
+    all_branches = list_remote_release_branches()
+    if not all_branches:
+        print("ERROR: No release branches found after fetch.")
+        sys.exit(1)
+
+    # Resolve a versioned branch to the canonical branch for its version so a
+    # manual --branch on a preview never clobbers the stable {version}.md page,
+    # and the result matches exactly what --all would produce. main and
+    # servicing (.x) branches are keyed by branch (not version) and processed
+    # as-is.
+    target = branch
+    if branch != "main" and not branch.endswith(".x"):
+        version = version_from_branch(branch)
+        canonical = _canonical_branches_by_version(all_branches).get(version)
+        if canonical and canonical != branch:
+            print("Note: {} is not the canonical branch for {}; "
+                  "processing {} instead.".format(branch, version, canonical))
+            target = canonical
+
+    files_to_polish = []
+    path = _write_page(target, all_branches, verbose=True)
+    if path:
+        files_to_polish.append(path)
+
+    # When a versioned branch is pushed, also regenerate the unreleased file(s)
+    # — the diff range for main or the servicing .x branch may have changed now
+    # that a new release branch exists.
+    if target != "main" and not target.endswith(".x"):
+        files_to_polish.extend(_regen_unreleased(target, all_branches))
 
-    # Regenerate TOC and index
     cmd_update_toc()
 
-    # Print summary for the AI agent
     print("")
     print("========================================")
     print("Files to polish:")
@@ -1155,90 +1366,125 @@ def cmd_branch(branch):
     print("========================================")
 
 
-def _regen_unreleased(trigger_branch):
-    # type: (str) -> list[str]
-    """Regenerate unreleased files after a versioned branch push.
-
-    When a new release/X.Y.Z branch appears, the diff ranges for
-    main and/or the servicing release/X.Y.x branch may have changed.
+def _regen_unreleased(trigger_branch, all_branches):
+    # type: (str, list[str]) -> list[str]
+    """Regenerate unreleased pages after a versioned branch push.
 
-    Returns a list of file paths that were written.
+    When a new release/X.Y.Z branch appears, the diff ranges for main and/or the
+    servicing release/X.Y.x branch may have moved. Regenerates whichever
+    unreleased pages are affected and returns the paths actually written.
     """
     m = re.match(r"release/(\d+)\.(\d+)\.\d+", trigger_branch)
     if not m:
         return []
+    minor = "{}.{}".format(m.group(1), m.group(2))
+    written = []  # type: list[str]
 
-    written_files = []  # type: list[str]
-
-    major = int(m.group(1))
-    minor_num = int(m.group(2))
-    minor = "{}.{}".format(major, minor_num)
-
-    all_branches = list_remote_release_branches()
-
-    # Regenerate the servicing branch (.x) if it exists
+    # Servicing (.x) line for the same minor, if it exists.
     svc_branch = "release/{}.x".format(minor)
     if svc_branch in all_branches:
         print("\nRegenerating unreleased for {}...".format(svc_branch))
-        try:
-            from_ref, to_ref, svc_version = determine_diff_range(svc_branch)
-            from_display = _removeprefix(from_ref, "origin/")
-            if re.match(r"^[0-9a-f]{7,}$", from_display):
-                from_display = from_display[:12]
-            to_display = _removeprefix(to_ref, "origin/")
-
-            prs = get_prs_from_diff(from_ref, to_ref)
-            metadata = {
-                "branch": svc_branch,
-                "version": svc_version,
-                "status": "unreleased",
-                "from": from_display,
-                "to": to_display,
-            }
-            svc_path = RELEASES_DIR / "{}-unreleased.md".format(svc_version)
-            svc_path.write_text(format_pr_list(prs, metadata))
-            print("Wrote {} ({} PRs)".format(svc_path, len(prs)))
-            written_files.append(str(svc_path))
-        except (RuntimeError, subprocess.CalledProcessError) as e:
-            print("  WARNING: Could not regenerate {}: {}".format(
-                svc_branch, e), file=sys.stderr)
-
-    # Regenerate main's unreleased file — but only if the trigger branch
-    # is in the same minor as main's upcoming version. A push to a 3.119.x
-    # preview doesn't change main's diff range if main is on 4.147.x.
+        path = _write_page(svc_branch, all_branches)
+        if path:
+            written.append(path)
+
+    # main — only when its upcoming version is in the same minor as the trigger
+    # (a push to a 3.119.x preview doesn't move main's range if main is on 4.x).
     main_version = get_upcoming_version()
-    main_minor = minor_group(main_version) if main_version else None
-    if main_minor and main_minor == minor:
+    if main_version and minor_group(main_version) == minor:
         print("\nRegenerating unreleased for main...")
-        try:
-            from_ref, to_ref, main_version = determine_diff_range("main")
-            from_display = _removeprefix(from_ref, "origin/")
-            if re.match(r"^[0-9a-f]{7,}$", from_display):
-                from_display = from_display[:12]
-            to_display = _removeprefix(to_ref, "origin/")
-
-            prs = get_prs_from_diff(from_ref, to_ref)
-            metadata = {
-                "branch": "main",
-                "version": main_version,
-                "status": "unreleased",
-                "from": from_display,
-                "to": to_display,
-            }
-            main_path = RELEASES_DIR / "{}-unreleased.md".format(main_version)
-            main_path.write_text(format_pr_list(prs, metadata))
-            print("Wrote {} ({} PRs)".format(main_path, len(prs)))
-            written_files.append(str(main_path))
-        except (RuntimeError, subprocess.CalledProcessError) as e:
-            print("  WARNING: Could not regenerate main: {}".format(e),
-                  file=sys.stderr)
+        path = _write_page("main", all_branches)
+        if path:
+            written.append(path)
 
-    return written_files
+    return written
 
 
 # ── Main ─────────────────────────────────────────────────────────────
 
 
+def cmd_all():
+    # type: () -> None
+    """Process all branches (main + all release/*). Skip unchanged files.
+
+    This is the idempotent "regenerate everything" mode used by the automated
+    workflow. It iterates over every known branch and regenerates its raw PR
+    data, but only WRITES files whose content actually changed (same PR count
+    AND same diff range == no write). The "Files to polish" output therefore
+    only lists files that genuinely changed, so the AI never re-polishes pages
+    that are already up to date.
+
+    Superseded versions (e.g. 4.147, which was skipped for 4.148) are still
+    generated — they keep their own page with a "superseded by" label. The
+    supersede marker only affects which version is used as a *baseline* when
+    diffing others (handled in determine_diff_range / _is_valid_stable_base),
+    never whether a page is produced.
+    """
+    print("Fetching remote branches...")
+    try:
+        run(["git", "fetch", "origin", "--unshallow", "--quiet"], check=False)
+        run(["git", "fetch", "origin",
+             "refs/heads/release/*:refs/remotes/origin/release/*",
+             "refs/heads/main:refs/remotes/origin/main",
+             "--quiet"], check=True)
+    except subprocess.CalledProcessError:
+        print("ERROR: git fetch failed.")
+        sys.exit(1)
+
+    all_branches = list_remote_release_branches()
+    if not all_branches:
+        print("ERROR: No release branches found after fetch.")
+        sys.exit(1)
+
+    # Build the processing list. Each output file must be produced by exactly
+    # ONE branch, otherwise branches that map to the same page overwrite each
+    # other and the last one processed wins (e.g. release/3.119.2 and its
+    # release/3.119.2-preview.* all render to 3.119.2.md). So:
+    #   - main                         -> {upcoming}-unreleased.md
+    #   - each servicing release/X.Y.x -> {X.Y.x}-unreleased.md  (distinct files)
+    #   - ONE canonical branch per versioned base version -> {version}.md
+    # Superseded versions are still included — they each keep their own page
+    # with a "superseded by" label (see docstring).
+    servicing_branches = [b for b in all_branches if b.endswith(".x")]
+    canonical_branches = sorted(
+        _canonical_branches_by_version(all_branches).values(),
+        key=release_branch_sort_key)
+    branches_to_process = ["main"] + servicing_branches + canonical_branches
+
+    files_to_polish = []
+    skipped_count = 0
+    processed_count = 0
+
+    for branch in branches_to_process:
+        if branch == "main" and not get_upcoming_version():
+            print("WARNING: Cannot determine main version, skipping main.")
+            continue
+
+        print("\n--- Processing: {} ---".format(branch))
+        path = _write_page(branch, all_branches)
+        if path:
+            files_to_polish.append(path)
+            processed_count += 1
+        else:
+            skipped_count += 1
+
+    # Regenerate TOC and index
+    cmd_update_toc()
+
+    # Print summary for the AI agent
+    print("")
+    print("========================================")
+    print("Processed: {}, Skipped/unchanged: {}".format(
+        processed_count, skipped_count))
+    print("Files to polish:")
+    if files_to_polish:
+        for f in files_to_polish:
+            print("  - {}".format(f))
+    else:
+        print("  (none — all files up to date)")
+    print("========================================")
+
+
 def main():
     parser = argparse.ArgumentParser(
         description="Fetch SkiaSharp release data for the website",
@@ -1249,6 +1495,8 @@ def main():
             "Raw PR data -> releases/4.147.0.md\n"
             "  %(prog)s --branch release/3.119.x            "
             "Raw PR data -> releases/3.119.5.md\n"
+            "  %(prog)s --all                               "
+            "Process all branches (skip unchanged)\n"
             "  %(prog)s --update-toc                        "
             "Regenerate TOC + index\n"
         ),
@@ -1256,21 +1504,27 @@ def main():
     parser.add_argument(
         "--branch",
         help="Diff branch against its predecessor and write raw PR data")
+    parser.add_argument(
+        "--all", action="store_true",
+        help="Process all branches (main + release/*), skip unchanged files")
     parser.add_argument(
         "--update-toc", action="store_true",
         help="Regenerate TOC.yml + index.md")
 
     args = parser.parse_args()
 
-    if not args.branch and not args.update_toc:
+    if not args.branch and not args.update_toc and not args.all:
         parser.print_help()
         sys.exit(1)
 
-    if args.branch and args.update_toc:
-        parser.error("Specify only one of --branch or --update-toc")
+    num_modes = sum([bool(args.branch), args.update_toc, args.all])
+    if num_modes > 1:
+        parser.error("Specify only one of --branch, --all, or --update-toc")
 
     if args.update_toc:
         cmd_update_toc()
+    elif args.all:
+        cmd_all()
     elif args.branch:
         cmd_branch(args.branch)
 
diff --git a/.github/workflows/api-diff.yml b/.github/workflows/api-diff.yml
index 239a917793f..f1ffb5f085b 100644
--- a/.github/workflows/api-diff.yml
+++ b/.github/workflows/api-diff.yml
@@ -1,28 +1,56 @@
 name: API Diff
 
+# ─────────────────────────────────────────────────────────────────────────────
+# API Diff workflow
+#
+# Keeps the committed API changelogs under changelogs/ up to date. It is a thin
+# runner around a single Cake target — there is no skill and no shell wrapper to
+# keep in sync:
+#
+#     dotnet cake --target=docs-api-diff-past
+#
+# That target wraps Mono.ApiTools.NuGetDiff, walks every version published to
+# NuGet.org (prereleases included) and writes a per-assembly markdown diff to
+# changelogs/{PackageId}/{version}/{Assembly}.md. Baseline selection and
+# superseded-version handling are driven by scripts/versions.json — see that
+# file's $comment fields and the docs-api-diff-past target in
+# scripts/infra/docs/docs.cake. To reproduce a run locally, just run that one
+# command from the repo root.
+#
+# This workflow only adds the automation around that command: when to run it and
+# how to turn its output into a single rolling PR. It deliberately does NOT
+# re-document how diffs are generated.
+#
+# Preview ("what will an unreleased branch's diff look like?") is intentionally
+# NOT automated here. It is a manual, multi-step operation — see the "Previewing
+# an unreleased branch" section in documentation/dev/writing-docs.md.
+# ─────────────────────────────────────────────────────────────────────────────
+
 on:
   push:
-    branches: [main]
-    paths:
+    branches:
+      - main
+      - 'release/**'
+    paths-ignore:
+      # Don't loop: the workflow's own changelog commits must not retrigger it.
+      - 'changelogs/**'
       - '.github/workflows/api-diff.yml'
+    tags:
+      - 'v*'
   schedule:
-    # Weekly on Sunday at 00:00 UTC
+    # Weekly safety net on Sunday at 00:00 UTC, in case a publish was missed.
     - cron: '0 0 * * 0'
   workflow_dispatch:
     inputs:
-      ref:
-        description: 'Branch, tag, or SHA to diff (e.g., main, release/3.119.x, v3.119.3-preview.1.1)'
-        required: false
-        default: 'main'
       prerelease:
-        description: 'Include prerelease versions as baseline'
+        description: 'Include prerelease versions in the diff set'
         type: boolean
         required: false
-        default: false
+        default: true
 
 concurrency:
-  group: api-diff-${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'main' }}
-  cancel-in-progress: false
+  group: api-diff
+  cancel-in-progress: true
 
 permissions: {}
 
@@ -30,123 +58,57 @@ jobs:
   generate:
     name: Generate API Diff
     runs-on: ubuntu-latest
-    timeout-minutes: 30
+    timeout-minutes: 60
     permissions:
       contents: read
+    env:
+      # Default to including prereleases (the 4.x line ships only as prereleases
+      # for now, so they must appear in the changelog set). Only an explicit
+      # workflow_dispatch with prerelease=false turns them off; every other
+      # trigger (and the default) yields 'true'. Written this way to avoid the
+      # `&& inputs || 'true'` boolean footgun, where false would collapse back
+      # to 'true' and make the input impossible to disable.
+      PRERELEASE: ${{ (github.event_name == 'workflow_dispatch' && inputs.prerelease == false) && 'false' || 'true' }}
     outputs:
-      version: ${{ steps.version.outputs.version }}
+      has_changes: ${{ steps.check.outputs.has_changes }}
     steps:
-      - name: Resolve ref
-        id: resolve
-        env:
-          INPUT_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'main' }}
-        run: |
-          set -euo pipefail
-
-          # Determine download mode based on ref format
-          if [[ "$INPUT_REF" =~ ^(main|release/[0-9]+\.[0-9]+\.[0-9a-z._-]+)$ ]]; then
-            echo "mode=branch" >> "$GITHUB_OUTPUT"
-          elif [[ "$INPUT_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9][0-9a-z._+-]*$ ]]; then
-            echo "mode=tag" >> "$GITHUB_OUTPUT"
-          elif [[ "$INPUT_REF" =~ ^[0-9a-f]{40}$ ]]; then
-            echo "mode=sha" >> "$GITHUB_OUTPUT"
-          else
-            echo "::error::Invalid ref: '$INPUT_REF'. Must be a branch (main, release/X.Y.Z), tag (vX.Y.Z...), or 40-char SHA."
-            exit 1
-          fi
-          echo "ref=$INPUT_REF" >> "$GITHUB_OUTPUT"
-          echo "Ref: $INPUT_REF"
-
       - name: Checkout
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
-          ref: ${{ github.ref }}
+          # Always regenerate the full historical set from main; the diff source
+          # is NuGet.org, not the checked-out tree.
+          ref: main
           fetch-depth: 1
           persist-credentials: false
 
-      - name: Get package versions from ref
-        id: fetch-ref
-        if: steps.resolve.outputs.mode != 'branch' || steps.resolve.outputs.ref != 'main'
-        env:
-          DIFF_REF: ${{ steps.resolve.outputs.ref }}
-        run: |
-          set -euo pipefail
-          # Fetch the target ref to resolve SHA and get package versions
-          git fetch --depth=1 origin "$DIFF_REF"
-          RESOLVED_SHA=$(git rev-parse FETCH_HEAD)
-          echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT"
-          echo "Resolved SHA: $RESOLVED_SHA"
-
-          # Extract only the nuget version lines from the ref's VERSIONS.txt
-          # Keep main's dependency (release/url) lines intact for compatibility
-          REF_NUGET_LINES=$(git show FETCH_HEAD:scripts/VERSIONS.txt | grep -E '[[:space:]]nuget[[:space:]]')
-
-          # Replace nuget lines in main's VERSIONS.txt with the ref's versions
-          grep -v -E '[[:space:]]nuget[[:space:]]' scripts/VERSIONS.txt | grep -v '^# nuget versions' > scripts/VERSIONS.tmp
-          mv scripts/VERSIONS.tmp scripts/VERSIONS.txt
-          # Append the ref's nuget lines
-          printf '\n# nuget versions\n' >> scripts/VERSIONS.txt
-          echo "$REF_NUGET_LINES" >> scripts/VERSIONS.txt
-
-          echo "Updated nuget versions from $DIFF_REF:"
-          grep 'nuget' scripts/VERSIONS.txt | head -5
-
       - name: Setup .NET
         uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
         with:
           global-json-file: global.json
 
-      - name: Restore tools
-        run: dotnet tool restore
-
-      - name: Download CI packages
-        env:
-          DIFF_MODE: ${{ steps.resolve.outputs.mode }}
-          DIFF_REF: ${{ steps.resolve.outputs.ref }}
-          RESOLVED_SHA: ${{ steps.fetch-ref.outputs.sha }}
+      # Single Cake target — the exact same command a human runs locally. It
+      # reads scripts/versions.json for compare_to / supersession overrides and
+      # writes the changelogs under changelogs/.
+      - name: Generate changelogs
         run: |
           set -euo pipefail
-          if [[ "$DIFF_MODE" == "branch" ]]; then
-            dotnet cake --target=docs-download-output --gitBranch="$DIFF_REF"
-          else
-            echo "Downloading packages for SHA: $RESOLVED_SHA"
-            dotnet cake --target=docs-download-output --gitSha="$RESOLVED_SHA"
-          fi
+          dotnet tool restore
+          dotnet cake --target=docs-api-diff-past --nugetDiffPrerelease="$PRERELEASE"
 
-      - name: Generate API diff
-        env:
-          DIFF_MODE: ${{ steps.resolve.outputs.mode }}
-          DIFF_REF: ${{ steps.resolve.outputs.ref }}
-          RESOLVED_SHA: ${{ steps.fetch-ref.outputs.sha }}
-          PRERELEASE: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || 'false' }}
+      - name: Check for changes
+        id: check
         run: |
-          set -euo pipefail
-          if [[ "$DIFF_MODE" == "branch" ]]; then
-            dotnet cake --target=docs-api-diff --gitBranch="$DIFF_REF" --nugetDiffPrerelease="$PRERELEASE"
+          # Detect ACTUAL changes to the changelogs, not merely the presence of
+          # files. changelogs/README.md is committed, so a bare existence check
+          # would always be true and the publish job would run every time.
+          if [ -n "$(git status --porcelain changelogs/)" ]; then
+            echo "has_changes=true" >> "$GITHUB_OUTPUT"
           else
-            dotnet cake --target=docs-api-diff --gitSha="$RESOLVED_SHA" --nugetDiffPrerelease="$PRERELEASE"
-          fi
-
-      - name: Upload API diff artifact
-        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
-        with:
-          name: api-diff
-          path: output/api-diff/
-          if-no-files-found: warn
-
-      - name: Detect version
-        id: version
-        run: |
-          set -euo pipefail
-          VERSION=$(awk '/^SkiaSharp[[:space:]]/ && /nuget/ {print $NF; exit}' scripts/VERSIONS.txt)
-          if [[ -z "$VERSION" || ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
-            echo "::error::Failed to detect valid SkiaSharp version from VERSIONS.txt (got: '$VERSION')"
-            exit 1
+            echo "has_changes=false" >> "$GITHUB_OUTPUT"
           fi
-          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
-          echo "Detected version: $VERSION"
 
       - name: Upload changelogs
+        if: steps.check.outputs.has_changes == 'true'
         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
         with:
           name: changelogs
@@ -156,6 +118,7 @@ jobs:
   publish:
     name: Create PR
     needs: generate
+    if: needs.generate.outputs.has_changes == 'true'
     runs-on: ubuntu-latest
     timeout-minutes: 5
     permissions:
@@ -168,6 +131,19 @@ jobs:
           ref: main
           fetch-depth: 1
 
+      - name: Clear committed changelogs
+        run: |
+          set -euo pipefail
+          # The artifact holds the COMPLETE desired changelogs/ tree (every
+          # generated diff plus the committed README). download-artifact only
+          # ever ADDS/overwrites files, so a file the generate job removed —
+          # e.g. a stale *.breaking.md after a baseline change — would survive
+          # here and the deletion would be silently lost. Clearing the tree
+          # first makes the artifact authoritative, so removals are staged as
+          # deletions by `git add -A` below.
+          rm -rf changelogs
+          mkdir changelogs
+
       - name: Download changelogs artifact
         uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
         with:
@@ -177,42 +153,45 @@ jobs:
       - name: Commit and create PR
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          VERSION: ${{ needs.generate.outputs.version }}
         run: |
           set -euo pipefail
 
-          PR_BRANCH="bot/api-diff-v${VERSION}"
+          # Single rolling PR branch — force-pushed each run so there is at most
+          # one open API-changelog PR, never one-per-version.
+          PR_BRANCH="bot/api-diff"
 
-          # Check for changes (tracked + untracked)
-          git add changelogs/
+          # No-op guard: if regeneration produced byte-identical output, don't
+          # open/refresh a PR. `-A` stages additions, modifications AND the
+          # deletions exposed by the clear-before-extract step above.
+          git add -A changelogs/
           if git diff --cached --quiet; then
             echo "No changelog changes detected. Skipping PR."
             exit 0
           fi
 
-          # Configure git
           git config user.name "github-actions[bot]"
           git config user.email "github-actions[bot]@users.noreply.github.com"
 
-          # Create/reset the PR branch and commit
           git checkout -B "$PR_BRANCH"
-          git commit -m "[docs] Update API diff for v${VERSION}" \
-            -m "Generated by the api-diff workflow."
+          git commit -m "[docs] Update API changelogs" \
+            -m "Regenerated by the api-diff workflow using Mono.ApiTools.NuGetDiff.
+          Compares all published NuGet.org versions (including prereleases).
+          Respects scripts/versions.json for comparison overrides."
 
           git push --force origin "$PR_BRANCH"
 
-          # Create PR if one doesn't already exist
-          existing=$(gh pr list --head "$PR_BRANCH" --base main --state open --json number --jq '.[0].number')
+          existing=$(gh pr list --head "$PR_BRANCH" --base main --state open --json number --jq '.[0].number // empty')
           if [ -n "$existing" ]; then
             echo "PR #$existing already exists, force-pushed update"
           else
             gh pr create --base main --head "$PR_BRANCH" \
-              --title "[docs] API diff for v${VERSION}" \
+              --title "[docs] API changelogs" \
               --label "documentation" \
-              --body "Automated API diff comparing v${VERSION} against the latest published NuGet.org release. Generated by the \`api-diff\` workflow using \`Mono.ApiTools.NuGetDiff\`.
+              --body "Automated API changelogs comparing all published NuGet.org versions.
+          Generated by the \`api-diff\` workflow using \`Mono.ApiTools.NuGetDiff\`.
+
+          Respects \`scripts/versions.json\` for comparison overrides and supersession tracking.
 
           ---
-          - **Version:** ${VERSION}
           - **Workflow run:** ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
           fi
-
diff --git a/.github/workflows/update-release-notes.lock.yml b/.github/workflows/update-release-notes.lock.yml
index 9e20b4785af..7f84bbf9309 100644
--- a/.github/workflows/update-release-notes.lock.yml
+++ b/.github/workflows/update-release-notes.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"674b0b60d3484f7ffc923d59db46622e0fcde4e9582cf8ffebe35dbbd8aedd94","compiler_version":"v0.71.5","strict":true,"agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"4f8405adab05b1dc9716d24ce3c6caa52d1dac8e5642e3313f753ddeee974787","compiler_version":"v0.71.5","strict":true,"agent_id":"copilot"}
 # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"b8068426813005612b960b5ab0b8bd2c27142323","version":"v0.71.5"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.40","digest":"sha256:14ff567e8d9d4c2fbc5e55c973488381c71d7e0fdbe72d30ee7b8a738fd86504","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.25.40@sha256:14ff567e8d9d4c2fbc5e55c973488381c71d7e0fdbe72d30ee7b8a738fd86504"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.40","digest":"sha256:2883ca3e5ae9f330cafdd9345bfd4ae17fc8da36c96d4c9a1f76e922b4c45280","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.40@sha256:2883ca3e5ae9f330cafdd9345bfd4ae17fc8da36c96d4c9a1f76e922b4c45280"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.40","digest":"sha256:b084f4a2c771f584ee68084ced52fa6b3245197a1889645d817462d307d3ac51","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.25.40@sha256:b084f4a2c771f584ee68084ced52fa6b3245197a1889645d817462d307d3ac51"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.6","digest":"sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c"},{"image":"ghcr.io/github/github-mcp-server:v1.0.3","digest":"sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
 #    ___                   _   _      
 #   / _ \                 | | (_)     
@@ -74,7 +74,7 @@ permissions: {}
 
 concurrency:
   cancel-in-progress: true
-  group: update-release-notes-${{ github.ref }}
+  group: update-release-notes
 
 run-name: "Update Release Notes"
 
@@ -197,23 +197,23 @@ jobs:
         run: |
           bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
           {
-          cat << 'GH_AW_PROMPT_53297906f9ee98e2_EOF'
+          cat << 'GH_AW_PROMPT_7e6e28ea8a4544d2_EOF'
           
-          GH_AW_PROMPT_53297906f9ee98e2_EOF
+          GH_AW_PROMPT_7e6e28ea8a4544d2_EOF
           cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
           cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
           cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
           cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
-          cat << 'GH_AW_PROMPT_53297906f9ee98e2_EOF'
+          cat << 'GH_AW_PROMPT_7e6e28ea8a4544d2_EOF'
           
           Tools: create_pull_request, missing_tool, missing_data, noop
-          GH_AW_PROMPT_53297906f9ee98e2_EOF
+          GH_AW_PROMPT_7e6e28ea8a4544d2_EOF
           cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
-          cat << 'GH_AW_PROMPT_53297906f9ee98e2_EOF'
+          cat << 'GH_AW_PROMPT_7e6e28ea8a4544d2_EOF'
           
-          GH_AW_PROMPT_53297906f9ee98e2_EOF
+          GH_AW_PROMPT_7e6e28ea8a4544d2_EOF
           cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
-          cat << 'GH_AW_PROMPT_53297906f9ee98e2_EOF'
+          cat << 'GH_AW_PROMPT_7e6e28ea8a4544d2_EOF'
           
           The following GitHub context information is available for this workflow:
           {{#if __GH_AW_GITHUB_ACTOR__ }}
@@ -242,12 +242,12 @@ jobs:
           {{/if}}
           
           
-          GH_AW_PROMPT_53297906f9ee98e2_EOF
+          GH_AW_PROMPT_7e6e28ea8a4544d2_EOF
           cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
-          cat << 'GH_AW_PROMPT_53297906f9ee98e2_EOF'
+          cat << 'GH_AW_PROMPT_7e6e28ea8a4544d2_EOF'
           
           {{#runtime-import .github/workflows/update-release-notes.md}}
-          GH_AW_PROMPT_53297906f9ee98e2_EOF
+          GH_AW_PROMPT_7e6e28ea8a4544d2_EOF
           } > "$GH_AW_PROMPT"
       - name: Interpolate variables and render templates
         uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -436,9 +436,9 @@ jobs:
           mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
           mkdir -p /tmp/gh-aw/safeoutputs
           mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
-          cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_5bb97ee3e69a95ca_EOF'
+          cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_b2662d890401fcea_EOF'
           {"create_pull_request":{"allowed_base_branches":["main"],"draft":false,"labels":["documentation"],"max":1,"max_patch_files":100,"max_patch_size":1024,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"recreate_ref":true,"title_prefix":"[docs] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
-          GH_AW_SAFE_OUTPUTS_CONFIG_5bb97ee3e69a95ca_EOF
+          GH_AW_SAFE_OUTPUTS_CONFIG_b2662d890401fcea_EOF
       - name: Generate Safe Outputs Tools
         env:
           GH_AW_TOOLS_META_JSON: |
@@ -646,7 +646,7 @@ jobs:
           
           mkdir -p /home/runner/.copilot
           GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
-          cat << GH_AW_MCP_CONFIG_f2556535b2cc304b_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
+          cat << GH_AW_MCP_CONFIG_fe7001875a95e7d0_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
           {
             "mcpServers": {
               "github": {
@@ -687,7 +687,7 @@ jobs:
               "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
             }
           }
-          GH_AW_MCP_CONFIG_f2556535b2cc304b_EOF
+          GH_AW_MCP_CONFIG_fe7001875a95e7d0_EOF
       - name: Mount MCP servers as CLIs
         id: mount-mcp-clis
         continue-on-error: true
@@ -740,7 +740,7 @@ jobs:
         # --allow-tool shell(wc)
         # --allow-tool shell(yq)
         # --allow-tool write
-        timeout-minutes: 10
+        timeout-minutes: 15
         run: |
           set -o pipefail
           touch /tmp/gh-aw/agent-step-summary.md
@@ -1070,7 +1070,7 @@ jobs:
           GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
           GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
           GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
-          GH_AW_TIMEOUT_MINUTES: "10"
+          GH_AW_TIMEOUT_MINUTES: "15"
         with:
           github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
           script: |
diff --git a/.github/workflows/update-release-notes.md b/.github/workflows/update-release-notes.md
index 659ebc7dff3..51bb3c988fb 100644
--- a/.github/workflows/update-release-notes.md
+++ b/.github/workflows/update-release-notes.md
@@ -9,9 +9,9 @@ on:
   workflow_dispatch:
   skip-bots: [github-actions, copilot, dependabot]
 concurrency:
-  group: update-release-notes-${{ github.ref }}
+  group: update-release-notes
   cancel-in-progress: true
-timeout-minutes: 10
+timeout-minutes: 15
 permissions:
   contents: read
 tools:
@@ -30,62 +30,36 @@ safe-outputs:
 
 # Update Release Notes
 
-Automatically update website release notes when code changes land on `main`,
-`release/*` branches, or when release tags are pushed.
+Automation that keeps the website release notes current. This file owns only the
+**prep and automation**. The actual work — running the script and polishing the
+pages — is the **release-notes skill**
+([`.agents/skills/release-notes/SKILL.md`](../../.agents/skills/release-notes/SKILL.md)).
+Do not restate the skill's steps here.
 
-## Step 1 — Determine the branch
+## Prep
 
-```bash
-echo "Ref: $GITHUB_REF"
-```
-
-Determine the branch name based on the ref type:
-
-```bash
-if echo "$GITHUB_REF" | grep -q "^refs/tags/"; then
-  # Tag push — derive release branch from tag
-  TAG=${GITHUB_REF#refs/tags/}
-  TAG_NO_V=${TAG#v}
-  if echo "$TAG_NO_V" | grep -qE "\-preview\.[0-9]+\.[0-9]+$"; then
-    BRANCH="release/$(echo "$TAG_NO_V" | sed 's|\.[0-9]*$||')"
-  else
-    BRANCH="release/${TAG_NO_V}"
-  fi
-else
-  # Branch push — extract branch name directly
-  BRANCH="${GITHUB_REF#refs/heads/}"
-fi
-```
-
-## Step 2 — Generate release notes using the skill
-
-**Important:** The script uses `origin/` remote refs for all git history queries.
-Always run from a `main` checkout to avoid leaking release-branch-specific files
-(like `PREVIEW_LABEL`) into the PR targeting main.
-
-Ensure all remote refs are fetched and the working tree is on `main`:
+Release notes are generated from `origin/` refs, so start from a clean `main`
+checkout. This avoids leaking release-branch-only files (e.g. `PREVIEW_LABEL`)
+into the main-targeted PR. The skill's `--all` script fetches the `release/*`
+branches it needs itself, so this only has to put you on `main`:
 
 ```bash
 git fetch origin main --quiet
 git checkout -B main origin/main
 ```
 
-Use the **release-notes** skill (`.agents/skills/release-notes/SKILL.md`) to generate
-polished release notes for the branch determined above. Pass the branch name to the skill.
-
-The skill handles everything: running the script, reading the template, writing the
-polished file, and regenerating the TOC.
-
-## Step 3 — Create or update the pull request
-
-Use a branch name that includes both the version and the release status to avoid collisions
-between release-branch triggers and main-branch triggers:
+## Do the work
 
-- For versioned release branches (e.g. `release/4.147.0-preview.3`): use `dev/release-notes-{VERSION}-released`
-- For `main` (unreleased content): use `dev/release-notes-{VERSION}-unreleased`
+Use the **release-notes skill** to regenerate and polish, running it in `--all`
+mode (regenerates every branch idempotently and lists only changed files). Edit
+exactly the files it lists under "Files to polish". If it reports
+"(none — all files up to date)", **make no file edits** — leave any existing
+`bot/release-notes` PR untouched and exit.
 
-This ensures each workflow run updates the **same PR** for a given version and status
-instead of opening duplicates, and prevents a main push from force-pushing over a
-release-branch PR (or vice versa).
+## Automation
 
-The PR targets `main` — release notes always live on main for the docs site.
+The PR is created by the `create-pull-request` safe-output from whatever files
+you edited — you do **not** git commit or push manually. It targets `main` on the
+single consolidated branch `bot/release-notes`, so there is at most one open
+release-notes PR rather than one-per-version. If you made no edits, no PR is
+created or updated.
diff --git a/documentation/dev/writing-docs.md b/documentation/dev/writing-docs.md
index b901768aa59..7801d2d9d3c 100644
--- a/documentation/dev/writing-docs.md
+++ b/documentation/dev/writing-docs.md
@@ -141,3 +141,90 @@ For detailed XML documentation patterns and review criteria, see:
 | `docs-format-docs` | Cleans XML output, removes duplicates, syncs extension method docs, reports coverage |
 | `update-docs` | Runs `docs-api-diff` → `docs-update-frameworks` → `docs-format-docs` in sequence |
 
+## API changelogs
+
+The per-assembly API changelogs under `changelogs/{PackageId}/{version}/` are
+generated by `Mono.ApiTools.NuGetDiff` and kept up to date automatically by the
+[`api-diff`](../../.github/workflows/api-diff.yml) workflow (on push to `main`
+and `release/*`, on `v*` tags, and weekly). The workflow just runs one Cake
+target and opens a single rolling PR — there is no skill or shell wrapper.
+
+To regenerate the full committed set locally exactly the way CI does:
+
+```bash
+dotnet tool restore
+dotnet cake --target=docs-api-diff-past --nugetDiffPrerelease=true
+git diff changelogs/
+```
+
+`docs-api-diff-past` diffs **published** NuGet.org versions: every version is
+compared against its predecessor, with baselines and superseded-version skips
+driven by [`scripts/versions.json`](../../scripts/versions.json) (same config the
+release-notes script uses). Superseded versions still get their own changelog;
+they are only removed from the pool of *baselines*, so e.g. `4.148.0` walks past
+the abandoned `4.147.*` previews and lands on `3.119.4`.
+
+### Previewing an unreleased branch
+
+Previewing the API diff of a branch/tag that is **not yet on NuGet.org** is a
+manual, multi-step operation (it is intentionally not part of the automated
+workflow). It diffs CI packages for the ref against their NuGet.org baseline:
+
+1. Overlay the ref's nuget versions onto `scripts/VERSIONS.txt` so the download
+   and diff targets resolve the right package versions (skip this if previewing
+   `main`, which is already the working tree):
+
+   ```bash
+   git fetch --depth=1 origin release/4.150.0-preview.1
+   git show FETCH_HEAD:scripts/VERSIONS.txt | grep -E '[[:space:]]nuget[[:space:]]'
+   # replace the "# nuget versions" block in scripts/VERSIONS.txt with those lines
+   ```
+
+2. Download the CI packages for the ref and run the diff against the feed:
+
+   ```bash
+   dotnet cake --target=docs-download-output --gitBranch=release/4.150.0-preview.1
+   dotnet cake --target=docs-api-diff --gitBranch=release/4.150.0-preview.1 --nugetDiffPrerelease=true
+   ```
+
+   (Use `--gitSha=<40-char-sha>` instead of `--gitBranch` for a tag or commit.)
+
+The preview output lands in `output/api-diff/` (and `changelogs/`) for
+inspection — it is **not** meant to be committed.
+
+### Relationship to release notes
+
+The API changelogs (Cake) and the website release notes
+([`generate-release-notes.py`](../../.agents/skills/release-notes/scripts/generate-release-notes.py))
+are **separate systems** that deliberately share only one thing:
+[`scripts/versions.json`](../../scripts/versions.json). That file is the single
+source of truth for two decisions, and both systems honour it identically:
+
+- **Supersession** — only a version with an explicit `status: superseded` entry
+  is treated as superseded (Cake's `IsVersionSuperseded`, Python's
+  `resolve_superseded_by`). Neither side auto-detects it; to skip a version
+  everywhere, add it to `versions.json`.
+- **`compare_to` baselines** — when present, both sides diff against the same
+  baseline version (e.g. `4.148.0` → `3.119.4`).
+
+For any version *not* carrying a `compare_to` override, each system picks the
+default baseline (the previous version) on its own, and the two can differ
+slightly: the Python release-notes generator additionally skips any candidate
+baseline that has no stable git tag, whereas Cake walks purely by version order.
+This only matters for unlisted, preview-only versions; add a `compare_to` entry
+to `versions.json` to pin both systems to the same baseline.
+
+Where they intentionally differ is **granularity**, and this is by design — do
+not "fix" them into agreement:
+
+- **API changelogs** produce one diff *per published NuGet package*, including
+  preview-to-preview deltas (e.g. each `4.147.0-preview.*` against the one
+  before it). The audience is "what changed in this exact package".
+- **Release notes** produce one cumulative page *per release* (the highest
+  branch for each version), diffed against the previous stable/baseline. The
+  audience is "what's new since the last release".
+
+So a given version can show a finer-grained baseline in `changelogs/` than on
+its release-notes page, even though both agree on supersession and any explicit
+`compare_to` override.
+
diff --git a/scripts/infra/caching/repo-deps.json b/scripts/infra/caching/repo-deps.json
index 9da6458c8e6..a880d72ef04 100644
--- a/scripts/infra/caching/repo-deps.json
+++ b/scripts/infra/caching/repo-deps.json
@@ -100,7 +100,7 @@
           "include": ["scripts/infra/package/**"],
           "children": {
             "api_diff": {
-              "include": ["scripts/infra/docs/**", "changelogs/**"]
+              "include": ["scripts/infra/docs/**", "changelogs/**", "scripts/versions.json"]
             },
             "samples": {
               "include": ["samples/**", "scripts/infra/samples/**"]
diff --git a/scripts/infra/docs/docs.cake b/scripts/infra/docs/docs.cake
index 0f89c1793f5..8298b714a4d 100644
--- a/scripts/infra/docs/docs.cake
+++ b/scripts/infra/docs/docs.cake
@@ -23,225 +23,6 @@ DirectoryPath ROOT_PATH = MakeAbsolute(Directory("../../.."));
 #load "../shared/shared.cake"
 #load "../shared/download.cake"
 
-////////////////////////////////////////////////////////////////////////////////////////////////////
-// DOCS UTILITIES
-////////////////////////////////////////////////////////////////////////////////////////////////////
-
-void DecompressArchive(FilePath archive, DirectoryPath outputDir)
-{
-    using (var stream = System.IO.File.OpenRead(archive.FullPath))
-    using (var reader = ReaderFactory.Open(stream)) {
-        while(reader.MoveToNextEntry()) {
-            if (!reader.Entry.IsDirectory) {
-                reader.WriteEntryToDirectory(outputDir.FullPath, new ExtractionOptions {
-                    ExtractFullPath = true,
-                    Overwrite = true
-                });
-            }
-        }
-    }
-}
-
-IEnumerable<(DirectoryPath path, string platform)> GetPlatformDirectories(DirectoryPath rootDir)
-{
-    var platformDirs = GetDirectories($"{rootDir}/*");
-
-    // try find any cross-platform frameworks
-    foreach (var dir in platformDirs) {
-        var d = dir.GetDirectoryName().ToLower();
-        if (d.StartsWith("netstandard") || d.StartsWith("portable") || d.Equals("net6.0") || d.Equals("net7.0") || d.Equals("net8.0") || d.Equals("net9.0") || d.Equals("net10.0")) {
-            // we just want this single platform
-            yield return (dir, null);
-            yield break;
-        }
-    }
-
-    // there were no cross-platform libraries, so process each platform
-    foreach (var dir in platformDirs) {
-        var d = dir.GetDirectoryName().ToLower();
-        if (d.StartsWith("monoandroid") || (d.StartsWith("net") && d.Contains("-android")))
-            yield return (dir, "android");
-        else if (d.StartsWith("net4"))
-            yield return (dir, "net");
-        else if (d.StartsWith("uap"))
-            yield return (dir, "uwp");
-        else if (d.StartsWith("xamarinios") || d.StartsWith("xamarin.ios") || (d.StartsWith("net") && d.Contains("-ios")))
-            yield return (dir, "ios");
-        else if (d.StartsWith("xamarinmac") || d.StartsWith("xamarin.mac") || (d.StartsWith("net") && d.Contains("-macos")))
-            yield return (dir, "macos");
-        else if (d.StartsWith("xamarintvos") || d.StartsWith("xamarin.tvos") || (d.StartsWith("net") && d.Contains("-tvos")))
-            yield return (dir, "tvos");
-        else if (d.StartsWith("xamarinwatchos") || d.StartsWith("xamarin.watchos") || (d.StartsWith("net") && d.Contains("-watchos")))
-            yield return (dir, "watchos");
-        else if (d.StartsWith("tizen") || (d.StartsWith("net") && d.Contains("-tizen")))
-            yield return (dir, "tizen");
-        else if (d.StartsWith("net") && d.Contains("-windows"))
-            yield return (dir, "windows");
-        else if (d.StartsWith("net") && d.Contains("-maccatalyst"))
-            yield return (dir, "maccatalyst");
-        else if (d.StartsWith("netcoreapp"))
-            continue; // skip this one for now
-        else
-            throw new Exception($"Unknown platform '{d}' found at '{dir}'.");
-    }
-}
-
-async Task CreateNuGetDiffAsync()
-{
-    var comparer = new NuGetDiff();
-    comparer.PackageCache = PACKAGE_CACHE_PATH.FullPath;
-    comparer.IgnoreResolutionErrors = true;
-    
-    Verbose ($"Adding dependencies...");
-
-    await AddDep("OpenTK.GLControl", "NET20");
-    await AddDep("GtkSharp", "netstandard2.0");
-    await AddDep("GdkSharp", "netstandard2.0");
-    await AddDep("GLibSharp", "netstandard2.0");
-    await AddDep("AtkSharp", "netstandard2.0");
-    await AddDep("System.Memory", "netstandard2.0");
-    await AddDep("System.Runtime.CompilerServices.Unsafe", "netstandard2.1");
-    await AddDep("Microsoft.WindowsAppSDK", "net6.0-windows10.0.18362.0");
-    await AddDep("Microsoft.Maui.Graphics", "netstandard2.0");
-    await AddDep("Microsoft.Windows.SDK.NET.Ref", "");
-    await AddDep("Microsoft.Windows.SDK.Contracts", "netstandard2.0");
-    await AddDep("System.Runtime.WindowsRuntime", "netstandard2.0");
-    await AddDep("System.Runtime.WindowsRuntime.UI.Xaml", "netstandard2.0");
-    await AddDep("Microsoft.WindowsDesktop.App.Ref", "net6.0");
-    await AddDep("Microsoft.AspNetCore.Components", "net6.0");
-    await AddDep("OpenTK.GLWpfControl", "netcoreapp3.1");
-    await AddDep("Microsoft.Maui.Core", "net10.0");
-    await AddDep("Microsoft.Maui.Controls.Core", "net10.0");
-    await AddDep("Microsoft.iOS.Ref.net10.0_26.0", "net10.0");
-    await AddDep("Microsoft.MacCatalyst.Ref.net10.0_26.0", "net10.0");
-    await AddDep("Microsoft.tvOS.Ref.net10.0_26.0", "net10.0");
-    await AddDep("Microsoft.macOS.Ref.net10.0_26.0", "net10.0");
-    await AddDep("Samsung.Tizen.Ref", "net10.0");
-    await AddDep("GirCore.Gdk-4.0", "net10.0");
-    await AddDep("GirCore.Gtk-4.0", "net10.0");
-    await AddDep("GirCore.Cairo-1.0", "net10.0");
-    await AddDep("GirCore.FreeType2-2.0", "net10.0");
-    await AddDep("GirCore.GdkPixbuf-2.0", "net10.0");
-    await AddDep("GirCore.Gio-2.0", "net10.0");
-    await AddDep("GirCore.GLib-2.0", "net10.0");
-    await AddDep("GirCore.GObject-2.0", "net10.0");
-    await AddDep("GirCore.Graphene-1.0", "net10.0");
-    await AddDep("GirCore.Gsk-4.0", "net10.0");
-    await AddDep("GirCore.HarfBuzz-0.0", "net10.0");
-    await AddDep("GirCore.Pango-1.0", "net10.0");
-    await AddDep("GirCore.PangoCairo-1.0", "net10.0");
-    await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.iOS/v1.0");
-    await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.TVOS/v1.0");
-    await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.WatchOS/v1.0");
-    await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.Mac/v2.0");
-    await AddVsixDep("Xamarin.Android.Sdk", "$ReferenceAssemblies/Microsoft/Framework/MonoAndroid/v1.0");
-    await AddVsixDep("Xamarin.Android.Sdk", "$ReferenceAssemblies/Microsoft/Framework/MonoAndroid/v13.0");
-    await AddDep("Uno.UI", "netstandard2.0");
-    await AddDep("Xamarin.Forms", "netstandard2.0");
-    await AddDep("Xamarin.Forms.Platform.WPF", "net461");
-    await AddDep("Xamarin.Forms.Platform.GTK", "net461");
-
-    // some parts of SkiaSharp depend on other parts
-    foreach (var dir in GetDirectories($"{PACKAGE_CACHE_PATH}/skiasharp/*/lib/netstandard2.0"))
-        comparer.SearchPaths.Add(dir.FullPath);
-    foreach (var dir in GetDirectories($"{PACKAGE_CACHE_PATH}/harfbuzzsharp/*/lib/netstandard2.0"))
-        comparer.SearchPaths.Add(dir.FullPath);
-    foreach (var dir in GetDirectories($"{PACKAGE_CACHE_PATH}/harfbuzzsharp/*/lib/netstandard1.3"))
-        comparer.SearchPaths.Add(dir.FullPath);
-
-    Verbose("Added search paths:");
-    foreach (var path in comparer.SearchPaths) {
-        var found = GetFiles($"{path}/*.dll").Any() || GetFiles($"{path}/*.winmd").Any();
-        Verbose($"    {(found ? " " : "!")} {path}");
-    }
-
-    return comparer;
-
-    async Task AddVsixDep(string id, string localPath, string type = "url")
-    {
-        var url = GetVersion(id, type);
-        var fileName = System.IO.Path.GetFileName(new Uri(url).LocalPath);
-        Verbose ($"    Adding VSIX dependency {id} ({fileName})...");
-        var dest = System.IO.Path.Combine(PACKAGE_CACHE_PATH.FullPath, id.ToLower(), fileName);
-        if (!FileExists(dest)) {
-            EnsureDirectoryExists(System.IO.Path.GetDirectoryName(dest));
-            Verbose($"      Downloading {url} to {dest}");
-            DownloadFile(url, dest);
-        }
-        var extractDir = System.IO.Path.Combine(PACKAGE_CACHE_PATH.FullPath, id.ToLower(), System.IO.Path.GetFileNameWithoutExtension(fileName));
-        if (!DirectoryExists(extractDir)) {
-            Verbose($"      Extracting {dest} to {extractDir}");
-            EnsureDirectoryExists(extractDir);
-            DecompressArchive(dest, extractDir);
-        }
-        var searchPath = System.IO.Path.Combine(extractDir, localPath);
-        if (DirectoryExists(searchPath)) {
-            Verbose($"      Adding VSIX search path: {searchPath}");
-            comparer.SearchPaths.Add(searchPath);
-        } else {
-            Verbose($"      No VSIX search path found at: {searchPath}");
-        }
-    }
-        
-    async Task AddDep(string id, string platform, string type = "release")
-    {
-        var version = GetVersion(id, type);
-        Verbose ($"    Adding dependency {id} version {version}...");
-        var root = await comparer.ExtractCachedPackageAsync(id, version);
-        var libPath = System.IO.Path.Combine(root, "lib", platform);
-        var refPath = System.IO.Path.Combine(root, "ref", platform);
-        if (DirectoryExists(libPath)) {
-            Verbose ($"      lib path {libPath}");
-            comparer.SearchPaths.Add(libPath);
-        } else if (DirectoryExists(refPath)) {
-            Verbose ($"      ref path {libPath}");
-            comparer.SearchPaths.Add(refPath);
-        } else {
-            Verbose ($"      no lib or ref path");
-        }
-    }
-}
-
-void CopyChangelogs (DirectoryPath diffRoot, string id, string version)
-{
-    foreach (var (path, platform) in GetPlatformDirectories (diffRoot)) {
-        // first, make sure to create markdown files for unchanged assemblies
-        var xmlFiles = $"{path}/*.new.info.xml";
-        foreach (var file in GetFiles (xmlFiles)) {
-            var dll = file.GetFilenameWithoutExtension ().GetFilenameWithoutExtension ().GetFilenameWithoutExtension ();
-            var md = $"{path}/{dll}.diff.md";
-            if (!FileExists (md)) {
-                var n = Environment.NewLine;
-                var noChangesText = $"# API diff: {dll}{n}{n}## {dll}{n}{n}> No changes.{n}";
-                FileWriteText (md, noChangesText);
-            }
-        }
-
-        // now copy the markdown files to the changelogs
-        var mdFiles = $"{path}/*.*.md";
-        ReplaceTextInFiles (mdFiles, "

", "> "); - ReplaceTextInFiles (mdFiles, "

", Environment.NewLine); - ReplaceTextInFiles (mdFiles, "\r\r", "\r"); - foreach (var file in GetFiles (mdFiles)) { - var dllName = file.GetFilenameWithoutExtension ().GetFilenameWithoutExtension ().GetFilenameWithoutExtension (); - if (file.GetFilenameWithoutExtension ().GetExtension () == ".breaking") { - // skip over breaking changes without any breaking changes - if (!FindTextInFiles (file.FullPath, "###").Any ()) { - DeleteFile (file); - continue; - } - - dllName += ".breaking"; - } - var changelogPath = (FilePath)$"{ROOT_PATH}/changelogs/{id}/{version}/{dllName}.md"; - EnsureDirectoryExists (changelogPath.GetDirectory ()); - CopyFile (file, changelogPath); - var changelogOutputPath = (FilePath)$"{ROOT_PATH}/output/logs/changelogs/{id}/{version}/{dllName}.md"; - EnsureDirectoryExists (changelogOutputPath.GetDirectory ()); - CopyFile (file, changelogOutputPath); - } - } -} Task ("docs-download-output") .Does (async () => @@ -252,6 +33,7 @@ Task ("docs-download-output") await DownloadPackageAsync ("_nugetspreview", OUTPUT_NUGETS_PATH); }); + Task ("docs-api-diff") .Does (async () => { @@ -269,6 +51,12 @@ Task ("docs-api-diff") comparer.SaveAssemblyApiInfo = true; comparer.SaveAssemblyMarkdownDiff = true; + // Shared version-comparison config — same source of truth used by + // docs-api-diff-past. Here it lets us pick a sensible baseline for the + // unpublished local build instead of blindly diffing against the newest + // feed version (which could be a superseded preview). + var versionsConfig = LoadVersionsConfig (); + var filter = new NuGetVersions.Filter { IncludePrerelease = NUGET_DIFF_PRERELEASE }; @@ -295,8 +83,25 @@ Task ("docs-api-diff") continue; } - var latestVersion = (await NuGetVersions.GetLatestAsync (id, filter))?.ToNormalizedString (); - Debug ($"Version '{latestVersion}' is the latest version of '{id}'..."); + // Pick the baseline to diff the local build against: + // 1. An explicit compare_to override in versions.json wins. + // 2. Otherwise use the newest published version that is NOT superseded + // and is not the build's own version — this skips abandoned preview + // lines (e.g. 4.147) the same way docs-api-diff-past does. + var allVersions = await NuGetVersions.GetAllAsync (id, filter); + var latestVersion = FindCompareToBaseline (versionsConfig, version, allVersions); + if (latestVersion == null) { + foreach (var candidate in allVersions.OrderByDescending (v => v)) { + var normalized = candidate.ToNormalizedString (); + if (normalized == localNugetVersion) + continue; + if (IsVersionSuperseded (versionsConfig, normalized)) + continue; + latestVersion = normalized; + break; + } + } + Debug ($"Version '{latestVersion}' is the baseline for '{id}'..."); // pre-cache so we can have better logs if (!string.IsNullOrEmpty (latestVersion)) { @@ -308,16 +113,8 @@ Task ("docs-api-diff") Debug ($"Running a diff on '{latestVersion}' vs '{localNugetVersion}' of '{id}'..."); var diffRoot = $"{baseDir}/{id}"; using (var reader = new PackageArchiveReader ($"{OUTPUT_NUGETS_PATH}/{id}.{localNugetVersion}.nupkg")) { - // run the diff with just the breaking changes - comparer.MarkdownDiffFileExtension = ".breaking.md"; - comparer.IgnoreNonBreakingChanges = true; - await comparer.SaveCompleteDiffToDirectoryAsync (id, latestVersion, reader, diffRoot); - // run the diff on everything - comparer.MarkdownDiffFileExtension = null; - comparer.IgnoreNonBreakingChanges = false; - await comparer.SaveCompleteDiffToDirectoryAsync (id, latestVersion, reader, diffRoot); + await RunBreakingAndFullDiff (comparer, id, latestVersion, reader, version, diffRoot); } - CopyChangelogs (diffRoot, id, version); // copy pretty version foreach (var md in GetFiles ($"{diffRoot}/*/*.md")) { @@ -339,11 +136,37 @@ Task ("docs-api-diff-past") var baseDir = $"{ROOT_PATH}/output/api-diffs-past"; CleanDirectories (baseDir); + // Make the regenerated set authoritative: clear the per-package changelog + // directories first. docs-api-diff-past rebuilds the COMPLETE historical + // set for every tracked package, so any version/assembly/breaking file that + // should no longer exist — e.g. a stale *.breaking.md after a baseline + // change in versions.json, or a package removed from TRACKED_NUGETS — is + // pruned instead of left behind to drift. Top-level files such as README.md + // are preserved (only subdirectories are removed). + var changelogsDir = $"{ROOT_PATH}/changelogs"; + if (DirectoryExists (changelogsDir)) { + foreach (var dir in GetSubDirectories (changelogsDir)) + DeleteDirectory (dir, new DeleteDirectorySettings { Recursive = true, Force = true }); + } + + // Shared version-comparison config — see LoadVersionsConfig and the + // versions.json header. Drives both the supersession skips and the + // compare_to overrides below. + Information ("Loading versions.json..."); + var versionsConfig = LoadVersionsConfig (); + Information ($"Creating comparer..."); var comparer = await CreateNuGetDiffAsync (); comparer.SaveAssemblyApiInfo = true; comparer.SaveAssemblyMarkdownDiff = true; + // Include prerelease packages when --nugetDiffPrerelease=true. The 4.x line + // ships only as prereleases for now, so the workflow passes true to make sure + // those versions get changelogs. + var filter = new NuGetVersions.Filter { + IncludePrerelease = NUGET_DIFF_PRERELEASE + }; + foreach (var id in TRACKED_NUGETS.Keys) { // skip doc generation for NativeAssets as that has nothing but a native binary if (id.Contains ("NativeAssets")) @@ -351,11 +174,30 @@ Task ("docs-api-diff-past") Information ($"Comparing the assemblies in '{id}'..."); - var allVersions = await NuGetVersions.GetAllAsync (id); + var allVersions = await NuGetVersions.GetAllAsync (id, filter); for (var idx = 0; idx < allVersions.Length; idx++) { - // get the versions for the diff + // get the version we are generating a changelog FOR (every version, + // including superseded ones, gets its own changelog) var version = allVersions [idx].ToNormalizedString (); - var previous = idx == 0 ? null : allVersions [idx - 1].ToNormalizedString (); + + // Pick the baseline to diff against: + // 1. An explicit compare_to override in versions.json wins. + // 2. Otherwise walk back to the most recent NON-superseded version. + // Step 2 is what makes a skipped line transparent: when generating + // 4.148 we walk past all of 4.147.* (superseded) and land on 3.119.4. + // The same walk-back is used for superseded versions themselves, so + // e.g. each 4.147 preview diffs cumulatively against 3.119.4. + var previous = FindCompareToBaseline (versionsConfig, version, allVersions); + if (previous == null) { + for (var j = idx - 1; j >= 0; j--) { + var candidate = allVersions [j].ToNormalizedString (); + if (!IsVersionSuperseded (versionsConfig, candidate)) { + previous = candidate; + break; + } + } + } + Information ($"Comparing version '{previous}' vs '{version}' of '{id}'..."); // pre-cache so we can have better logs @@ -369,15 +211,7 @@ Task ("docs-api-diff-past") // generate the diff and copy to the changelogs Debug ($"Running a diff on '{previous}' vs '{version}' of '{id}'..."); var diffRoot = $"{baseDir}/{id}/{version}"; - // run the diff with just the breaking changes - comparer.MarkdownDiffFileExtension = ".breaking.md"; - comparer.IgnoreNonBreakingChanges = true; - await comparer.SaveCompleteDiffToDirectoryAsync (id, previous, version, diffRoot); - // run the diff on everything - comparer.MarkdownDiffFileExtension = null; - comparer.IgnoreNonBreakingChanges = false; - await comparer.SaveCompleteDiffToDirectoryAsync (id, previous, version, diffRoot); - CopyChangelogs (diffRoot, id, version); + await RunBreakingAndFullDiff (comparer, id, previous, version, diffRoot); Debug ($"Diff complete of version '{version}' of '{id}'."); } @@ -794,4 +628,318 @@ Task ("update-docs") Task ("Default") .IsDependentOn ("update-docs"); + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// DOCS UTILITIES +//////////////////////////////////////////////////////////////////////////////////////////////////// + +void DecompressArchive(FilePath archive, DirectoryPath outputDir) +{ + using (var stream = System.IO.File.OpenRead(archive.FullPath)) + using (var reader = ReaderFactory.Open(stream)) { + while(reader.MoveToNextEntry()) { + if (!reader.Entry.IsDirectory) { + reader.WriteEntryToDirectory(outputDir.FullPath, new ExtractionOptions { + ExtractFullPath = true, + Overwrite = true + }); + } + } + } +} + +IEnumerable<(DirectoryPath path, string platform)> GetPlatformDirectories(DirectoryPath rootDir) +{ + var platformDirs = GetDirectories($"{rootDir}/*"); + + // try find any cross-platform frameworks + foreach (var dir in platformDirs) { + var d = dir.GetDirectoryName().ToLower(); + if (d.StartsWith("netstandard") || d.StartsWith("portable") || d.Equals("net6.0") || d.Equals("net7.0") || d.Equals("net8.0") || d.Equals("net9.0") || d.Equals("net10.0")) { + // we just want this single platform + yield return (dir, null); + yield break; + } + } + + // there were no cross-platform libraries, so process each platform + foreach (var dir in platformDirs) { + var d = dir.GetDirectoryName().ToLower(); + if (d.StartsWith("monoandroid") || (d.StartsWith("net") && d.Contains("-android"))) + yield return (dir, "android"); + else if (d.StartsWith("net4")) + yield return (dir, "net"); + else if (d.StartsWith("uap")) + yield return (dir, "uwp"); + else if (d.StartsWith("xamarinios") || d.StartsWith("xamarin.ios") || (d.StartsWith("net") && d.Contains("-ios"))) + yield return (dir, "ios"); + else if (d.StartsWith("xamarinmac") || d.StartsWith("xamarin.mac") || (d.StartsWith("net") && d.Contains("-macos"))) + yield return (dir, "macos"); + else if (d.StartsWith("xamarintvos") || d.StartsWith("xamarin.tvos") || (d.StartsWith("net") && d.Contains("-tvos"))) + yield return (dir, "tvos"); + else if (d.StartsWith("xamarinwatchos") || d.StartsWith("xamarin.watchos") || (d.StartsWith("net") && d.Contains("-watchos"))) + yield return (dir, "watchos"); + else if (d.StartsWith("tizen") || (d.StartsWith("net") && d.Contains("-tizen"))) + yield return (dir, "tizen"); + else if (d.StartsWith("net") && d.Contains("-windows")) + yield return (dir, "windows"); + else if (d.StartsWith("net") && d.Contains("-maccatalyst")) + yield return (dir, "maccatalyst"); + else if (d.StartsWith("netcoreapp")) + continue; // skip this one for now + else + throw new Exception($"Unknown platform '{d}' found at '{dir}'."); + } +} + +async Task CreateNuGetDiffAsync() +{ + var comparer = new NuGetDiff(); + comparer.PackageCache = PACKAGE_CACHE_PATH.FullPath; + comparer.IgnoreResolutionErrors = true; + + Verbose ($"Adding dependencies..."); + + await AddDep("OpenTK.GLControl", "NET20"); + await AddDep("GtkSharp", "netstandard2.0"); + await AddDep("GdkSharp", "netstandard2.0"); + await AddDep("GLibSharp", "netstandard2.0"); + await AddDep("AtkSharp", "netstandard2.0"); + await AddDep("System.Memory", "netstandard2.0"); + await AddDep("System.Runtime.CompilerServices.Unsafe", "netstandard2.1"); + await AddDep("Microsoft.WindowsAppSDK", "net6.0-windows10.0.18362.0"); + await AddDep("Microsoft.Maui.Graphics", "netstandard2.0"); + await AddDep("Microsoft.Windows.SDK.NET.Ref", ""); + await AddDep("Microsoft.Windows.SDK.Contracts", "netstandard2.0"); + await AddDep("System.Runtime.WindowsRuntime", "netstandard2.0"); + await AddDep("System.Runtime.WindowsRuntime.UI.Xaml", "netstandard2.0"); + await AddDep("Microsoft.WindowsDesktop.App.Ref", "net6.0"); + await AddDep("Microsoft.AspNetCore.Components", "net6.0"); + await AddDep("OpenTK.GLWpfControl", "netcoreapp3.1"); + await AddDep("Microsoft.Maui.Core", "net10.0"); + await AddDep("Microsoft.Maui.Controls.Core", "net10.0"); + await AddDep("Microsoft.iOS.Ref.net10.0_26.0", "net10.0"); + await AddDep("Microsoft.MacCatalyst.Ref.net10.0_26.0", "net10.0"); + await AddDep("Microsoft.tvOS.Ref.net10.0_26.0", "net10.0"); + await AddDep("Microsoft.macOS.Ref.net10.0_26.0", "net10.0"); + await AddDep("Samsung.Tizen.Ref", "net10.0"); + await AddDep("GirCore.Gdk-4.0", "net10.0"); + await AddDep("GirCore.Gtk-4.0", "net10.0"); + await AddDep("GirCore.Cairo-1.0", "net10.0"); + await AddDep("GirCore.FreeType2-2.0", "net10.0"); + await AddDep("GirCore.GdkPixbuf-2.0", "net10.0"); + await AddDep("GirCore.Gio-2.0", "net10.0"); + await AddDep("GirCore.GLib-2.0", "net10.0"); + await AddDep("GirCore.GObject-2.0", "net10.0"); + await AddDep("GirCore.Graphene-1.0", "net10.0"); + await AddDep("GirCore.Gsk-4.0", "net10.0"); + await AddDep("GirCore.HarfBuzz-0.0", "net10.0"); + await AddDep("GirCore.Pango-1.0", "net10.0"); + await AddDep("GirCore.PangoCairo-1.0", "net10.0"); + await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.iOS/v1.0"); + await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.TVOS/v1.0"); + await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.WatchOS/v1.0"); + await AddVsixDep("Xamarin.VisualStudio.Apple.Sdk", "$ReferenceAssemblies/Microsoft/Framework/Xamarin.Mac/v2.0"); + await AddVsixDep("Xamarin.Android.Sdk", "$ReferenceAssemblies/Microsoft/Framework/MonoAndroid/v1.0"); + await AddVsixDep("Xamarin.Android.Sdk", "$ReferenceAssemblies/Microsoft/Framework/MonoAndroid/v13.0"); + await AddDep("Uno.UI", "netstandard2.0"); + await AddDep("Xamarin.Forms", "netstandard2.0"); + await AddDep("Xamarin.Forms.Platform.WPF", "net461"); + await AddDep("Xamarin.Forms.Platform.GTK", "net461"); + + // some parts of SkiaSharp depend on other parts + foreach (var dir in GetDirectories($"{PACKAGE_CACHE_PATH}/skiasharp/*/lib/netstandard2.0")) + comparer.SearchPaths.Add(dir.FullPath); + foreach (var dir in GetDirectories($"{PACKAGE_CACHE_PATH}/harfbuzzsharp/*/lib/netstandard2.0")) + comparer.SearchPaths.Add(dir.FullPath); + foreach (var dir in GetDirectories($"{PACKAGE_CACHE_PATH}/harfbuzzsharp/*/lib/netstandard1.3")) + comparer.SearchPaths.Add(dir.FullPath); + + Verbose("Added search paths:"); + foreach (var path in comparer.SearchPaths) { + var found = GetFiles($"{path}/*.dll").Any() || GetFiles($"{path}/*.winmd").Any(); + Verbose($" {(found ? " " : "!")} {path}"); + } + + return comparer; + + async Task AddVsixDep(string id, string localPath, string type = "url") + { + var url = GetVersion(id, type); + var fileName = System.IO.Path.GetFileName(new Uri(url).LocalPath); + Verbose ($" Adding VSIX dependency {id} ({fileName})..."); + var dest = System.IO.Path.Combine(PACKAGE_CACHE_PATH.FullPath, id.ToLower(), fileName); + if (!FileExists(dest)) { + EnsureDirectoryExists(System.IO.Path.GetDirectoryName(dest)); + Verbose($" Downloading {url} to {dest}"); + DownloadFile(url, dest); + } + var extractDir = System.IO.Path.Combine(PACKAGE_CACHE_PATH.FullPath, id.ToLower(), System.IO.Path.GetFileNameWithoutExtension(fileName)); + if (!DirectoryExists(extractDir)) { + Verbose($" Extracting {dest} to {extractDir}"); + EnsureDirectoryExists(extractDir); + DecompressArchive(dest, extractDir); + } + var searchPath = System.IO.Path.Combine(extractDir, localPath); + if (DirectoryExists(searchPath)) { + Verbose($" Adding VSIX search path: {searchPath}"); + comparer.SearchPaths.Add(searchPath); + } else { + Verbose($" No VSIX search path found at: {searchPath}"); + } + } + + async Task AddDep(string id, string platform, string type = "release") + { + var version = GetVersion(id, type); + Verbose ($" Adding dependency {id} version {version}..."); + var root = await comparer.ExtractCachedPackageAsync(id, version); + var libPath = System.IO.Path.Combine(root, "lib", platform); + var refPath = System.IO.Path.Combine(root, "ref", platform); + if (DirectoryExists(libPath)) { + Verbose ($" lib path {libPath}"); + comparer.SearchPaths.Add(libPath); + } else if (DirectoryExists(refPath)) { + Verbose ($" ref path {refPath}"); + comparer.SearchPaths.Add(refPath); + } else { + Verbose ($" no lib or ref path"); + } + } +} + +void CopyChangelogs (DirectoryPath diffRoot, string id, string version) +{ + foreach (var (path, platform) in GetPlatformDirectories (diffRoot)) { + // first, make sure to create markdown files for unchanged assemblies + var xmlFiles = $"{path}/*.new.info.xml"; + foreach (var file in GetFiles (xmlFiles)) { + var dll = file.GetFilenameWithoutExtension ().GetFilenameWithoutExtension ().GetFilenameWithoutExtension (); + var md = $"{path}/{dll}.diff.md"; + if (!FileExists (md)) { + var n = Environment.NewLine; + var noChangesText = $"# API diff: {dll}{n}{n}## {dll}{n}{n}> No changes.{n}"; + FileWriteText (md, noChangesText); + } + } + + // now copy the markdown files to the changelogs + var mdFiles = $"{path}/*.*.md"; + ReplaceTextInFiles (mdFiles, "

", "> "); + ReplaceTextInFiles (mdFiles, "

", Environment.NewLine); + ReplaceTextInFiles (mdFiles, "\r\r", "\r"); + foreach (var file in GetFiles (mdFiles)) { + var dllName = file.GetFilenameWithoutExtension ().GetFilenameWithoutExtension ().GetFilenameWithoutExtension (); + if (file.GetFilenameWithoutExtension ().GetExtension () == ".breaking") { + // skip over breaking changes without any breaking changes + if (!FindTextInFiles (file.FullPath, "###").Any ()) { + DeleteFile (file); + continue; + } + + dllName += ".breaking"; + } + var changelogPath = (FilePath)$"{ROOT_PATH}/changelogs/{id}/{version}/{dllName}.md"; + EnsureDirectoryExists (changelogPath.GetDirectory ()); + CopyFile (file, changelogPath); + var changelogOutputPath = (FilePath)$"{ROOT_PATH}/output/logs/changelogs/{id}/{version}/{dllName}.md"; + EnsureDirectoryExists (changelogOutputPath.GetDirectory ()); + CopyFile (file, changelogOutputPath); + } + } +} + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// SHARED API-DIFF HELPERS +// +// The two API-changelog targets — docs-api-diff ("current": diff the unpublished +// local CI build against the feed) and docs-api-diff-past ("historical": +// regenerate changelogs for every published version) — answer different +// questions and take a different NEW side (a local .nupkg vs a published +// version). But the actual diff mechanics and the version-comparison rules are +// identical, so they live here and both targets call them. This keeps the two +// targets thin and guarantees they treat supersession the same way. +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// Load the shared version-comparison config (scripts/versions.json). This is the +// single source of truth for how versions relate to each other. It is "override +// only": versions NOT listed fall back to the default behaviour of diffing +// against the immediately-preceding published version. Returns the "versions" +// array (empty when the file is absent). See versions.json for the schema. +JArray LoadVersionsConfig () +{ + var path = $"{ROOT_PATH}/scripts/versions.json"; + if (!FileExists (path)) + return new JArray (); + var doc = JObject.Parse (System.IO.File.ReadAllText (path)); + return doc ["versions"] as JArray ?? new JArray (); +} + +// A "superseded" version is one that was previewed but never shipped stable +// (e.g. 4.147 was abandoned in favour of 4.148). It still gets its OWN changelog +// generated — it is only excluded from acting as a *baseline* for other +// versions, so a later release diffs against the last real predecessor instead. +// Matched on major.minor.patch, so an entry for "4.147.0" covers every +// 4.147.0-preview.* (all previews of that exact patch), but not a different +// patch such as 4.147.1. +bool IsVersionSuperseded (JArray config, string normalizedVersion) +{ + var nv = new NuGetVersion (normalizedVersion); + var key = $"{nv.Major}.{nv.Minor}.{nv.Patch}"; + return config.Any (v => (string)v ["version"] == key && (string)v ["status"] == "superseded"); +} + +// Return the explicit "compare_to" baseline declared for a version in +// versions.json (e.g. 4.148 → 3.119.4, deliberately skipping 4.147), resolved to +// the newest actual package that matches that major.minor.patch. Returns null +// when no override exists, in which case the caller falls back to a walk-back. +string FindCompareToBaseline (JArray config, string normalizedVersion, NuGetVersion[] allVersions) +{ + var nv = new NuGetVersion (normalizedVersion); + var key = $"{nv.Major}.{nv.Minor}.{nv.Patch}"; + var entry = config.FirstOrDefault (v => (string)v ["version"] == key && v ["compare_to"] != null); + if (entry == null) + return null; + + var compareTo = (string)entry ["compare_to"]; + var candidates = allVersions + .Where (v => $"{v.Major}.{v.Minor}.{v.Patch}" == compareTo) + .OrderByDescending (v => v) + .ToArray (); + return candidates.Length > 0 ? candidates [0].ToNormalizedString () : null; +} + +// Run the standard two-pass diff (breaking-only, then full/non-breaking) and copy +// the resulting markdown into changelogs/{id}/{changelogVersion}. The two passes +// produce the {dll}.breaking.md and {dll}.md files respectively. +// +// Overload 1 — NEW side is an unpublished local .nupkg (the current CI build). +async Task RunBreakingAndFullDiff (NuGetDiff comparer, string id, string oldVersion, PackageArchiveReader newReader, string changelogVersion, string diffRoot) +{ + comparer.MarkdownDiffFileExtension = ".breaking.md"; + comparer.IgnoreNonBreakingChanges = true; + await comparer.SaveCompleteDiffToDirectoryAsync (id, oldVersion, newReader, diffRoot); + + comparer.MarkdownDiffFileExtension = null; + comparer.IgnoreNonBreakingChanges = false; + await comparer.SaveCompleteDiffToDirectoryAsync (id, oldVersion, newReader, diffRoot); + + CopyChangelogs (diffRoot, id, changelogVersion); +} + +// Overload 2 — NEW side is a published feed version (historical regeneration). +async Task RunBreakingAndFullDiff (NuGetDiff comparer, string id, string oldVersion, string newVersion, string diffRoot) +{ + comparer.MarkdownDiffFileExtension = ".breaking.md"; + comparer.IgnoreNonBreakingChanges = true; + await comparer.SaveCompleteDiffToDirectoryAsync (id, oldVersion, newVersion, diffRoot); + + comparer.MarkdownDiffFileExtension = null; + comparer.IgnoreNonBreakingChanges = false; + await comparer.SaveCompleteDiffToDirectoryAsync (id, oldVersion, newVersion, diffRoot); + + CopyChangelogs (diffRoot, id, newVersion); +} + RunTarget(TARGET); diff --git a/scripts/versions.json b/scripts/versions.json new file mode 100644 index 00000000000..b56d1d41af4 --- /dev/null +++ b/scripts/versions.json @@ -0,0 +1,54 @@ +{ + "$comment": "Shared version comparison config for API changelogs and release notes. Override-only: list a version only when it needs a non-default comparison baseline (compare_to) or is superseded (status + superseded_by). The default baseline is the previous version; the inverse 'supersedes' link is derived automatically from superseded_by, so it is not configured here.", + "versions": [ + { + "version": "3.0.0", + "status": "superseded", + "superseded_by": "3.116.0", + "$comment": "3.0.x series was replaced by the 3.116 rewrite" + }, + { + "version": "3.116.0", + "compare_to": "2.88.9", + "$comment": "First stable after the rewrite; compare against last 2.x stable" + }, + { + "version": "3.118.0", + "status": "superseded", + "superseded_by": "3.119.0", + "$comment": "3.118.x never went stable" + }, + { + "version": "3.119.0", + "compare_to": "3.116.1", + "$comment": "Skips over 3.118.x which never shipped stable" + }, + { + "version": "3.119.3", + "status": "superseded", + "superseded_by": "3.119.4", + "$comment": "Hotfix 3.119.4 replaced 3.119.3" + }, + { + "version": "3.119.4", + "compare_to": "3.119.2", + "$comment": "Supersedes 3.119.3; compare against last good patch before it" + }, + { + "version": "4.147.0", + "status": "superseded", + "superseded_by": "4.148.0", + "$comment": "4.147.x was preview-only, never went stable" + }, + { + "version": "4.148.0", + "compare_to": "3.119.4", + "$comment": "First 4.x RC; compare against last stable (3.119.4)" + }, + { + "version": "4.150.0", + "compare_to": "4.148.0", + "$comment": "Compares against previous 4.x release" + } + ] +}