Back to Skiasharp

Release notes & API diffs — behavior spec

documentation/dev/release-notes-and-api-diffs.md

4.150.058.0 KB
Original Source

Release notes & API diffs — behavior spec

READ THIS FIRST before editing any generator. This document is the specification; the Python script, the Cake script, and the workflow are just its implementation. When behavior must change, change this spec first, then make the code match it. Do not "patch" the code into a new behavior and leave this doc stale — that is how these generators accreted contradictory special-cases in the past.

The document is ordered deliberately: the rules (§1), then the layout and ownership (§2–§3), then the per-engine implementation (§4–§5). Read §1–§3 before touching any code. Every concrete filesystem path is defined once, in §3; the engine sections reference it and must never restate a divergent path.

SkiaSharp generates two version-indexed artifacts from one shared versioning model (§1):

  • Release notes — human-readable "what's new" pages, generated by generate-release-notes.py (Python) from git history, then AI-polished for prose.
  • API diffs — machine-generated public-API diffs, generated by api-diff.cake (Cake) from the published NuGet feed; no AI.

Both live in the docfx site under documentation/docfx/releases/ (§3) and are produced by one self-contained skill (§2). A full feature comparison is in §6.

Looking for the big picture instead of the rules? This document is the deep behavior spec. For a one-screen map of the whole documentation system — all four artifacts, the engines, the skills, and the cross-repo CI — start at docs-overview.md.

Both are agent/CI tooling, not human-facing CLIs. They take no "fancy" toggles. They always regenerate the complete set and rely on being idempotent (unchanged outputs are skipped or rewritten identically). If you find yourself adding a flag "for convenience", stop — the correct behavior is to always do the full, correct thing.


Contents

  • §1 — The shared versioning model (the rules)
    • §1.1 One artifact per release line, keyed by the version core
    • §1.2 versions.json — the only override surface
    • §1.3 Default comparison: the previous emitted line
    • §1.4 Which lines get an artifact (emission)
    • §1.5 Two parallel version families (SkiaSharp & HarfBuzzSharp)
  • §2 — Skill layout & orchestration
    • §2.1 Engines live in scripts/infra/docs/; the skill just calls them
    • §2.2 Two phases: Prepare → Polish
    • §2.3 One workflow, one PR
  • §3 — Filesystem layout & naming (canonical)
    • §3.1 The releases/ tree at a glance
    • §3.2 Human pages (release-notes engine owns)
    • §3.3 API-diff folders (API-diff engine owns)
    • §3.4 The HarfBuzz tree shape
    • §3.5 Ownership — who writes and clears what
    • §3.6 The co-release map sidecar (Cake → Python)
  • §4 — Release-notes engine (generate-release-notes.py)
    • §4.1 Inputs & outputs
    • §4.2 Released vs unreleased — two coexisting pages
    • §4.3 Per-preview PR buckets
    • §4.4 Division of responsibility — the script structures, the AI only polishes
    • §4.5 The HarfBuzz family pages
    • §4.6 How it runs
  • §5 — API-diff engine (api-diff.cake)
    • §5.1 Inputs & outputs
    • §5.2 Behavior
    • §5.3 The "current" CI variant
    • §5.4 How it runs
  • §6 — Differences at a glance
  • §7 — Editing rules
    • §7.1 Editing this spec
    • §7.2 Behavioral invariants the code must uphold

1. The shared versioning model (the rules)

This is the hard-won core. Both engines implement exactly these rules; they differ only in the content they diff and the file layout they emit (§3).

1.1 One artifact per release line, keyed by the version core

A line is a version with its prerelease label stripped: 4.148.0-rc.1.24.148.0. Every preview/rc of a line collapses into that line's single artifact, named by the core (4.148.0). The artifact is a rollup of everything in the line. (The 4th segment of a genuine 4-part stable like 1.49.2.1 is preserved; only the prerelease suffix is stripped.)

Throughout this doc, the path placeholder <line> means exactly this version core.

1.2 versions.json — the only override surface

scripts/infra/docs/versions.json is the single place that deviates from the defaults. It has two distinct roles, and a version may use either or both:

  • Emission opt-in — listing a line makes it emit (§1.4), even if it never shipped stable.
  • Comparison/status override — changing how a line is diffed or marked.

File shape — one bucket per family (§1.5). Because SkiaSharp and HarfBuzz are independent families with possibly-colliding version cores, the file is keyed by family at the top level, then by line within each family:

json
{
  "skiasharp":      { "4.147.0": { "status": "superseded", "superseded_by": "4.148.0" },
                      "4.148.0": { "compare_to": "3.119.4" } },
  "harfbuzzsharp":  { }
}

Each engine looks up overrides only inside its own family's bucket; an absent or empty 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 the §1.3 default. compare_to always wins when present; the §1.3 skip rule applies only in its absence. (It may even point at a superseded line if that is deliberately wanted.)
  • status: "superseded"this field is what excludes a line from being used as a baseline (§1.3) within its own family (§1.5). A superseded line shipped previews but was abandoned and never went stable; it is not deleted, and it still gets its own artifact, but no other line in that family will diff against it.
  • superseded_by: "X.Y.Z" — the forward link used only to render the "superseded by" banner on the superseded page and the inverse supersedes back-link on the successor. It does not by itself affect baseline selection; pair it with status: "superseded" to actually exclude the line. Chains are single-hop: each line points only at its immediate successor.

supersedes is a derived field — never written in the file. The renderer computes it as the inverse of another line's superseded_by (§1.2) and §4.6 may key on it; do not add it to versions.json by hand.

There is intentionally no heuristic that infers any of this from git/NuGet. If a release needs special handling, it gets an entry here. Nothing else.

1.3 Default comparison: the previous emitted line

With no compare_to override, a line is diffed against the most recent line before it that was itself emitted (§1.4), skipping any status: "superseded" line (§1.2). Superseded/abandoned lines are therefore transparent: the next emitted line diffs past them and rolls their work up. Each engine resolves the baseline this way within its own version family (§1.5). If there is no prior emitted line at all (the first line a family ever emits), the baseline is empty: the API diff is the full public surface and the release notes list every PR in the range.

Worked examples (today's SkiaSharp versions):

  • 4.148.0 → the previous emitted line is the superseded 4.147.0, which is skipped, so the baseline is the last stable 3.119.4; 4.148 thus rolls up all of the 4.147 work. (versions.json also records compare_to: "3.119.4" for it — an explicit belt-and-braces that produces the same result the skip rule would.)
  • 4.150.0 → the previous emitted line is 4.148.0 (a preview-only line ahead of the latest stable still counts as emitted, §1.4), so the baseline is 4.148.0.

There is no "previous stable only" rule and no other heuristic.

1.4 Which lines get an artifact (emission)

A line is emitted (gets its own artifact in both systems) when any of the following holds — and not otherwise:

  1. It shipped a stable — the permanent record of a released line.
  2. It is listed in versions.json — an intentional line the team tracks (e.g. a status: "superseded" line like 4.147.0, or any line carrying a compare_to).
  3. It is an in-flight line ahead of the latest stable — the active development line(s) showing what is coming next (e.g. 4.148, 4.150).

Emission is governed solely by these three signals, never by the incidental existence of a release/<ver> branch: a stale or abandoned preview branch that is not listed in versions.json and is not ahead of the latest stable produces no artifact. Each family (§1.5) reads the three signals from its own evidence, and both engines share the resulting line set for that family:

  • SkiaSharp reads them from its own git: shipped-stable v* tags, the versions.json skiasharp bucket, and the latest-stable boundary.
  • HarfBuzz has no tags of its own, so the equivalent evidence is the co-release map (§3.6): the set of distinct hb_line values it records is the HarfBuzz emission set (each ships inside an emitted SkiaSharp line), together with the harfbuzzsharp versions.json bucket. An in-flight HarfBuzz line — one an in-flight SkiaSharp line newly declares but that has not published yet — counts as "ahead of the latest stable" exactly like rule 3; its version is read from source (§5.1) and it emits an in-flight page (§4.5).

Within each family the release-notes page set and the API-diff line set therefore cover exactly the same lines.

1.5 Two parallel version families (SkiaSharp & HarfBuzzSharp)

SkiaSharp and HarfBuzzSharp ship together (HarfBuzz never releases on its own) but carry different version numbers (SkiaSharp 3.119.0, HarfBuzz 8.3.0). Rather than fold one into the other, each family keeps its own identity and applies the §1.1–§1.4 rules independently:

Family = the package's own version scheme

Every SkiaSharp.* package belongs to the SkiaSharp family — SkiaSharp, SkiaSharp.Views.*, SkiaSharp.Skottie, …, and crucially SkiaSharp.HarfBuzz (the managed shaper binding, which carries the SkiaSharp version, not a HarfBuzz version). The HarfBuzz family is only the HarfBuzzSharp-versioned packages: HarfBuzzSharp (the native binding) and HarfBuzzSharp.NativeAssets.*.

Both families are first-class and identical in structure

Each applies §1.1–§1.4 entirely within its own version scheme: the same emission rules (§1.4), the same previous-emitted-line baselines (§1.3) and versions.json bucket (§1.2), the same human release-notes pages (§4) and API diffs (§5), and the same released/unreleased split, preview buckets, supersession, and AI polish. Neither is folded into or subordinate to the other, and there are no cross-family baselines. SkiaSharp lives at the releases/ root and HarfBuzz under releases/harfbuzzsharp/ (§3) — identical shapes, one level deeper.

The one difference: HarfBuzz is processed through SkiaSharp's branches and tags

HarfBuzz never releases on its own — every HarfBuzz version ships inside a SkiaSharp release (usually via a Bump HarfBuzz PR), so it has no release/* branches or v* tags of its own. So everything the engines otherwise read from a family's own git refs — commit ranges, preview-milestone boundaries, release dates — the HarfBuzz family takes from the SkiaSharp refs of its co-shipping release (via the co-release map below), then filters the commits to HarfBuzz-owned files so the page shows HarfBuzz's own changes instead of SkiaSharp's. That commit filter is the only content difference; the engine mechanics are in §4.5. Specifically:

  • HarfBuzz-owned files are the HarfBuzz packages, native build, and tests — never any SkiaSharp.* source (SkiaSharp.HarfBuzz is SkiaSharp-versioned and stays on the SkiaSharp page). §4.5 holds the canonical path list.
  • A PR that touches both families appears on both pages — that duplication is intended.
  • Every HarfBuzz line gets a page (a published line beside its API-diff folder, §3.4; an in-flight line without one yet, §4.5); when a line's filtered commit set is empty the generator writes a deterministic No changes page (§4.5), so the page set still equals the line set.

The co-release map is the bridge

The bridge is deterministic and script-owned. The exact HarfBuzz version that ships with SkiaSharp line L is read from SkiaSharp.HarfBuzz@L's HarfBuzzSharp package dependency (e.g. SkiaSharp.HarfBuzz 2.88.0 → HarfBuzzSharp 2.8.2) — from the published package for an emitted SkiaSharp line, or from the working tree for an in-flight line not yet on the feed (§5.1) — kept at full §1.1 line granularity so it names a real HarfBuzz line/folder (never truncated to Major.Minor.Patch). The Cake engine — which already reads the packages — records this L ↔ hb mapping in the co-release map sidecar (§3.6); the Python engine consumes it both ways (§4.5): to give each HarfBuzz line its SkiaSharp git range, and to cross-link the SkiaSharp and HarfBuzz hub pages (§4.4). When a single HarfBuzz line ships across several SkiaSharp lines, the earliest (introducing) one — whose Bump HarfBuzz PR created the line — is its canonical co-ship release: the one its banner and back-link name (§4.4/§4.5). A HarfBuzz dependency of a published SkiaSharp line always resolves to a released HarfBuzz folder; an in-flight line may name an as-yet-unpublished HarfBuzz line, which emits an in-flight page (§4.5) rather than a folder. The mapping is never computed by the AI.


2. Skill layout & orchestration

2.1 Engines live in scripts/infra/docs/; the skill just calls them

All the doc-generation engines and their per-path runner scripts live together under scripts/infra/docs/, so local runs, CI, and the docs Docker image all share one copy and nothing can drift:

scripts/infra/docs/
  api-diff.cake                API-diff engine (§5)
  generate-release-notes.py    release-notes engine (§4)
  api-diff-tools.cake          shared NuGet-diff comparer + layout helpers (§5),
                               #loaded by api-diff.cake AND docs.cake
  docs.cake                    mdoc-based docs/ XML generators (a different concern)
  generate-api-diffs.sh       Path 1 runner: cake docs-api-diff-past
  generate-release-notes.sh    Path 2 runner: python generate-release-notes.py
  generate-api-docs.sh         Path 3 runner: cake update-docs (mdoc under mono)
  versions.json                supersession + baseline config (§1), used by both engines
  pr-authors.json              PR-author cache for the release-notes engine (§4)
  docker/                      reproducible image + run.sh wrapper for every path

The release-notes skill stays thin: it owns only the AI polish instructions and a single orchestrator that calls the runner scripts in order, so the skill never has to know the individual commands:

.agents/skills/release-notes/
  SKILL.md                       the AI's polish-only instructions (§4.4)
  scripts/
    generate.sh                  wrapper: runs Path 1 then Path 2 in order, verbose,
                                 and writes the Files-to-polish list to a file (§2.2)

generate.sh owns no commands of its own — it delegates to scripts/infra/docs/generate-api-diffs.sh then …/generate-release-notes.sh — so the skill, the update-release-notes workflow, the Docker wrapper, and a human running it by hand all execute the exact same code.

The general-purpose Cake machinery (shared.cake, download.cake) stays under scripts/infra/shared/ and is #loaded by the engines. api-diff-tools.cake (the NuGet-diff comparer factory, the breaking/full-diff runner, and versions.json loading) is used by only the two doc engines (api-diff.cake and the mdoc generators in docs.cake), so it sits next to them in scripts/infra/docs/ rather than under shared/.

2.2 Two phases: Prepare → Polish

A full regeneration always runs in two phases — the deterministic Prepare phase first, the AI Polish phase last:

Prepare. One wrapper script, scripts/generate.sh, runs the two deterministic generators in a fixed order (forwarding any scope arguments to the Python generator). The skill and the workflow call that single script rather than the two generators individually, so the run order lives in one place:

  • Cake (api-diff.cake) regenerates the complete API-diff tree under releases/ (§3.3/§3.4) from the published feed, and writes the §1.5 co-release map sidecar (§3.6). Deterministic, no AI.
  • Python (generate-release-notes.py) regenerates the human pages' raw-data blocks (the structured in-page region the AI polishes from, §4.3) under releases/ (§3.2) from git history, reads the co-release map sidecar (§3.6) to write the deterministic page→API-diff links (including the HarfBuzz link), and writes the "Files to polish" list to a file (§2.3).

Both generators run verbose: Cake and Python stream their progress to the job log so a long download or a disk/timeout failure is visible as it happens. The machine-readable "Files to polish" list is therefore not on stdout — Python always writes it to a file (output/files-to-polish.txt by default, overridable via --polish-list) that the Polish phase reads (§2.3).

Polish. The AI (the skill) rewrites only the prose of the listed human pages (§4.4). It never creates, renames, or deletes files, never writes structural content or links, and never edits either script. Polish is never part of the script — it is the AI's job afterwards.

So consumers see just two phases — Prepare (run the one script) then Polish (the AI) — and never juggle the individual generators by hand.

2.3 One workflow, one PR

A single GitHub workflow (update-release-notes) runs the §2.2 sequence and opens one pull request with the regenerated releases/ tree. There is no separate "api-diff" vs "release-notes" workflow — they are one pipeline producing one coherent update.

main is the only source of truth, on a daily schedule. The workflow runs on pushes to main and on a daily cron, and is deliberately not triggered by release/* pushes or v* tags. A push/tag event runs the workflow copy that lives on that ref, so triggering from a release branch or tag could only ever run a stale per-branch copy (the duplicate-PR trap). Instead the main run discovers everything itself — it walks every release/* ref and reads v* tags during generation (§1.4, §3) — so a new release-branch commit or a freshly-pushed stable tag is picked up by the next daily run (within ~24h) with no per-branch/tag workflow. A manual workflow_dispatch refreshes immediately when waiting for the daily run is undesirable (e.g. right after tagging). (Walking a release/* ref is a git source for content, not an emission trigger; emission is governed solely by §1.4.)

Prepare runs as a standalone job; the agent only polishes. The Prepare phase (the generate.sh script — Cake then Python, §2.2) runs in its own dedicated job (prepare), separate from the agent job. It uses an ordinary GitHub-Actions runner with the .NET SDK and full network, and — because the historical API diff downloads every published package of both families — it owns a free-disk-space step and its own timeout-minutes so the heavy generation can't exhaust the agent runner's disk or clock. Giving Prepare its own machine is the whole point of the split: a download, disk, or timeout failure fails Prepare loudly and in isolation, not the agent mid-polish.

The two jobs hand off through a workflow artifact, never a shared runner. The prepare job uploads (a) all of its working-tree changes (the regenerated releases/ tree — pages, API-diff folders, and the co-release-map sidecar) and (b) the files-to-polish.txt list. The agent job declares needs: prepare, downloads the artifact, and restores those changes into a clean checkout before the agent starts: the working-tree changes are re-applied and the list is placed back at output/files-to-polish.txt — the same path a manual Prepare run writes — so the Polish phase reads it from one place regardless of how it was triggered. The agent then runs the Polish phase (§2.2) with no network and no python3/cake access — it reads output/files-to-polish.txt first and edits only the prose of the listed pages. The create-pull-request safe-output captures the combined working-tree diff (restored Prepare output + agent prose edits) into the single PR, so both artifacts always ship together. The agent job is gated on Prepare having produced changes (prepare.outputs.has_changes): on a no-op run the deterministic generators reproduce the existing tree byte-for-byte, Prepare's patch is empty, and the agent and the PR are skipped — so an unchanged daily run opens no PR (§4.6 idempotency).


3. Filesystem layout & naming (canonical)

This section is the single source of truth for every path. Engine sections (§4, §5) reference these names; if you need a new path, define it here first. The <line> placeholder is the §1.1 version core; <hb-line> is the HarfBuzz family's core.

Everything lives inside the docfx site, under documentation/docfx/releases/, so docfx renders it and the human pages can link to the API diffs with internal links. Elsewhere in this doc the short form releases/… is used for the same location.

3.1 The releases/ tree at a glance

documentation/docfx/releases/
  3.119.0.md                 ← human landing page (the hub)            [Python] §3.2
  3.119.0-unreleased.md      ← human unreleased delta                 [Python] §3.2
  3.119.0/                   ← API diffs for the SkiaSharp family      [Cake]   §3.3
    index.md                (per-line diff landing — the link target, §3.3)
    SkiaSharp/SkiaSharp.md  (+ SkiaSharp.breaking.md)
    SkiaSharp.Views/SkiaSharp.Views.Android.md  (+ iOS/Mac/Tizen/tvOS …)
    SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz.md
    …
  harfbuzzsharp/             ← HarfBuzz family (its own version scheme)   §3.4
    8.3.0.md                ← human landing page (the hub)            [Python] §3.2
    8.3.0-unreleased.md     ← human unreleased delta                 [Python] §3.2
    8.3.0/                  ← API diffs for the HarfBuzz family        [Cake]  §3.4
      index.md              (per-line diff landing — the link target, §3.4)
      HarfBuzzSharp/HarfBuzzSharp.md  (+ .breaking.md)
      HarfBuzzSharp.NativeAssets.Android/…  …
  co-release-map.json        ← Cake→Python sidecar (not rendered)       [Cake]   §3.6
  TOC.yml, index.md          ← regenerated                            [Python] §3.5

3.2 Human pages (release-notes engine owns)

Both families get human pages (§1.5). The SkiaSharp family lives at the releases/ root; the HarfBuzz family lives under releases/harfbuzzsharp/ with the same shapes:

  • Landing / released page: releases/<line>.md (SkiaSharp) or releases/harfbuzzsharp/<hb-line>.md (HarfBuzz) — the AI-polished "what's new" hub (semantics in §4.2; HarfBuzz specifics in §4.5).
  • Unreleased page: releases/<line>-unreleased.md or releases/harfbuzzsharp/<hb-line>-unreleased.md — the small head delta; omitted when empty (semantics in §4.2).
  • The flat <line>.md name is kept deliberately: the same-named <line>/ API-diff folder (§3.3) coexists beside it, so adding API diffs requires no rename of existing pages and no TOC href churn. The HarfBuzz tree mirrors this exactly.

3.3 API-diff folders (API-diff engine owns)

One folder per emitted line, named by the §1.1 core, holding package-namespaced, per-assembly diffs:

  • releases/<line>/<package>/<assembly>.md — full public-API diff.
  • releases/<line>/<package>/<assembly>.breaking.md — breaking-only sibling.

Rules:

  • The directory level is the package id, so two packages that emit the same assembly name — e.g. SkiaSharp.Views.Windows from both SkiaSharp.Views.WinUI and SkiaSharp.Views.Uno.WinUI — never collide.
  • A multi-target package fans out to one file pair per assembly (e.g. releases/3.119.0/SkiaSharp.Views/ holds Android/iOS/Mac/Tizen/tvOS).

Each folder also holds a generated index.md — the line's diff landing page and the target of the hub page's API-changes link (§4.4). It is deterministic and script-owned: it lists every <package>/<assembly>.md in the folder (flagging any with a <assembly>.breaking.md sibling as ⚠️ breaking) and links back to the ../<line>.md hub. It carries the # API diff: marker like every other generated file, so the §3.5 wipe regenerates it each run. Linking the hub at <line>/index.md (not the bare folder) gives docfx a concrete page to resolve, so the link never dangles.

3.4 The HarfBuzz tree shape

The HarfBuzz family (rules in §1.5) lives in the parallel releases/harfbuzzsharp/ tree, keyed by its own version (the folder name is deliberately lowercase harfbuzzsharp/ to mark the family-tree root, distinct from the HarfBuzzSharp package id one level down). It mirrors the SkiaSharp root exactly, just one level deeper:

  • Human pages (Python, §3.2): releases/harfbuzzsharp/<hb-line>.md and …/<hb-line>-unreleased.md, beside the API-diff folder of the same name.
  • API-diff folders (Cake): releases/harfbuzzsharp/<hb-line>/<package>/<assembly>.md (+ .breaking.md), using the same package-namespaced, per-assembly shape as §3.3.
  • One page — and, once published, one folder — per distinct HarfBuzz line. Repeated SkiaSharp releases shipping the same HarfBuzz line reuse the same folder/page (they just point at it via the co-release map, §3.6); they do not create empty duplicates. An in-flight line that has not published yet has its page but no folder (§4.5).
  • Each HarfBuzz folder also gets a generated index.md (same shape as §3.3), including a back-link to its ../<hb-line>.md hub. It otherwise follows §3.3's index rules and is the target of that hub page's API-changes link (§4.4).

3.5 Ownership — who writes and clears what

To stay idempotent without clobbering each other, each engine owns a disjoint set of paths and only ever clears its own:

PathOwnerCleared by
releases/<line>.mdPythonPython (pruned only if the line stops being emitted §1.4; a shipped-stable line is permanent — see §4.2)
releases/<line>-unreleased.mdPythonPython (stale-page pruning, §4.2)
releases/harfbuzzsharp/<hb-line>.mdPythonPython (same rules as the SkiaSharp hub, per family — §4.2/§4.5)
releases/harfbuzzsharp/<hb-line>-unreleased.mdPythonPython (stale-page pruning, §4.2)
releases/TOC.yml, releases/index.mdPythonPython (regenerated each run)
releases/<line>/<package>/… (SkiaSharp family)CakeCake (generated files only, §5.2)
releases/harfbuzzsharp/<hb-line>/…CakeCake (generated files only, §5.2)
releases/<line>/index.md, releases/harfbuzzsharp/<hb-line>/index.md (per-line diff landings, §3.3/§3.4)CakeCake (marker-managed; regenerated each run, §5.2)
releases/co-release-map.json (§3.6)CakeCake (rewritten each run)

The Cake engine clears only the generated API-diff files it owns. A file is treated as generated — and therefore deleted before the rebuild — only when both of these hold:

  1. Its first line starts with the # API diff: marker. This distinguishes a generated <assembly>.md from a hand-authored file shaped like one (e.g. 1.68.0/SkiaSharp/gpu-migration.md), which a plain *.md glob could not.
  2. It is not a *.humanreadable.md file. This leaves the retired legacy *.humanreadable.md format untouched (and treats all such files consistently regardless of whether they happen to carry the marker).

So hand-authored files nested inside a <line>/ folder are preserved, and the human pages at the releases/ root are never touched. Empty directories are pruned after deletion. The deterministic page→folder links are written by the Python engine (§2.2), not the AI.

TOC / index representation

TOC.yml groups SkiaSharp lines into Version X.Y.x minor nodes (each nesting its patch releases), plus a HarfBuzz section that mirrors the same shape — HarfBuzzSharp X.Y.x minor subgroups, each nesting its emitted HarfBuzz lines pointing at their harfbuzzsharp/<hb-line>.md hubs (an intentional navigation grouping — the lone place the family subfolder surfaces in the TOC; it groups the equally first-class HarfBuzz hubs, it does not subordinate them). Grouping by minor keeps the node from degrading into one flat list as HarfBuzz lines accumulate. The <line>/ and harfbuzzsharp/<hb-line>/ API-diff folders are reached through the hub page's script-owned API-changes link (§4.4) — which targets each folder's generated index.md (§3.3/§3.4) — not surfaced as independent top-level TOC nodes; they are reference material for a release, not separate browsing destinations. From a hub page it 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 <details> block in index.md.
  • Obsolete — the 1.x and 2.x lines, folded under the Obsolete Versions TOC node and a collapsed <details> 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:

ConditionVerdict
stable* = S🟢 ok — current stable
stable* = E (E<S) and preview* ≥ S🟢 ok — promotion gap
stable* = E (E<S) and preview* < S🔴 drift — stuck on extended, nothing promoting
stable* < E, between E and S, or any stable entry off {E,S}🔴 drift — out of date / off-channel
stable* > 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 deterministic SkiaSharp↔HarfBuzz mapping across the engine boundary. It is written by Cake (which already reads the package dependencies) and read by Python (which writes the page→folder link). It is not a rendered page (no .md), it is not edited by the AI, and it is the only thing that crosses from the API-diff engine into the release-notes engine.

Shape

One entry per emitted SkiaSharp line (including in-flight lines), each mapping that SkiaSharp line to the single HarfBuzz line it ships:

json
[
  { "skia_line": "3.119.0", "hb_line": "8.3.0",  "hb_link": "harfbuzzsharp/8.3.0/index.md" },
  { "skia_line": "4.148.0", "hb_line": "14.2.0", "hb_link": "harfbuzzsharp/14.2.0/index.md" }
]

Field meanings

  • hb_line is authoritative. It is the HarfBuzz family line at full §1.1 granularity (never truncated to Major.Minor.Patch, so a 4-part stable like 8.3.1.5 is preserved). Python derives everything else from it: the API-diff index path harfbuzzsharp/<hb-line>/index.md and the hub page path harfbuzzsharp/<hb-line>.md.
  • hb_link is a convenience mirror, equal to harfbuzzsharp/<hb-line>/index.md; Python may use it verbatim or re-derive it from hb_line. It is never a second source of truth.
  • Each SkiaSharp line maps to exactly one HarfBuzz line — its representative package's (§5.2) HarfBuzz dependency. An intermediate HarfBuzz bump within a single SkiaSharp line's previews is attributed to that line's final HarfBuzz version; the map records no finer-grained history.

How Python consumes it

Python consumes the map both ways (§1.5/§4.5): it groups skia_lines by hb_line to give each HarfBuzz line its SkiaSharp git range and its canonical (earliest) co-ship release, and it cross-links the SkiaSharp and HarfBuzz hub pages. A hb_line of a published SkiaSharp line always resolves to a released HarfBuzz folder; an in-flight SkiaSharp line may map to an as-yet-unpublished hb_line that has no folder and instead drives an in-flight HarfBuzz page (§4.5). This sidecar is cross-engine; it is distinct from a page's raw-data block (§4.3), the in-page structured region Python writes for the AI to polish.


4. Release-notes engine (generate-release-notes.py)

4.1 Inputs & outputs

  • Inputs: git log over a diff range (merged-PR subjects … (#1234)), published v* release tags (for preview milestones + dates), versions.json, and the §1.5 co-release map sidecar (§3.6) written by the Cake generator.
  • Two families, one engine (§1.5). The same machinery runs over both families. The SkiaSharp family discovers its lines from release/* branches + v* tags as usual. The HarfBuzz family has no tags of its own, so it discovers its lines and their git ranges from the co-release map (§4.5) and filters every range to HarfBuzz-owned files.
  • Outputs: the human pages and site index defined in §3.2/§3.5 — for both families (releases/<line>.md and releases/harfbuzzsharp/<hb-line>.md, each with an optional -unreleased sibling).
  • No GitHub API for content — PRs come from commit messages. (A cached, best-effort GraphQL lookup only upgrades author handles; it never affects which PRs or pages exist.)

4.2 Released vs unreleased — two coexisting pages

A version's released and unreleased states are orthogonal and get separate pages (§3.2) that coexist while the version is in flight:

  • Released <line>.md — the full cumulative rollup of a line's shipped prerelease/stable from its baseline (§1.3), carrying preview-milestone sections (§4.3) and supersede banners. Its milestones come from the line's v* tags (§4.1) while the commit range comes from the matching release/X.Y.Z branch checkout (§4.6) — tags name the previews, the branch supplies the commits.

  • Unreleased <line>-unreleased.md — a small delta from the last release on that same line to the head branch (main, or a servicing release/X.Y.x) — "what may ship next". It is not a rollup: it ignores compare_to, never reaches across minors for its base, and therefore lists no preview milestones and no buckets — just a flat list of the head-only PRs. Omitted when the delta is empty.

    Invariant: the base of an unreleased page is always the latest release of its own line, so its milestone window is empty by construction. A non-empty window is a base-resolution bug, not a feature.

So 4.150.0 can have both 4.150.0.md (rollup) and 4.150.0-unreleased.md (the release/4.150.0-preview.1..main delta). They never collide.

Pruning. A -unreleased page is removed as soon as its line stops being the head's in-flight line — i.e. once the head advances to a higher version (in the same minor or a higher minor), the now-orphaned page is pruned. A released <line>.md is pruned only when the line stops being emitted under §1.4 — e.g. a never-stable line dropped from versions.json and no longer ahead of the latest stable. A line that shipped a stable is emitted forever (§1.4.1), so its released page is permanent and never pruned.

4.3 Per-preview PR buckets

When a released page rolls up several prerelease tags, the raw-data block groups the PRs into per-preview buckets: each PR is assigned by git ancestry to the earliest milestone tag whose range contains its commit — the preview it first shipped in. The buckets exhaustively partition the range (every PR in exactly one), so they are the full list; there is no separate flat list. Pages with no previews (an unreleased delta, a plain stable patch) carry one flat list instead.

4.4 Division of responsibility — the script structures, the AI only polishes

Division of responsibility:

OwnerResponsibility
ScriptsEverything structural and deterministic: every filename, diff range, released-vs-unreleased split, rollup-vs-delta, supersession banner, preview bucketing, stale-page pruning, and all links (including the §1.5 HarfBuzz page→folder link).
AI / skillOnly rewrites prose in the files the script lists under "Files to polish". Never creates, renames, or deletes pages; never writes structural content or links; never edits either script. On any anomaly (a missing/unexpected page, data that looks wrong) it stops and reports instead of working around it.

A maintainer then fixes the script (and this spec), never the output. See .agents/skills/release-notes/SKILL.md.

API-diff link rule

The API-diff links are required, fixed, and non-AI. Every emitted page whose line has an API-diff folder carries the script-owned > **API changes** · … line in its structural header (above the prose region).

  • On a SkiaSharp page it points at this line's <line>/index.md (§3.3) and, when HarfBuzz co-ships, at the co-shipped HarfBuzz hub page harfbuzzsharp/<hb-line>.md (§1.5/§3.6) — which carries HarfBuzz's own notes and its own API-diff link.
  • On a HarfBuzz page it points at this HarfBuzz line's harfbuzzsharp/<hb-line>/index.md (§3.4) and back at its canonical (introducing) SkiaSharp release (§1.5).

A page with no API-diff folder — typically an -unreleased head delta, whose commits are not yet in any published line's folder — carries no such line; the folder's presence drives the link, and the link's presence is part of the content key (§4.6), so it is backfilled the moment a folder appears.

The line is identical in presence and ownership on every page (always script-owned, never AI-authored); its two targets differ by family per the rules above. The AI does not decide whether to include it, where to put it, how to word it, or what it links to, and does not narrate around it: it preserves any such line verbatim and writes no API-diff or cross-family links of its own. "Just link" — the diff itself is the artifact (§5.4); the page only points at it.

4.5 The HarfBuzz family pages

The engine processes the HarfBuzz family with the same code paths as SkiaSharp (§4.2–§4.4, §4.6); only line discovery, range resolution, and the commit filter differ. All of this is deterministic and script-owned.

  • Lines come from the co-release map (§3.6), not from branches or tags. Python inverts the map (groups skia_lines by hb_line) to get the set of HarfBuzz lines and their order. Every HarfBuzz line therefore gets a page, so the page set equals the line set, exactly as for SkiaSharp: a published line pairs 1:1 with its API-diff folder (§3.4), while an in-flight line has a page but no folder yet (see Released vs unreleased below).
  • Ranges come from the co-shipping SkiaSharp releases (§1.5), resolved with the same release/* branch + v* tag machinery the SkiaSharp family uses. A HarfBuzz line's commit window is the SkiaSharp range(s) of the SkiaSharp line(s) that ship it (the skia_lines mapping to it), then filtered to HarfBuzz files. In the common case a HarfBuzz line is introduced by exactly one SkiaSharp release (its Bump HarfBuzz PR lands there), so the window is that one release's range; when a HarfBuzz line ships unchanged across several SkiaSharp releases the window is their union (the later releases contribute no HarfBuzz-touching commits). Preview milestones (§4.3), when present, are the SkiaSharp previews in that window where HarfBuzz work landed, ordered by their SkiaSharp release.
  • Commits are filtered to HarfBuzz-owned files (§1.5): the HarfBuzzSharp / HarfBuzzSharp.NativeAssets.* projects, the native libHarfBuzzSharp definition and its native harfbuzz build, and HarfBuzz tests — never any SkiaSharp.* source. The script owns the canonical path list; illustratively it includes binding/HarfBuzzSharp/**, binding/HarfBuzzSharp.NativeAssets.*/**, binding/libHarfBuzzSharp.json, binding/IncludeNativeAssets.HarfBuzzSharp.targets, native/*/libHarfBuzzSharp/**, and tests/Tests/HarfBuzzSharp/**, and excludes all binding/SkiaSharp*/** and source/SkiaSharp.HarfBuzz/** (the SkiaSharp-versioned managed shaper). A PR touching both families lands on both pages (intended duplication).
  • A page for every line; "No changes" when empty. Every HarfBuzz line gets a page. If the filtered commit set is empty (a SkiaSharp release that merely rebuilds the same HarfBuzz, or whose HarfBuzz delta is a bump-only with no notable PRs), the generator writes a deterministic No changes page — a stable, fully-rendered page (status banner
    • script-owned API-changes link + a short "No HarfBuzz changes in this release" body) with no AI raw-data block, so it is never listed under "Files to polish". A page whose filtered set is non-empty is polished like any SkiaSharp page. (This No changes rule is general — it just never triggers for SkiaSharp, whose lines always have commits.)
  • Released vs unreleased (§4.2) and content-key idempotency (§4.6) apply unchanged, per family. A HarfBuzz line that shipped inside a released SkiaSharp line gets a permanent <hb-line>.md, dated by its canonical (introducing) SkiaSharp release (HarfBuzz has no release date of its own). An in-flight HarfBuzz line — one an in-flight SkiaSharp line newly declares (its SkiaSharp.HarfBuzzHarfBuzzSharp dependency read from the working tree, §5.1) but that has not published — gets a <hb-line>-unreleased.md: the HarfBuzz-filtered delta of that SkiaSharp line's own unreleased window (its release/X.Y.x-or-main head, §4.2), rendered "In development". It exists only when the in-flight SkiaSharp line introduces a new HarfBuzz line (the head dependency differs from the last released HarfBuzz line); a head that merely rebuilds an already-released HarfBuzz line produces no in-flight HarfBuzz page. Such an in-flight line has no published package, hence no API-diff folder and no API-changes link (§4.4), and is pruned by the same rule once it ships.
  • Range resolution here is for the release-notes git window only. The Cake API-diff baseline for a HarfBuzz line is the previous emitted HarfBuzz line on the HarfBuzz feed (§1.3/§5.2, per family) — it does not use the co-release ranges; only the release-notes git window does.

4.6 How it runs

Always the full, idempotent pass: fetch main + every release/*, regenerate each line's raw-data block (§4.3), prune orphaned -unreleased pages (§4.2), and write only files whose content key changed — the key compares PR count, diff range, the supersession metadata (status, superseded_by, supersedes), and the script-owned API-changes link (whether this line has an API-diff folder, its HarfBuzz co-release mapping, and — for a HarfBuzz page — its canonical SkiaSharp back-link target, §4.4). Toggling a version's supersession in versions.json rewrites its banner even when the PR set is identical; likewise, a page that newly gains (or loses) an API-diff folder is rewritten to inject (or drop) the API-changes link — which is what backfills the link across historical pages on first run. Then regenerate TOC.yml + index.md. The "Files to polish" list — written to the files-to-polish.txt file the Polish phase reads (§2.3) — names only genuinely-changed pages, so the AI never re-polishes an up-to-date page. When it is empty and the tree is unchanged, the workflow opens no PR (§2.3).


5. API-diff engine (api-diff.cake)

5.1 Inputs & outputs

  • Inputs: every published version of each package in TRACKED_NUGETS (defined in scripts/infra/shared/shared.cake; add a package by adding it there) from nuget.org, prereleases included, diffed with Mono.ApiTools.NuGetDiff, versions.json, and — for the co-release map's in-flight entries (§3.6) — the working-tree SkiaSharp.HarfBuzzHarfBuzzSharp dependency of each in-flight SkiaSharp line, so an in-flight HarfBuzz line is recorded even before it publishes (§4.5).
  • Outputs: the per-family API-diff trees defined in §3.3 / §3.4, and the §1.5 co-release map sidecar (§3.6) for the Python generator.

5.2 Behavior

The historical target rebuilds the complete set authoritatively: it clears the generated API-diff files it owns first (§3.5) so anything that should no longer exist (a stale *.breaking.md, a package dropped from TRACKED_NUGETS, a baseline changed in versions.json) is pruned rather than left to drift. It removes only # API diff:-marked files (preserving any hand-authored extras) and never touches the human pages.

It applies the §1 model per family (§1.5): collapse each family's feed into lines, emit exactly the lines §1.4 selects, and diff each emitted line against its baseline (§1.2/§1.3). A line's representative package is the newest stable if it shipped, otherwise the newest prerelease.

Deterministic reference resolution

The problem. The comparer must resolve every referenced assembly: an unresolved reference makes Mono.ApiTools silently degrade type matching into spurious "New Type" dumps whose shape depends on what is installed on the build host, so the output stops being deterministic. CreateNuGetDiffAsync (scripts/infra/docs/api-diff-tools.cake) therefore adds every real dependency explicitly.

Third-party references come from packages pinned in scripts/VERSIONS.txt via AddDep/AddPackageDir — covering the framework/reference packs, the GTK/GIR and Maui stacks, and the Xamarin.Forms platform renderers (the iOS/macOS ones ship under build/XCODE11, the rest under lib/, all in the one pinned Xamarin.Forms package).

SkiaSharp's own inter-package references (e.g. SkiaSharp.Views.*/SkiaSharp.ResourcesSkiaSharp, SkiaSharp.Views.Maui.ControlsSkiaSharp.Views.Maui.Core, SkiaSharp.HarfBuzzHarfBuzzSharp) are not added globally. They are staged per diff by StageSelfDepsFromNuspecAsync, which reads the package-under-diff's own .nuspec and adds each of our managed dependencies at the exact version that nuspec pins — the version the package was actually built against — then UnstageSearchPaths removes them before the next line so a self-dependency never leaks across versions. This is contemporaneous: a 1.x diff resolves SKObject from the 1.x SkiaSharp, not from today's build, so it can never show inherited members the historical type never had.

It must be read from the nuspec rather than reused from the package's own version because the self-dependency family is not a single version lineSkiaSharp.HarfBuzz 2.88.7 was built against HarfBuzzSharp 7.3.0.1, not 2.88.7 — so only the nuspec records the correct version for every self-dependency uniformly. NativeAssets.* dependencies are excluded (they ship only native binaries, contribute no managed types, and are the largest packages to fetch), and a self-dependency version that cannot be fetched is logged and skipped rather than aborting the run. The nuspec is frozen, so this is deterministic and host/cache-independent, unlike the old cache-glob that bound by enumeration order.

Why it matters. Resolving these matters because the public SkiaSharp types derive from SKObject, which implements the internal infrastructure interfaces ISKReferenceCounted and ISKSkipObjectRegistration; if the referenced assembly is unresolved those internals leak into the public diff as bogus base-interface entries instead of collapsing to the public System.IDisposable.

The sole exception is _Microsoft.Android.Resource.Designer, generated into every .NET-Android assembly and shipped in no package; because it is absent on every host it is skipped with IgnoreResolutionErrors = true without breaking determinism, and it is never part of SkiaSharp's public API.

Troubleshooting. If a regeneration shows unexplained "New Type" churn, a dependency is missing — add it as a real AddDep/AddPackageDir; never accept the churn. (Only the cross-platform netstandard SkiaSharp.Views.Forms assembly is emitted as an api diff; the obsoleted per-platform builds are diffed only so the run completes, and their output is discarded.)

5.3 The "current" CI variant

Alongside the historical regeneration, a lighter current variant diffs the freshly built, unpublished CI packages against the feed during a release, as a build-pipeline validation gate. It shares the same baseline logic and the same shared diff machinery (§2.1) and lives in the same api-diff.cake, but it is invoked from the build/test pipeline, not from the §2.2 regeneration sequence, and writes only transient CI artifacts — never the committed releases/ tree.

5.4 How it runs

dotnet cake … --target=… for api-diff.cake, as the Cake generator of the §2.2 Prepare phase, which runs in the workflow's dedicated prepare job (§2.3). There is no AI polish on the diffs themselves — the generated diff is the artifact; the human pages merely link to it (§1.5/§4.4).

Package cache — cache once, run many

Every package the run needs (each representative + baseline body and their declared dependencies) is fetched once into externals/package_cache and reused on every subsequent run. Mono.ApiTools.NuGetDiff downloads a package's declared nuspec dependencies on demand during the compare, not during the initial extract, so there is no separate "fully offline" pre-warm step — the on-demand path is itself idempotent (a warm cache is a no-op; only genuinely new packages are downloaded). Because resolution is now deterministic (§5.2), a warm and a cold cache produce byte-identical output, so the cache never needs to be wiped to get a correct result — wiping only forces a slow re-download. The docs Docker image (scripts/infra/docs/docker/) bind-mounts the host cache for warm runs and a separate host-owned empty dir for an explicit --cold run when you want to re-prove that equivalence.


6. Differences at a glance

Everything in §1 — line collapsing, the previous-emitted-line baseline, compare_to/supersession overrides, per-family handling, and which lines emit — is identical across both engines. They differ only here:

ConcernRelease notesAPI diffs
Generatorgenerate-release-notes.py (Python)api-diff.cake (Cake)
Content diffedmerged PRs (git)public API (assemblies)
Released + unreleased splityes (two-file, §4.2)no (one set per line)
Per-preview bucketsyes (§4.3)no
AI post-processingyes — prose only (§4.4)no
HarfBuzz familyfirst-class hub pages under harfbuzzsharp/ (§4.5)first-class diff tree under harfbuzzsharp/ (§3.4)

The split exists because release notes are read by humans tracking "what's shipped vs what's coming", whereas an API diff is a mechanical record of a released package's surface. Both live in the docfx site under releases/ (§3), so a release page links straight to its API diffs.


7. Editing rules

7.1 Editing this spec

  1. Spec first. Change this document, then make the code implement it.
  2. Define paths once. §3 is the only place that defines filesystem paths. Engine sections reference it; never restate a divergent path elsewhere.
  3. Keep the two engines aligned on §1. If you change a shared rule, change it in both generate-release-notes.py and api-diff.cake, and re-state it here.

7.2 Behavioral invariants the code must uphold

  1. No human toggles. These are agent/CI tools. They always do the full, correct, idempotent thing. Don't add convenience flags or per-item modes.

  2. versions.json is the only override surface (§1.2). No new auto-detection magic.

  3. Respect ownership (§3.5). Each engine clears only the paths it owns; the scripts own all structure and links; the AI only polishes prose (§4.4). Neither engine edits the other's files.

  4. Prove no regression. Both generators are idempotent: run before/after on a clean tree and diff the generated outputs; intended changes only.

  5. Prepare is isolated and verbose. The Prepare phase runs in its own job with its own disk/timeout budget and streams progress to the log; it hands off to the Polish agent only through the uploaded artifact (working-tree changes + the files-to-polish.txt list), never a shared runner or stdout capture (§2.3).

  6. API-diff output is host-independent (§5.2). Every assembly reference is satisfied by a real, host-independent dependency, so resolution is contemporaneous and never depends on the build host or cache state:

    • third-party references by a version pinned in scripts/VERSIONS.txt (AddDep/AddPackageDir); and
    • SkiaSharp's own inter-package references by the exact version pinned in the package-under-diff's own .nuspec, staged per-diff by StageSelfDepsFromNuspecAsync and removed afterwards.

    The comparer runs with IgnoreResolutionErrors = true solely to skip the single reference that ships in no package — _Microsoft.Android.Resource.Designer, generated into every .NET-Android assembly and absent from SkiaSharp's public surface, hence unresolvable 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.

  7. 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.