diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md index c0b0c426974..ac48809f044 100644 --- a/.agents/skills/security-audit/SKILL.md +++ b/.agents/skills/security-audit/SKILL.md @@ -50,7 +50,7 @@ combined into a single unified report. 1. Search GitHub issues/PRs (all deps including Skia) 2. Query Chrome Releases blog (`query-chrome-releases.py`) — see [Chrome Releases](references/chrome-releases.md) -3. Query Chromium release schedule (`query-milestone-schedule.py`) — main vs Beta heads-up, see [Milestone Schedule](references/milestone-schedule.md) +3. Query Chromium release schedule (`query-milestone-schedule.py`) — main vs Beta heads-up + release-notes support-tier drift, see [Milestone Schedule](references/milestone-schedule.md) 4. Verify dependency versions from submodule/DEPS/headers (NOT cgmanifest.json) 5. Audit Skia core CVEs — see [Skia CVE Resolution](references/skia-cve-resolution.md) 6. Audit third-party dependency CVEs — see [Third-Party Deps](references/third-party-deps.md) @@ -149,6 +149,23 @@ upcoming schedule, and prints prioritized heads-up alerts: | 🟡 `watch` | A milestone past main branches within the window — start preparing. | | 🟢 `ok` | `main >= Beta` — front line current. | +#### Support-tier drift (release-notes `support` block) + +The same run also drift-checks the release-notes **support paths** in +`scripts/infra/docs/versions.json` (two hand-maintained lists, `stable` + `preview`) against +the live channels — detection only, the fix is a manual edit of that file (spec §3.5). The +verdict is in the `support` object of the JSON (`status`: `ok` | `warn` | `drift` | `absent`) +and printed under **"Support tiers (versions.json)"**: + +| `support.status` | Meaning | Audit action | +|------------------|---------|--------------| +| 🟢 `ok` | `stable` covers Chrome Stable (or Extended-stable during the promotion gap) and `preview` tracks Beta-or-newer. | None. | +| 🟡 `warn` | Plausible but worth noting (e.g. `stable` ahead of Chrome Stable, `preview` empty or trailing Beta). | Mention in the prose summary. | +| 🔴 `drift` | `stable` is behind/off-channel, or `preview` is not a real preview. | **Raise a finding** in `nextSteps`: edit `versions.json` `support` to the milestones we actually ship. | + +This is a docs-grouping check, not a CVE — but a `drift` verdict means the website is +mis-stating what is supported, so treat it as a finding. + #### Use the result - **Where we are vs what's coming** — `meta.status` + the `upcoming` table answer it directly. @@ -425,6 +442,7 @@ Then highlight the **top actionable items** from the report: - Any `needs_attention` or `undiscovered` findings - Chrome Releases CVEs above our current milestone (especially Skia/ANGLE) - 🔴/🟠 release heads-up (Step 3) — `main` behind the Beta milestone, or a milestone branching/going stable soon +- 🔴 support-tier drift (Step 3) — `versions.json` `support` block out of date with the live Chrome channels - Critical/High CG alerts #### Report quality rules diff --git a/.agents/skills/security-audit/references/milestone-schedule.md b/.agents/skills/security-audit/references/milestone-schedule.md index 7212901d92d..2f2820be144 100644 --- a/.agents/skills/security-audit/references/milestone-schedule.md +++ b/.agents/skills/security-audit/references/milestone-schedule.md @@ -2,6 +2,8 @@ [Chromium Dash](https://chromiumdash.appspot.com/) exposes two JSON endpoints we combine to answer one question: **are we keeping up with what's coming, and how much time do we have?** +The same data also drift-checks the release-notes **support** paths (see +[Support-Tier Drift](#support-tier-drift-release-notes-support-block) below). 1. **Channel releases** — which milestone *and exact Skia commit* is live in each channel (Extended stable, Stable, Beta, Dev, Canary). @@ -136,7 +138,17 @@ main-vs-Beta heads-up. Progress is logged to stderr, so stdout/`--output` stay c ], "headsup": [ { "level": "ok", "milestone": 150, "message": "main (m150) is at or ahead of Beta..." } - ] + ], + "support": { + "configured": true, + "stable_lines": ["4.148"], + "preview_lines": ["4.150"], + "chrome": { "extended": 148, "stable": 149, "beta": 150 }, + "status": "ok", // "ok" | "warn" | "drift" | "unknown" | "absent" + "alerts": [ + { "level": "ok", "message": "support.stable is on Extended-stable m148 while preview m150..." } + ] + } } ``` @@ -154,6 +166,38 @@ main-vs-Beta heads-up. Progress is logged to stderr, so stdout/`--output` stay c > The `critical` trigger uses the **live** Stable/Extended channel milestones from `fetch_releases` > (authoritative), falling back to the scheduled stable date only when channel data is missing. +## Support-Tier Drift (release-notes `support` block) + +The same run also drift-checks the release-notes **support paths** in +`scripts/infra/docs/versions.json` against the live channels. That block is two +hand-maintained lists of `major.minor` lines SkiaSharp actually ships — `stable` (the +supported stable line) and `preview` (the in-flight preview line) — and drives the +website's TOC/index grouping (see the release-notes spec §3.5). SkiaSharp ships NuGet +packages, not a multi-tier channel product, so this is **two lists, not a mirror of +Chrome's five channels**. + +This is **detection only**: the fix is always a manual edit of `versions.json` (we don't +ship every Chrome milestone, so the block must not be auto-derived). With `E ≤ S ≤ B` = +Chrome Extended/Stable/Beta milestones and `stable*`/`preview*` = our newest stable/preview +milestone: + +| Condition | Verdict | +|-----------|---------| +| `stable* = S` | 🟢 ok — current stable | +| `stable* = E` (E S` | 🟡 warn — ahead of Chrome stable, verify | +| `preview* = B` or `preview* > B` | 🟢 ok — tracks beta / ahead in Dev/Canary | +| `S < preview* < B` | 🟡 warn — trails beta, update soon | +| `preview* ≤ S` | 🔴 drift — not a real preview | +| `preview` empty | 🟡 warn — no preview documented | + +The verdict lands in the `support` object of the JSON (`status` = `ok`/`warn`/`drift`/ +`unknown`/`absent`) and prints under **"Support tiers (versions.json)"**. A `drift` verdict +is an audit finding: the website is mis-stating what is supported — fix it by editing the +`support` lists to the milestones we actually released. + ## How the Audit Uses This Run in **Step 3** (right after the Chrome Releases blog query): diff --git a/.agents/skills/security-audit/scripts/query-milestone-schedule.py b/.agents/skills/security-audit/scripts/query-milestone-schedule.py index 431903bfa59..bf50efac0b2 100755 --- a/.agents/skills/security-audit/scripts/query-milestone-schedule.py +++ b/.agents/skills/security-audit/scripts/query-milestone-schedule.py @@ -19,6 +19,9 @@ -> the live milestone + exact Skia commit (hashes.skia) per channel. Default output is main-centric and lean. The full 5-channel list is included as context. +It also drift-checks the release-notes "support" paths (scripts/infra/docs/versions.json) +against the live channels and reports OK/WARN/DRIFT — detection only; the fix is a manual +edit of that file (spec §3.5). Usage: python3 query-milestone-schedule.py # lean heads-up to stdout @@ -49,8 +52,8 @@ PLATFORM = "Windows" # the only platform that carries all five channels KEY_DATES = ["branch_point", "stable_date", "late_stable_date"] -LEVEL_ORDER = {"critical": 0, "urgent": 1, "unknown": 1, "watch": 2, "ok": 3, "info": 4} -LEVEL_ICON = {"critical": "🔴", "urgent": "🟠", "unknown": "❓", "watch": "🟡", "ok": "🟢", "info": "🔵"} +LEVEL_ORDER = {"critical": 0, "error": 0, "urgent": 1, "unknown": 1, "watch": 2, "warn": 2, "ok": 3, "info": 4} +LEVEL_ICON = {"critical": "🔴", "error": "🔴", "urgent": "🟠", "unknown": "❓", "watch": "🟡", "warn": "🟡", "ok": "🟢", "info": "🔵"} def log(msg): @@ -141,6 +144,56 @@ def read_main_versions(repo_root): return parse_versions(f.read()) +# --- Support tiers (versions.json) --- +# The release-notes TOC/index groups release lines by a manually-maintained +# "support" block in scripts/infra/docs/versions.json (two lists: stable + +# preview). We DON'T write that file here — we only read it and report drift +# against the live Chrome channels, so a maintainer can fix it by hand. + +SUPPORT_VERSIONS_REL = os.path.join("scripts", "infra", "docs", "versions.json") + + +def line_milestone(line): + """'4.148' -> 148 (the SkiaSharp minor IS the Chrome/Skia milestone).""" + try: + return int(str(line).split(".")[1]) + except (IndexError, ValueError): + return None + + +def load_support_block(repo_root): + """Read the support paths from versions.json: {"stable": [...], "preview": [...]}. + + Returns None when the file or block is absent (the grouping then degrades to + the legacy flat layout and there is nothing to drift-check). + """ + if not repo_root: + return None + path = os.path.join(repo_root, SUPPORT_VERSIONS_REL) + if not os.path.isfile(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (ValueError, OSError): + return None + block = data.get("support") + if not isinstance(block, dict): + return None + + def as_list(value): + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [str(v) for v in value] + return [] + + return {"stable": as_list(block.get("stable")), + "preview": as_list(block.get("preview"))} + + # --- date math --- def parse_date(value): @@ -213,6 +266,110 @@ def build_headsup(main_ms, beta_ms, stable_like_ms, upcoming, window, beta_stabl return alerts +def build_support_alerts(support, chrome_ms): + """Compare versions.json support paths to the live Chrome channels (spec §3.5). + + ``chrome_ms`` maps channel name -> milestone (from fetch_releases). Implements + the support-tier decision table: + + * stable line must sit on Chrome **Stable** ``S``, or on **Extended-stable** + ``E`` during the promotion gap (when a preview already reaches ``S``); + every stable entry must be ``S`` or ``E`` — nothing older or off-channel. + * preview line must cover Chrome **Beta** ``B`` (newer, in Dev/Canary, is OK; + behind ``B`` warns; at/below ``S`` is not a real preview). + + Detection only — the fix is always a manual edit of versions.json. Returns + ``(alerts, status)`` where status is ``ok`` | ``warn`` | ``drift`` | ``unknown``. + """ + E = chrome_ms.get("Extended") + S = chrome_ms.get("Stable") + B = chrome_ms.get("Beta") + alerts = [] + + if S is None or B is None: + alerts.append({"level": "unknown", "message": ( + "Could not read the Chrome Stable/Beta milestone — support-tier drift " + "not evaluated. Re-run before relying on this.")}) + return alerts, "unknown" + + e_label = "m%d" % E if E is not None else "m?" + stable_ms = sorted({m for m in (line_milestone(l) for l in support["stable"]) if m is not None}) + preview_ms = sorted({m for m in (line_milestone(l) for l in support["preview"]) if m is not None}) + allowed = {m for m in (E, S) if m is not None} + floor = E if E is not None else S + + # --- stable line --- + if not stable_ms: + alerts.append({"level": "error", "message": ( + "support.stable is empty — it must list the current Chrome Stable " + "milestone m%d (or Extended-stable %s during a promotion gap)." % (S, e_label))}) + else: + top = max(stable_ms) + ptop = max(preview_ms) if preview_ms else None + # Additional (older) supported lines must still be on a stable-class channel. + for m in stable_ms: + if m != top and m not in allowed: + alerts.append({"level": "error", "message": ( + "support.stable also lists m%d, which is neither the Chrome Stable " + "(m%d) nor Extended-stable (%s) milestone — supported lines must be " + "stable or extended-stable." % (m, S, e_label))}) + # Headline: where the newest supported stable sits vs the channels. + if top == S: + pass # current stable — ideal + elif E is not None and top == E and E < S: + if ptop is not None and ptop >= S: + alerts.append({"level": "ok", "message": ( + "support.stable is on Extended-stable m%d while preview m%d " + "(>= Chrome Stable m%d) is about to promote — the normal " + "promotion gap." % (top, ptop, S))}) + else: + alerts.append({"level": "error", "message": ( + "support.stable is on Extended-stable m%d but no preview reaches " + "Chrome Stable m%d — promote a stable or add a preview at m%d." % (top, S, S))}) + elif top > S: + alerts.append({"level": "warn", "message": ( + "support.stable newest line is m%d, ahead of Chrome Stable m%d — " + "unusual; verify the support paths." % (top, S))}) + elif top < floor: + alerts.append({"level": "error", "message": ( + "support.stable newest line is m%d, behind even Chrome Extended-stable " + "%s — SkiaSharp stable is out of date." % (top, e_label))}) + else: # floor < top < S — not a stable-class channel + alerts.append({"level": "error", "message": ( + "support.stable newest line is m%d, between Chrome Extended-stable %s " + "and Stable m%d and not a supported channel — use the Stable (m%d) or " + "Extended-stable milestone." % (top, e_label, S, S))}) + + # --- preview line --- + if not preview_ms: + alerts.append({"level": "warn", "message": ( + "support.preview is empty — no in-flight preview line is documented " + "(Chrome Beta is m%d)." % B)}) + else: + ptop = max(preview_ms) + for m in [m for m in preview_ms if m <= S]: + alerts.append({"level": "error", "message": ( + "support.preview lists m%d, which is not newer than Chrome Stable " + "m%d — a preview line must be ahead of stable." % (m, S))}) + if ptop > B: + alerts.append({"level": "ok", "message": ( + "support.preview m%d is ahead of Chrome Beta m%d (Dev/Canary) — " + "fine, previewing ahead." % (ptop, B))}) + elif ptop > S and ptop < B: + alerts.append({"level": "warn", "message": ( + "support.preview newest line is m%d, behind Chrome Beta m%d — update " + "the preview line soon." % (ptop, B))}) + + if not alerts: + alerts.append({"level": "ok", "message": ( + "support.stable/preview match the Chrome channels (Extended %s, Stable " + "m%d, Beta m%d)." % (e_label, S, B))}) + status = ("drift" if any(a["level"] == "error" for a in alerts) + else "warn" if any(a["level"] == "warn" for a in alerts) else "ok") + alerts.sort(key=lambda a: LEVEL_ORDER.get(a["level"], 9)) + return alerts, status + + def main(): ap = argparse.ArgumentParser(description="Chromium release heads-up for SkiaSharp Skia bumps (main vs Beta).") ap.add_argument("--ahead", type=int, default=4, @@ -279,6 +436,32 @@ def main(): status = "current" headsup = build_headsup(main_ms, beta_ms, stable_like_ms, upcoming, args.window, beta_stable_days) + # Support-tier drift: compare the manually-maintained versions.json support + # paths to the live channels (read-only; the fix is a manual edit, spec §3.5). + chrome_ms = {c["channel"]: c.get("milestone") for c in channels} + support_block = load_support_block(repo_root) + if support_block is None: + support_result = { + "configured": False, + "status": "absent", + "alerts": [{"level": "info", "message": ( + "No 'support' block in %s — support-tier drift not checked." % SUPPORT_VERSIONS_REL)}], + } + else: + support_alerts, support_status = build_support_alerts(support_block, chrome_ms) + support_result = { + "configured": True, + "stable_lines": support_block["stable"], + "preview_lines": support_block["preview"], + "chrome": { + "extended": chrome_ms.get("Extended"), + "stable": chrome_ms.get("Stable"), + "beta": chrome_ms.get("Beta"), + }, + "status": support_status, + "alerts": support_alerts, + } + result = { "meta": { "generated_at": now.isoformat(), @@ -297,6 +480,7 @@ def main(): "channels": channels, "upcoming": upcoming, "headsup": headsup, + "support": support_result, } if args.output: @@ -350,6 +534,19 @@ def print_summary(result): print(f" {LEVEL_ICON.get(a['level'], '•')} [{a['level'].upper()}] {a['message']}") print() + support = result.get("support") + if support: + status = support.get("status", "absent") + icon = {"ok": "🟢", "warn": "🟡", "drift": "🔴", "unknown": "❓", "absent": "🔵"}.get(status, "•") + print(f" Support tiers (versions.json): {icon} {status.upper()}") + if support.get("configured"): + ch = support.get("chrome", {}) + print(f" stable={support.get('stable_lines')} preview={support.get('preview_lines')}" + f" (Chrome Extended m{ch.get('extended')} / Stable m{ch.get('stable')} / Beta m{ch.get('beta')})") + for a in support.get("alerts", []): + print(f" {LEVEL_ICON.get(a['level'], '•')} [{a['level'].upper()}] {a['message']}") + print() + if __name__ == "__main__": sys.exit(main()) diff --git a/documentation/dev/release-notes-and-api-diffs.md b/documentation/dev/release-notes-and-api-diffs.md index b9952812bc3..36253f65083 100644 --- a/documentation/dev/release-notes-and-api-diffs.md +++ b/documentation/dev/release-notes-and-api-diffs.md @@ -114,6 +114,12 @@ Each engine looks up overrides only inside its own family's bucket; an absent or bucket means "no overrides — pure defaults" for that family. (HarfBuzz has no entries today; the empty bucket is its override surface for the day it needs one.) +Alongside the family buckets the file carries one **non-family** top-level key, +**`support`**, which declares the navigation support tiers consumed only by the +release-notes TOC/index (§3.5 "Support tiers"). It is read solely by the release-notes +engine — the API-diff engine ignores everything outside its own family bucket — so it +never affects comparison or emission. + The recognised fields: - **`compare_to: "X.Y.Z"`** — diff this line against an explicit baseline instead of @@ -523,6 +529,79 @@ reference material for a release, not separate browsing destinations. From a hub is one click to the line's diff index and one more to a specific assembly. `index.md` (at the `releases/` root) is the top-level list of release lines. +##### Support tiers (the `support` block) + +Both `TOC.yml` and `index.md` are organised by a **support tier** so the navigation +leads with what is actually supported. SkiaSharp ships NuGet packages on two supported +paths (it is *not* a multi-tier channel product), so the tiers are: + +- **Supported** — the `stable` line(s) and the `preview` line(s). These render as the + top-level `Version X.Y.x` nodes in the TOC and under the **Supported versions** heading + in `index.md` (each tagged `Stable` or `Preview`). In `index.md` they are *also* + summarised at the very top by a **Support overview** block — a short lifecycle legend + (stable / preview / out of support / obsolete) plus a "currently supported" table listing + each supported line and a link to its latest release — so the page opens with what to use + at a glance. The overview is emitted only when a `support` block is configured. +- **Out of support** — every other 3.x+ line. These fold under a single + **Out of Support Versions** TOC node and a collapsed `
` block in `index.md`. +- **Obsolete** — the 1.x and 2.x lines, folded under the **Obsolete Versions** TOC node + and a collapsed `
` block in `index.md`. + +The tier of a line is **not** inferred from git or NuGet — it is read from the +top-level **`support`** block in `versions.json` (a sibling of the `skiasharp` / +`harfbuzzsharp` family buckets, consumed only by the release-notes engine; the API-diff +engine ignores it). It is **two lists** of `major.minor` line cores (the SkiaSharp minor +*is* the Chrome/Skia milestone): + +```json +"support": { + "stable": ["4.148"], + "preview": ["4.150"] +} +``` + +- **`stable`** — the supported stable line(s): normally the current **Chrome Stable** + milestone, or the **Chrome Extended-stable** milestone during the *promotion gap* (when + a `preview` line is about to go stable). +- **`preview`** — the in-flight preview/RC line(s): normally the **Chrome Beta** milestone, + or newer when previewing ahead in Dev/Canary. + +Either field may be a single string or a list. An absent or empty block makes every 3.x+ +line render as top-level/supported (the legacy flat layout), so the feature is purely +additive. + +**Maintained by hand, on purpose (do not auto-sync).** The values *correspond to* Chrome's +channels, but the block is a **human-curated grouping for the docs site**, not a mirror of +the live channels — so it is edited by hand and must not be auto-derived from Chromium Dash. +The reason is that SkiaSharp does not ship every Chrome milestone: if we **skip a bump**, +blindly copying the live Chrome milestones in would point a tier at a milestone that has +**no SkiaSharp release line**, and the page would then show the actually-released lines as +out of support (worst case: "everything is unsupported"). A maintainer therefore sets each +list to milestones we actually released. Automating this is only safe once the Skia-update +pipeline is **fully automated and auto-merging every milestone** (so "skipped bump" can't +happen); until then, treat these two lists as a manual editorial decision. + +**Drift is detected, not auto-fixed.** The security audit's milestone heads-up +(`.agents/skills/security-audit/scripts/query-milestone-schedule.py`, Step 3) already +fetches the live Chrome channels, so it also compares this block to them and emits an +`ok` / `warn` / `drift` verdict in its `support` output section. With `E ≤ S ≤ B` = Chrome +Extended/Stable/Beta and `stable*`/`preview*` = our newest stable/preview milestone: + +| Condition | Verdict | +|-----------|---------| +| `stable* = S` | 🟢 ok — current stable | +| `stable* = E` (E S` | 🟡 warn — ahead of Chrome stable, verify | +| `preview* = B` or `preview* > B` | 🟢 ok — tracks beta / ahead in Dev/Canary | +| `S < preview* < B` | 🟡 warn — trails beta, update soon | +| `preview* ≤ S` | 🔴 drift — not a real preview | +| `preview` empty | 🟡 warn — no preview documented | + +A `drift` verdict is an audit **finding**; the fix is always a manual edit of this block +(never auto-written — see the manual-by-design note above). + ### 3.6 The co-release map sidecar (Cake → Python) `releases/co-release-map.json` is the **inter-engine contract** that carries the §1.5 @@ -912,3 +991,12 @@ page links straight to its API diffs. identically on every host. When a regeneration produces unexpected "New Type" churn, the cause is almost always a newly-missing dependency: add it as a real `AddDep`/`AddPackageDir`, never treat the churn as a real API change. + +10. **Support tiers are config-driven (§3.5).** The TOC/index support grouping comes + only from the `support` block in `versions.json` — never inferred from git tags or + NuGet. It is two hand-maintained lists (`stable` + `preview`) of milestone lines + SkiaSharp actually ships, edited on each release. The security audit's milestone + heads-up (Step 3) drift-checks them against the live Chrome channels and reports + `ok`/`warn`/`drift` — detection only; the fix is a manual edit, never auto-written. + An absent/empty block degrades to the legacy "every 3.x+ line is top-level" layout, + so the grouping stays purely additive. diff --git a/scripts/infra/docs/generate-release-notes.py b/scripts/infra/docs/generate-release-notes.py index ed44758a12b..4b8f681190f 100644 --- a/scripts/infra/docs/generate-release-notes.py +++ b/scripts/infra/docs/generate-release-notes.py @@ -193,6 +193,102 @@ def _versions_config_lookup(version, family="skiasharp"): return None +# Support-channel config — the top-level "support" block in versions.json, read +# ONLY by the release-notes TOC/index (the API-diff engine ignores it). Cached +# after first load. +_SUPPORT_CONFIG = None # type: Optional[dict] + + +def load_support_config(): + # type: () -> dict + """Load the SkiaSharp support config from versions.json (spec §3.5). + + SkiaSharp ships NuGet packages on two release paths (not a multi-tier channel + product), so the top-level ``support`` block is two lists of ``major.minor`` + line cores (the SkiaSharp minor IS the Chrome/Skia milestone): + + * ``stable`` — the supported stable line(s): the current Chrome Stable + milestone, or the Chrome Extended-stable milestone during the promotion + gap (a preview about to go stable). + * ``preview`` — the in-flight preview/RC line(s): the Chrome Beta milestone, + or newer when previewing ahead in Dev/Canary. + + Either field may be given as a single string or a list. Returns a normalized + dict carrying the raw lists plus a derived ``supported`` set (their union) and + a ``channels`` map (line -> "Stable"/"Preview" label). A missing/empty block + yields an empty ``supported`` set, so callers fall back to the legacy "every + 3.x+ line is top-level/supported" behavior. + """ + global _SUPPORT_CONFIG + if _SUPPORT_CONFIG is not None: + return _SUPPORT_CONFIG + + def _as_lines(value): + # type: (object) -> list[str] + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [str(v) for v in value] + raise ValueError( + "versions.json: 'support' list fields must be a string or array " + "(spec §3.5); got %s" % type(value).__name__) + + stable = [] # type: list[str] + preview = [] # type: list[str] + if VERSIONS_JSON_PATH.exists(): + with open(VERSIONS_JSON_PATH) as f: + data = json.load(f) + block = data.get("support", {}) or {} + if not isinstance(block, dict): + raise ValueError( + "versions.json: 'support' must be an object (spec §3.5); got %s" + % type(block).__name__) + stable = _as_lines(block.get("stable")) + preview = _as_lines(block.get("preview")) + supported = set() # type: set[str] + channels = {} # type: dict[str, str] + for line in stable: + supported.add(line) + channels.setdefault(line, "Stable") + for line in preview: + supported.add(line) + channels.setdefault(line, "Preview") + _SUPPORT_CONFIG = { + "stable": stable, + "preview": preview, + "supported": supported, + "channels": channels, + } + return _SUPPORT_CONFIG + + +def classify_support_tier(group, support=None): + # type: (str, Optional[dict]) -> str + """Classify a minor group ("3.119") into a TOC/index support tier (spec §3.5). + + Returns one of: + + * ``"supported"`` — a stable or preview line (spec §3.5), rendered + prominently at the top level. + * ``"obsolete"`` — a 1.x or 2.x line, folded into "Obsolete Versions". + * ``"unsupported"`` — every other 3.x+ line, folded into "Out of Support + Versions". + + With no ``support`` block configured (empty ``supported`` set) every 3.x+ line + is treated as supported, preserving the legacy flat layout. + """ + if support is None: + support = load_support_config() + if int(group.split(".")[0]) < 3: + return "obsolete" + supported = support.get("supported") or set() + if not supported: + return "supported" + return "supported" if group in supported else "unsupported" + + # Co-release map sidecar (spec §3.6), written by the Cake API-diff engine and # read here to emit the deterministic SkiaSharp-page -> HarfBuzz-folder link. It is # the ONLY thing that crosses from the API-diff engine into this engine @@ -1777,9 +1873,57 @@ def cleanup_stale_unreleased(): return removed +def _toc_folded_section(title, groups, stable_groups, unreleased_groups): + # type: (str, list[str], dict, dict) -> list[str] + """Render a collapsed parent TOC node nesting its minor groups (spec §3.5). + + Used for the "Out of Support Versions" and "Obsolete Versions" folds: each + minor group becomes a child node, a single-release minor collapsing to one + node while a multi-release minor nests its individual patch releases. This + mirrors the supported top-level layout one level deeper so the sidebar fold + stays tidy instead of degrading into a flat wall of patch links. Returns the + YAML lines (empty when ``groups`` is empty). + """ + out = [] # type: list[str] + if not groups: + return out + head_members = stable_groups.get(groups[0]) or unreleased_groups.get(groups[0]) + head = ("{}.md".format(head_members[0]) if groups[0] in stable_groups + else "{}-unreleased.md".format(head_members[0])) + out.append("- name: {}".format(title)) + out.append(" href: {}".format(head)) + out.append(" items:") + for g in groups: + stable = stable_groups.get(g, []) + unreleased = unreleased_groups.get(g, []) + entries = [(v, True) for v in unreleased] + [(v, False) for v in stable] + if not entries: + continue + entries.sort(key=lambda t: version_key(t[0]), reverse=True) + g_header = ("{}.md".format(stable[0]) if stable + else "{}-unreleased.md".format(unreleased[0])) + out.append(" - name: Version {}.x".format(g)) + out.append(" href: {}".format(g_header)) + if len(entries) > 1: + out.append(" items:") + for v, is_unrel in entries: + if is_unrel: + out.append(" - name: Version {} (Unreleased)".format(v)) + out.append(" href: {}-unreleased.md".format(v)) + else: + out.append(" - name: Version {}".format(v)) + out.append(" href: {}.md".format(v)) + return out + + def generate_toc(versions, next_versions, hb_versions=None, hb_next_versions=None): # type: (list[str], list[str], Optional[list[str]], Optional[list[str]]) -> str - """Generate TOC.yml grouped by major.minor, obsolete under one node. + """Generate TOC.yml grouped by major.minor and support tier (spec §3.5). + + SkiaSharp minor groups are split into three tiers by their support status + (``classify_support_tier``): supported lines (stable / preview) render at the + top level, while the remaining 3.x+ lines fold under "Out of Support Versions" + and 1.x/2.x lines fold under "Obsolete Versions". Unreleased pages are listed in their minor group even when no stable page of that exact version exists yet (e.g. 3.119.5-unreleased before 3.119.5 @@ -1797,18 +1941,23 @@ def generate_toc(versions, next_versions, hb_versions=None, hb_next_versions=Non for v in next_versions: unreleased_groups[minor_group(v)].append(v) - current = [] + support = load_support_config() + supported = [] + unsupported = [] obsolete = [] for g in sorted(set(stable_groups) | set(unreleased_groups), key=lambda x: version_key(x), reverse=True): - if int(g.split(".")[0]) < 3: + tier = classify_support_tier(g, support) + if tier == "obsolete": obsolete.append(g) + elif tier == "unsupported": + unsupported.append(g) else: - current.append(g) + supported.append(g) lines = ["- name: Overview", " href: index.md"] - for g in current: + for g in supported: stable = stable_groups.get(g, []) unreleased = unreleased_groups.get(g, []) header = "{}.md".format(stable[0]) if stable \ @@ -1826,22 +1975,10 @@ def generate_toc(versions, next_versions, hb_versions=None, hb_next_versions=Non lines.append(" - name: Version {}".format(v)) lines.append(" href: {}.md".format(v)) - if obsolete: - first = stable_groups.get(obsolete[0]) or unreleased_groups.get(obsolete[0]) - lines.append("- name: Obsolete Versions") - lines.append(" href: {}.md".format(first[0])) - lines.append(" items:") - for g in obsolete: - members = stable_groups.get(g, []) - if not members: - continue - lines.append(" - name: Version {}.x".format(g)) - lines.append(" href: {}.md".format(members[0])) - if len(members) > 1: - lines.append(" items:") - for v in members: - lines.append(" - name: Version {}".format(v)) - lines.append(" href: {}.md".format(v)) + lines.extend(_toc_folded_section( + "Out of Support Versions", unsupported, stable_groups, unreleased_groups)) + lines.extend(_toc_folded_section( + "Obsolete Versions", obsolete, stable_groups, unreleased_groups)) # HarfBuzz peer family — sibling node grouping HarfBuzz lines by minor # (spec §3.5), mirroring the SkiaSharp version groups so the node is a tidy @@ -1888,49 +2025,152 @@ def generate_toc(versions, next_versions, hb_versions=None, hb_next_versions=Non def generate_index(versions, next_versions, hb_versions=None, hb_next_versions=None): # type: (list[str], list[str], Optional[list[str]], Optional[list[str]]) -> str - """Generate index.md with version list grouped by major. + """Generate index.md grouped by support tier (spec §3.5). + + When a ``support`` block is configured the page opens with a "Support + overview" — a short lifecycle legend (stable / preview / out of support / + obsolete) and a table of the currently-supported lines and their latest + release — so a reader sees what to use at a glance. SkiaSharp lines are then + split by their support status (``classify_support_tier``): the supported + lines (stable / preview) are listed prominently, each tagged with its path, + while the remaining 3.x+ lines and the obsolete 1.x/2.x lines fold into + collapsed ``
`` blocks so the page leads with what is supported. Unreleased pages are listed even when no stable page of that exact version exists yet. ``hb_versions``/``hb_next_versions`` render a trailing "HarfBuzzSharp" section linking the peer-family hub pages (spec §3.5). """ - lines = [ - "# Release Notes", - "", - "Release notes for all SkiaSharp versions.", - "", - ] + support = load_support_config() + channels = support.get("channels", {}) entries = [(v, False) for v in versions] + [(v, True) for v in next_versions] - - major_groups = defaultdict(list) + minor_map = defaultdict(list) for v, is_unrel in entries: - major_groups[v.split(".")[0]].append((v, is_unrel)) - - for major in sorted(major_groups.keys(), key=int, reverse=True): - lines.extend(["### SkiaSharp {}.x".format(major), ""]) + minor_map[minor_group(v)].append((v, is_unrel)) + + supported_groups = [] + unsupported_groups = [] + obsolete_groups = [] + for g in sorted(minor_map.keys(), key=lambda x: version_key(x), reverse=True): + tier = classify_support_tier(g, support) + if tier == "obsolete": + obsolete_groups.append(g) + elif tier == "unsupported": + unsupported_groups.append(g) + else: + supported_groups.append(g) + + def render_group(g, with_label): + # type: (str, bool) -> list[str] + members = sorted(minor_map[g], key=lambda t: version_key(t[0]), reverse=True) + label = channels.get(g) + if with_label and label: + out = ["- **Version {}.x** — {}".format(g, label)] + else: + out = ["- **Version {}.x**".format(g)] + for v, is_unrel in members: + if is_unrel: + out.append(" - [Version {} (Unreleased)]({}-unreleased.md)".format(v, v)) + else: + out.append(" - [Version {}]({}.md)".format(v, v)) + return out + + def details(summary, body): + # type: (str, list[str]) -> list[str] + return ["
", "{}".format(summary), ""] \ + + body + ["", "
", ""] + + def latest_link(g): + # type: (str) -> str + """Markdown link to the newest page in line ``g`` for the overview table. + + Prefers the newest *released* page; falls back to the newest unreleased + page when a line has shipped no stable page yet (spec §3.5). + """ + members = sorted(minor_map[g], key=lambda t: version_key(t[0]), reverse=True) + released = [(v, u) for v, u in members if not u] + v, is_unrel = released[0] if released else members[0] + if is_unrel: + return "[{} (Unreleased)]({}-unreleased.md)".format(v, v) + return "[{}]({}.md)".format(v, v) + + # The support block drives the top "what is supported right now" overview. + # Without it (legacy/empty config) the page keeps the plain flat layout. + configured = bool(support.get("supported")) + + if configured: + intro = ( + "Release notes for SkiaSharp. SkiaSharp ships as NuGet packages whose " + "minor version is the Chrome/Skia milestone it builds on. Two release " + "lines are supported at a time — a **stable** line for production and a " + "**preview** line for the milestone currently being stabilized — " + "mirroring " + "[Chrome's release channels](https://developer.chrome.com/docs/web-platform/chrome-release-channels) " + "(stable / extended-stable and beta). Everything else stays published " + "for reference but is no longer serviced.") + else: + intro = "Release notes for all SkiaSharp versions." + + lines = ["# Release Notes", "", intro, ""] + + if configured: + lines.extend([ + "## Support overview", + "", + "- **Stable** — the line we recommend for production apps. Tracks " + "Chrome's Stable / Extended Stable channel.", + "- **Preview** — prerelease NuGets for the next milestone, so you can " + "test ahead of its stable release. Tracks Chrome's Beta channel.", + "- **Out of support** — older 3.x / 4.x lines, still listed below for " + "reference but no longer serviced.", + "- **Obsolete** — SkiaSharp 1.x and 2.x, no longer maintained.", + "", + ]) + # Currently-supported table: Stable rows first, then Preview; newest line + # first within each (supported_groups is already version-descending and the + # sort below is stable). + channel_order = {"Stable": 0, "Preview": 1} + table_groups = sorted( + supported_groups, + key=lambda g: channel_order.get(channels.get(g), 9)) + lines.append("| Path | Version line | Latest release |") + lines.append("|------|--------------|----------------|") + for g in table_groups: + lines.append("| {} | {}.x | {} |".format( + channels.get(g, "Supported"), g, latest_link(g))) + lines.append("") - minor_groups_map = defaultdict(list) - for v, is_unrel in major_groups[major]: - minor_groups_map[minor_group(v)].append((v, is_unrel)) + if supported_groups: + lines.extend(["## Supported versions", ""]) + for g in supported_groups: + lines.extend(render_group(g, with_label=True)) + lines.append("") - for g in sorted(minor_groups_map.keys(), - key=lambda x: version_key(x), reverse=True): - members = sorted(minor_groups_map[g], - key=lambda t: version_key(t[0]), reverse=True) - lines.append("- **Version {}.x**".format(g)) - for v, is_unrel in members: - if is_unrel: - lines.append(" - [Version {} (Unreleased)]({}-unreleased.md)".format(v, v)) - else: - lines.append(" - [Version {}]({}.md)".format(v, v)) + if unsupported_groups: + lines.extend(["## Out of support", ""]) + lines.append( + "These SkiaSharp 3.x and 4.x lines are no longer supported. They " + "remain available for reference.") + lines.append("") + body = [] # type: list[str] + for g in unsupported_groups: + body.extend(render_group(g, with_label=False)) + lines.extend(details("Show out-of-support releases", body)) + + if obsolete_groups: + lines.extend(["## Obsolete versions", ""]) + lines.append("SkiaSharp 1.x and 2.x are obsolete and no longer maintained.") lines.append("") + body = [] + for g in obsolete_groups: + body.extend(render_group(g, with_label=False)) + lines.extend(details("Show obsolete releases", body)) # HarfBuzz peer family (spec §3.5) — its own section, grouped by HB minor. hb_versions = hb_versions or [] hb_next_versions = hb_next_versions or [] if hb_versions or hb_next_versions: - lines.extend(["### HarfBuzzSharp", ""]) + lines.extend(["## HarfBuzzSharp", ""]) hb_entries = ([(v, False) for v in hb_versions] + [(v, True) for v in hb_next_versions]) hb_minor_map = defaultdict(list) diff --git a/scripts/infra/docs/versions.json b/scripts/infra/docs/versions.json index 24cbe9e9e6d..e5897f40a80 100644 --- a/scripts/infra/docs/versions.json +++ b/scripts/infra/docs/versions.json @@ -1,5 +1,14 @@ { - "$comment": "Shared version-comparison overrides for API diffs AND release notes. See documentation/dev/release-notes-and-api-diffs.md (the behavior spec). OVERRIDE-ONLY: list a line only when it needs a non-default comparison baseline (compare_to) or is superseded (status + superseded_by), or to force emission of a never-stable line. The default baseline is the previous emitted line; the inverse 'supersedes' back-link is DERIVED from superseded_by and must not be set here. Top level is keyed by FAMILY (spec 1.5): 'skiasharp' for SkiaSharp-versioned packages and 'harfbuzzsharp' for HarfBuzzSharp-versioned packages. Each family is then keyed by line core (X.Y.Z).", + "$comment": "Shared version-comparison overrides for API diffs AND release notes. See documentation/dev/release-notes-and-api-diffs.md (the behavior spec). OVERRIDE-ONLY: list a line only when it needs a non-default comparison baseline (compare_to) or is superseded (status + superseded_by), or to force emission of a never-stable line. The default baseline is the previous emitted line; the inverse 'supersedes' back-link is DERIVED from superseded_by and must not be set here. Top level is keyed by FAMILY (spec 1.5): 'skiasharp' for SkiaSharp-versioned packages and 'harfbuzzsharp' for HarfBuzzSharp-versioned packages. Each family is then keyed by line core (X.Y.Z). The separate top-level 'support' block (read only by the release-notes engine) declares the SkiaSharp supported release paths (stable + preview) that drive the TOC/index grouping.", + "support": { + "$comment": "SkiaSharp's SUPPORTED release paths, used only by the release-notes TOC/index grouping. SkiaSharp ships NuGet packages on two paths (it is NOT a multi-tier channel product), so this is two lists of 'major.minor' lines (the SkiaSharp MINOR number IS the Chrome/Skia milestone). 'stable' = the supported stable line(s): normally the current Chrome Stable milestone, or the Chrome Extended-stable milestone during the promotion gap (when a preview is about to go stable). 'preview' = the in-flight preview/RC line(s): normally the Chrome Beta milestone, or newer when we are previewing ahead in Dev/Canary. Every other 3.x+ line is OUT OF SUPPORT; 1.x/2.x lines are OBSOLETE. MAINTAINED BY HAND on each release (we do not ship every Chrome milestone, so this must not be auto-derived from Chromium Dash). The security audit's heads-up check (.agents/skills/security-audit/scripts/query-milestone-schedule.py) compares these lists to the live Chrome channels and reports drift; the fix is always a manual edit here. Empty/absent block => every 3.x+ line is treated as supported (legacy behavior).", + "stable": [ + "4.148" + ], + "preview": [ + "4.150" + ] + }, "skiasharp": { "3.0.0": { "status": "superseded",