documentation/dev/release-notes-and-api-diffs.md
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):
generate-release-notes.py (Python) from git history, then AI-polished for prose.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.
versions.json — the only override surfacescripts/infra/docs/; the skill just calls themreleases/ tree at a glancegenerate-release-notes.py)
api-diff.cake)
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).
A line is a version with its prerelease label stripped: 4.148.0-rc.1.2 →
4.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.
versions.json — the only override surfacescripts/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:
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:
{
"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.
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.
A line is emitted (gets its own artifact in both systems) when any of the following holds — and not otherwise:
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).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:
v* tags, the versions.json
skiasharp bucket, and the latest-stable boundary.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.
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:
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.*.
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.
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:
SkiaSharp.* source (SkiaSharp.HarfBuzz is SkiaSharp-versioned and stays on the
SkiaSharp page). §4.5 holds the canonical path list.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.
scripts/infra/docs/; the skill just calls themAll 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/.
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:
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.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.
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).
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.
releases/ tree at a glancedocumentation/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
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:
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).releases/<line>-unreleased.md or
releases/harfbuzzsharp/<hb-line>-unreleased.md — the small head delta; omitted when
empty (semantics in §4.2).<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.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:
SkiaSharp.Views.Windows from both SkiaSharp.Views.WinUI
and SkiaSharp.Views.Uno.WinUI — never collide.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.
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:
releases/harfbuzzsharp/<hb-line>.md and
…/<hb-line>-unreleased.md, beside the API-diff folder of the same name.releases/harfbuzzsharp/<hb-line>/<package>/<assembly>.md
(+ .breaking.md), using the same package-namespaced, per-assembly shape as §3.3.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).To stay idempotent without clobbering each other, each engine owns a disjoint set of paths and only ever clears its own:
| Path | Owner | Cleared by |
|---|---|---|
releases/<line>.md | Python | Python (pruned only if the line stops being emitted §1.4; a shipped-stable line is permanent — see §4.2) |
releases/<line>-unreleased.md | Python | Python (stale-page pruning, §4.2) |
releases/harfbuzzsharp/<hb-line>.md | Python | Python (same rules as the SkiaSharp hub, per family — §4.2/§4.5) |
releases/harfbuzzsharp/<hb-line>-unreleased.md | Python | Python (stale-page pruning, §4.2) |
releases/TOC.yml, releases/index.md | Python | Python (regenerated each run) |
releases/<line>/<package>/… (SkiaSharp family) | Cake | Cake (generated files only, §5.2) |
releases/harfbuzzsharp/<hb-line>/… | Cake | Cake (generated files only, §5.2) |
releases/<line>/index.md, releases/harfbuzzsharp/<hb-line>/index.md (per-line diff landings, §3.3/§3.4) | Cake | Cake (marker-managed; regenerated each run, §5.2) |
releases/co-release-map.json (§3.6) | Cake | Cake (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:
# 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.*.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.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 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:
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.<details> block in index.md.<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):
"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) 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).
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.
One entry per emitted SkiaSharp line (including in-flight lines), each mapping that SkiaSharp line to the single HarfBuzz line it ships:
[
{ "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" }
]
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.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.
generate-release-notes.py)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.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.releases/<line>.md and releases/harfbuzzsharp/<hb-line>.md, each with an
optional -unreleased sibling).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.
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.
Division of responsibility:
| Owner | Responsibility |
|---|---|
| Scripts | Everything 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 / skill | Only 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.
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).
<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.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.
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.
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).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.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).<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.HarfBuzz → HarfBuzzSharp
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.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).
api-diff.cake)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.HarfBuzz → HarfBuzzSharp dependency of each in-flight SkiaSharp line, so an
in-flight HarfBuzz line is recorded even before it publishes (§4.5).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.
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.Resources
→ SkiaSharp, SkiaSharp.Views.Maui.Controls → SkiaSharp.Views.Maui.Core,
SkiaSharp.HarfBuzz → HarfBuzzSharp) 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 line — SkiaSharp.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.)
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.
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).
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.
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:
| Concern | Release notes | API diffs |
|---|---|---|
| Generator | generate-release-notes.py (Python) | api-diff.cake (Cake) |
| Content diffed | merged PRs (git) | public API (assemblies) |
| Released + unreleased split | yes (two-file, §4.2) | no (one set per line) |
| Per-preview buckets | yes (§4.3) | no |
| AI post-processing | yes — prose only (§4.4) | no |
| HarfBuzz family | first-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.
generate-release-notes.py and api-diff.cake, and re-state it here.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.
versions.json is the only override surface (§1.2). No new auto-detection
magic.
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.
Prove no regression. Both generators are idempotent: run before/after on a clean tree and diff the generated outputs; intended changes only.
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).
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:
scripts/VERSIONS.txt
(AddDep/AddPackageDir); and.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.
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.