READ THIS FIRST before editing any generator. This document is the specification; the Python scripts, 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, built from deterministic
JSON (
release-notes-data.py+release-notes-index.py), AI-authored prose JSON, and the offlinerelease-notes-render.pyrenderer. - 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. The public entrypoints keep
a deliberately small, uniform interface: --force, --min-version, and
--max-version for the shell orchestrators (§2.2), translated to Cake's
--force=true, --minVersion, and --maxVersion where needed (§5.3). A normal
unforced run is cheap because every deterministic engine is incremental; it skips
work whose committed output already exists or whose data sidecar is unchanged. If
you find yourself adding a mode flag "for convenience", stop — the correct
behavior is to do the same full pipeline and let the engines skip safely.
- §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 HarfBuzzSharp co-ships inside SkiaSharp pages
- §2 — Skill layout & orchestration
- §2.1 Where the engines live
- §2.2 Two phases: Prepare → Polish
- §2.3 One workflow, one PR
- §2.4 The rendering pipeline
- §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 API-diff tree
- §3.5 Ownership — who writes and clears what
- §3.6 The co-release map sidecar (Cake → release-notes-data.py)
- §3.7 The manual additions sidecar (maintainer → release-notes-data.py → Polish AI)
- §3.1 The
- §4 — Release-notes pipeline (
release-notes-data.py,release-notes-index.py,release-notes-render.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 section on a SkiaSharp page
- §4.6 How it runs
- §4.7 Manual additions & breaking-change summaries (companion files)
- §5 — API-diff engine (
api-diff.cake)- §5.1 Inputs & outputs
- §5.2 Behavior
- §5.3 Incremental, scoped, and clearing behavior
- §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
This is the hard-won core. Both engines share these rules for SkiaSharp release lines; the API-diff engine also applies the same bucket rules to HarfBuzzSharp-versioned API-diff folders (§3.4). 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.
scripts/infra/docs/versions.json is the single place that deviates from the
defaults. It has three distinct roles, and a line may use any combination:
- 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.
- Navigation support tiers — the top-level
supportblock drives only the release-notes TOC/index grouping (§3.5).
File shape — override buckets plus non-family site config. The two package version schemes still need separate override buckets because the API-diff engine emits both SkiaSharp-versioned and HarfBuzzSharp-versioned diff folders (§3.3/§3.4):
{
"skiasharp": { "4.147.0": { "status": "superseded", "superseded_by": "4.148.0" },
"4.148.0": { "compare_to": "3.119.4" } },
"harfbuzzsharp": { },
"support": { "stable": ["4.148"], "preview": ["4.150"] },
"history_floor": { "skiasharp": "3.0.0" }
}skiasharp is the only bucket that can create or compare release-notes pages:
there are no standalone HarfBuzzSharp release-notes pages anymore (§1.5/§4.5).
harfbuzzsharp remains the override surface for the HarfBuzzSharp API-diff
folders, whose package versions can differ from SkiaSharp's. An absent or empty
bucket means "no overrides — pure defaults" for that bucket.
Alongside the override buckets the file carries non-family top-level keys:
support— navigation support tiers consumed only by the release-notes TOC/index (§3.5). It never affects comparison or emission.history_floor— the per-bucket regeneration floor (§1.4).
The recognised per-line fields are:
compare_to: "X.Y.Z"— diff this line against an explicit baseline instead of the §1.3 default.compare_toalways wins when present; the §1.3 skip rule applies only in its absence. (It may even point at asupersededline if that is deliberately wanted.)status: "superseded"— this field is what excludes a line from being used as a baseline (§1.3) inside the same override bucket. 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 bucket will diff against it.superseded_by: "X.Y.Z"— the forward link used only to render the "superseded by" banner on the superseded SkiaSharp page and the inversesupersedesback-link on the successor. It does not by itself affect baseline selection; pair it withstatus: "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.
Release notes resolve this rule for SkiaSharp lines only. The API-diff engine
resolves the same rule inside each package-version bucket it emits (skiasharp and
harfbuzzsharp), because HarfBuzzSharp API diffs still have their own package version
scheme (§3.4). There is no HarfBuzzSharp release-notes baseline: its notes are a
section of the co-shipping SkiaSharp page (§1.5/§4.5).
If there is no prior emitted line at all (the first line a bucket 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 superseded4.147.0, which is skipped, so the baseline is the last stable3.119.4;4.148thus rolls up all of the4.147work. (versions.jsonalso recordscompare_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 is4.148.0(a preview-only line ahead of the latest stable still counts as emitted, §1.4), so the baseline is4.148.0.
There is no "previous stable only" rule and no other heuristic.
A line is emitted (gets its own artifact) when any of the following holds — and not otherwise:
- It shipped a stable — the permanent record of a released line.
- It is listed in
versions.json— an intentional line the team tracks (e.g. astatus: "superseded"line like4.147.0, or any line carrying acompare_to). - 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).
For release notes, these signals are read only for SkiaSharp lines: shipped-stable
v* tags, the versions.json skiasharp bucket, and the latest-stable boundary.
A released SkiaSharp page may carry a folded HarfBuzzSharp section, but that section
does not make a second emitted release-notes line (§1.5/§4.5).
For API diffs, the same emission rule is applied to each package-version bucket
(skiasharp and harfbuzzsharp). The SkiaSharp API-diff folders and SkiaSharp
release-note pages therefore stay aligned above the history floor. HarfBuzzSharp
API-diff folders remain under releases/harfbuzzsharp/<hb-line>/ and are linked from
the co-shipping SkiaSharp pages; they are reference artifacts, not standalone
release-note hubs (§3.4/§4.5).
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 SkiaSharp release
page.
History floor (a performance skip, not a rule change). The top-level
history_floor block in versions.json optionally sets a per-bucket minimum line core
(e.g. {"skiasharp": "3.0.0"}). A line below the floor is not regenerated and not
re-emitted, so a full regeneration skips the obsolete back-catalogue (every 1.x/2.x
line) it would otherwise rebuild from the NuGet feed on every run. It is not a
deletion: pages and API-diff folders already committed below the floor are left exactly
as they are — the Cake engine skips clearing them symmetrically with skipping their
emission, so a floored run never wipes history, it just doesn't rebuild it. Baselines
are unaffected: a floored line can still be downloaded as a baseline (e.g. 3.116.0
still diffs against 2.88.9) — both via an explicit compare_to override and as the
implicit predecessor of the lowest emitted line. That lowest line (the floor line
itself, e.g. 3.0.0) has no emitted predecessor — its natural baseline sits below the
floor — so the API-diff engine resolves it from the pre-floor emittable set and downloads
it for comparison only (never emitting the below-floor line's own page). Without this,
the floor line would diff against an empty assembly (0.0.0.0) and re-emit its entire API
surface as "new" on every run — a large, wrong churn. It is one already-cached package (it
is also the next line's baseline), so the floor's performance win is preserved. Absent/empty
block ⇒ no floor (every line is regenerated, the legacy behavior). Raise the floor as old
lines stop needing refreshes; lower or remove it to rebuild history.
SkiaSharp and HarfBuzzSharp ship together (HarfBuzzSharp never releases on its own),
but they carry different package version numbers (SkiaSharp 3.119.0,
HarfBuzzSharp 8.3.0). The release-notes model is therefore:
- SkiaSharp is the release-note page family. Every human release-notes page is a
SkiaSharp page at the
releases/root (§3.2). - HarfBuzzSharp is folded into the co-shipping SkiaSharp page. Every released
SkiaSharp page carries exactly one HarfBuzzSharp version in
data.harfbuzzand renders it as a## HarfBuzzSharp X.Y.Zsection (§4.5). - HarfBuzzSharp still has API-diff folders. The Cake API-diff engine emits
HarfBuzzSharp-versioned folders under
releases/harfbuzzsharp/<hb-line>/(§3.4), and the SkiaSharp page's banner links to them (§4.4).
There are no standalone HarfBuzzSharp release-notes hub pages, no
harfbuzzsharp/_sources/*.data.json / *.prose.json, no HarfBuzz TOC node, and no
HarfBuzz section in the releases index. release-notes-render.py --all actively retires old
releases/harfbuzzsharp/<hb-line>.md and <hb-line>-unreleased.md hub pages while
leaving the Cake-generated API-diff folders in place (§3.5/§4.6).
Every SkiaSharp.* package belongs to the SkiaSharp-versioned bucket — SkiaSharp,
SkiaSharp.Views.*, SkiaSharp.Skottie, and crucially SkiaSharp.HarfBuzz (the
managed shaper binding, which carries the SkiaSharp version, not a HarfBuzzSharp
version). The HarfBuzzSharp-versioned API-diff bucket is only HarfBuzzSharp and
HarfBuzzSharp.NativeAssets.*.
That distinction is for API diffs and dependency resolution; it does not create a second release-notes page family. A PR that touches both SkiaSharp and HarfBuzzSharp can be covered by the normal SkiaSharp prose and also contribute to the folded HarfBuzz section, but it does not appear on a separate HarfBuzz page.
HarfBuzzSharp cannot ship without SkiaSharp. The exact HarfBuzzSharp 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 the API-diff engine records in the co-release map (§5.1). The value
is kept at full §1.1 line granularity so it names a real HarfBuzzSharp API-diff folder
(never truncated to Major.Minor.Patch).
release-notes-data.py reads the co-release map by SkiaSharp line and, on released
SkiaSharp pages only, writes:
"harfbuzz": {
"version": "<hb-line>",
"api_diff_link": "harfbuzzsharp/<hb-line>/index.md",
"prs": [1234, 5678]
}The prs list is the same SkiaSharp release window filtered to HarfBuzz-owned paths:
binding/HarfBuzzSharp/**binding/HarfBuzzSharp.NativeAssets.*/**binding/libHarfBuzzSharp.jsonbinding/IncludeNativeAssets.HarfBuzzSharp.targetsnative/*/libHarfBuzzSharp/**tests/Tests/HarfBuzzSharp/**
It deliberately excludes SkiaSharp.* source (SkiaSharp.HarfBuzz is
SkiaSharp-versioned and stays with the normal SkiaSharp page content). Multiple
SkiaSharp lines may share one HarfBuzzSharp version; each SkiaSharp page still records
its own co-shipped version and its own filtered PRs for that release window. The mapping
is deterministic and script-owned — the AI never computes it.
All three doc-generation engines live together under scripts/infra/docs/ (local
runs, CI, and the docs Docker image share one copy). The release-notes engine —
its data builders, renderer and prose schema — sits beside the API-diff engine and
the shared Cake machinery it runs with, rather than in the skill folder. The
skill keeps only the thin, stable entrypoints (prepare.sh, render.sh) plus
the AI's instructions (SKILL.md, samples/): the entrypoints redirect to the
real engine under scripts/infra/docs/, so the skill stays a simple, stable
surface while the implementation can be edited underneath it. (The committed page
inputs — _sources/*.data.json, *.prose.json, co-release-map.json, index.json,
and the pr-authors.json author cache — live under releases/_sources/.)
scripts/infra/docs/ (all doc engines, together)
api-diff.cake API-diff engine (§5), used by the single
top-level `docs-api-diff` target
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-docs.sh Path 3 runner: cake update-docs (mdoc under mono)
release-notes-data.py Prepare data engine (§4) — emits _sources/<version>.data.json,
owns shared git/version helpers and page-set discovery
release-notes-index.py Prepare index-data engine (§4) — emits _sources/index.json
(Chrome schedule + live-head set); writes no Markdown
release-notes-render.py Polish renderer (§4) — all Markdown: pages + TOC.yml + index.md,
prunes stale -unreleased pages and retires HarfBuzz hub pages
release-notes-schema/prose.schema.json the prose contract the agent fills
versions.json supersession + baseline config (§1); shared repo-wide
release-notes-paths.json PR path→tag classification (§4.4): the deterministic,
editable product/mixed/internal path map read by release-notes-data.py
docker/ reproducible image + run.sh wrapper; `api-diffs`
invokes the `docs-api-diff` target directly
.agents/skills/release-notes/ (the skill: instructions + thin entrypoints)
SKILL.md the AI's prose instructions (§4.4)
samples/ worked end-to-end examples
scripts/
prepare.sh Prepare entrypoint → docs-api-diff → release-notes-data.py →
release-notes-index.py; writes the Files-to-polish list (§2.2)
render.sh Polish-Finalize entrypoint: offline release-notes-render.py --all (§2.2)
The release-notes engine is split by phase and artifact, and the skill's two
entrypoints delegate to it. prepare.sh owns no generation logic of its own — it
delegates, in order, to the shared docs-api-diff Cake target (Path 1), then to
release-notes-data.py, then to release-notes-index.py. The Docker api-diffs
subcommand also invokes that Cake target directly. render.sh is the corresponding
offline finalizer: it wraps release-notes-render.py --all under the same narrow flag
surface so the skill, the update-release-notes workflow, the Docker wrapper, and a
human running locally all execute the same phase commands. release-notes-render.py
imports release-notes-data.py with importlib for shared version parsing and
page-set helpers (version_key, minor_group, get_version_files,
cadence_milestones, and the versions.json path); module load is offline-safe, and
all network data it needs arrives through _sources/index.json. versions.json sits
in the same folder and is shared config (the API-diff engine, security-audit,
skia-sync and nuget-feed all read it), not release-notes-private.
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/.
A full regeneration always runs in two phases — the deterministic, network-capable Prepare phase first, the offline AI Polish phase last:
Prepare. One wrapper script,
.agents/skills/release-notes/scripts/prepare.sh, runs the deterministic
producers in a fixed order. It accepts only --force, --min-version, and
--max-version and forwards those flags to the engines that can use them: Cake
receives --force=true, --minVersion=X, and --maxVersion=Y; release-notes-data.py
receives --force, --min-version X, and --max-version Y; release-notes-index.py is
repo-global and always runs unscoped. The skill and the workflow call this single
script rather than the individual producers, so the run order and flag translation
live in one place:
- Cake (
docs-api-diff→api-diff.cake) incrementally regenerates the required API-diff folders underreleases/(§3.3/§3.4) from the published feed, and updates the §1.5 co-release map sidecar atreleases/_sources/co-release-map.json(§3.6). Deterministic, no AI. release-notes-data.pyregenerates each changed SkiaSharp page's deterministic facts into_sources/<version>.data.json(§3.2/§4.1), using git history, tags,versions.json, the co-release map, and companion-file hashes. On released pages it folds the co-shipped HarfBuzzSharp version/API-diff link/filtered PR list into the page'sharfbuzzblock (§4.5). It also writes the machine-readable Files to polish list tooutput/files-to-polish.txt(or--polish-list).release-notes-index.pywrites the network-sourced aggregate data toreleases/_sources/index.json(§3.5/§4.1) — the live Chrome schedule for the two milestones in flight, pluslive_unreleased, the set of version cores whose-unreleasedpage is still a live head (from the remote branch list). It writes no Markdown and deletes nothing; the stale-page pruning it informs happens offline inrelease-notes-render.py --all(§4.2).
All three producers run verbose: Cake and Python stream 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; it is always a
file the Polish phase reads (§2.3). The JSON files are timestamp-free and committed
with the rendered pages. A normal unforced run is incremental: Cake skips lines whose
API-diff folder already exists (§5.3), and release-notes-data.py skips pages whose
data.json dict is unchanged (§4.6).
Polish. The AI (the skill) works only from already-produced files, with no
network and no permission to re-run Cake, prepare.sh, release-notes-data.py, or
release-notes-index.py. For each path in output/files-to-polish.txt, it reads that
page's _sources/<version>.data.json plus any referenced *.breaking.md and
_sources/<version>.notes.md companions (§4.7), writes
_sources/<version>.prose.json (schema: release-notes-schema/prose.schema.json, including
harfbuzz_summary when required), and runs
release-notes-render.py <data.json> <prose.json> <out.md> to validate the prose. A render
that prints PROSE VALIDATION FAILED is fixed in prose and rerun; the .md page is
never hand-edited.
After every changed page validates, Polish runs the offline finalizer once — either
.agents/skills/release-notes/scripts/render.sh or its core command,
release-notes-render.py --all. render.sh accepts the same three flags as prepare.sh for
a uniform interface; --force is a no-op because rendering is idempotent, and scoped
runs first validate in-range pages individually before the authoritative --all pass.
That final pass prunes any now-stale -unreleased page (per the live_unreleased
set release-notes-index.py recorded in index.json), retires legacy standalone
HarfBuzzSharp hub pages under releases/harfbuzzsharp/, regenerates every
SkiaSharp page from committed _sources/*.data.json + _sources/*.prose.json (with
folded HarfBuzz sections where present), then rebuilds TOC.yml + index.md from
the finished page set and the committed Chrome schedule in _sources/index.json. So
consumers see just two phases — Prepare (run one script) then Polish (write
prose, render) — 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
(prepare.sh — Cake, release-notes-data.py, then release-notes-index.py, §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
feed-based API diff may download published packages from both version buckets — 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 — API-diff folders, _sources/co-release-map.json, _sources/*.data.json,
_sources/index.json, author cache, and any deletions/pruning) 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) offline: it writes only prose JSON for the
listed pages, may read only the companion files those pages reference, runs
release-notes-render.py to materialize Markdown, and never reruns the network producers.
The create-pull-request safe-output captures the combined working-tree diff
(restored Prepare output + agent prose JSON + rendered pages + TOC.yml +
index.md) into the single PR, so all 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).
The live release-notes pipeline removes page structure from the agent's job.
Instead of the agent editing a whole <version>.md (owning headings, the
contributor table, the banner, @handles, ❤️, and PR links along with the prose),
the work is split by artifact:
release-notes-data.pyemits_sources/<version>.data.json— the deterministic facts a SkiaSharp page is built from: banner facts + links, the flat PR map (each with its three-way tag andcommunityflag), the authoritative contributor roster, per-preview buckets, breaking-change sources (*.breaking.mdand_sources/<version>.notes.md), supersede/API links, tallies, family status, and — on released pages — the foldedharfbuzzblock (version,api_diff_link, and HarfBuzz-path PR numbers). It contains the shared low-level helpers, the page-set discovery helperget_version_files, andcadence_milestones().release-notes-index.pyemits_sources/index.json— network-sourced aggregate data the offline render cannot recompute: the live Chrome schedule for the two milestones in flight, pluslive_unreleased(the version cores whose-unreleasedpage is still a live head, from the remote branch list). It writes no Markdown and deletes nothing — the pruning it informs runs inrelease-notes-render.py --all(§4.2).- The agent writes
_sources/<version>.prose.json— prose only, againstrelease-notes-schema/prose.schema.json:theme,highlights_headline/highlights_body,breaking,categories,contributor_summaries,preview_summaries, andharfbuzz_summarywhendata.harfbuzz.prsis non-empty. It never writes a heading, table, banner, handle, ❤️, or PR link. release-notes-render.pyrenders Markdown — per-page validation withrelease-notes-render.py <data.json> <prose.json> [out.md], and the finalrelease-notes-render.py --allpass that prunes stale-unreleasedpages (perindex.json'slive_unreleased), retires old HarfBuzz hub pages, then rebuilds every SkiaSharp page plusTOC.ymlandindex.mdfrom committed JSON. It is pure stdlib, dependency-free, and offline; the release-cadence timeline reads the schedule from_sources/index.json.
Why the split. Every failure mode that recurred under the single-agent path lived
exactly where the agent owned structure: dropped ## Breaking Changes headings, bare
@handles, dropped contributors, <THEME> left in the banner, and Highlights that
enumerated every PR. Now the renderer always emits the heading; the table is rendered
from the roster (roster == table by construction); handle/❤️/link formatting is
owned by release-notes-render.py from data.json; and Highlights is validated from the
small prose payload rather than freehand Markdown — so structural errors fail the
render instead of shipping.
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 HarfBuzzSharp API-diff line 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.
documentation/docfx/releases/
3.119.0.md ← rendered SkiaSharp landing page (the hub) [release-notes-render.py] §3.2
3.119.0-unreleased.md ← rendered SkiaSharp unreleased delta [release-notes-render.py] §3.2
_sources/ ← inputs for SkiaSharp pages + site index
3.119.0.data.json ← deterministic page facts (+ harfbuzz block on released pages) [release-notes-data.py] §4.1
3.119.0.prose.json ← AI prose payload (+ harfbuzz_summary) [Polish AI] §4.4
3.119.0.notes.md ← optional manual additions sidecar [Maintainer] §3.7
co-release-map.json ← Cake→release-notes-data.py HarfBuzz co-ship input [Cake] §3.6
index.json ← network-sourced index data [release-notes-index.py] §3.5
pr-authors.json ← PR-number → GitHub-login cache (offline author resolution) [release-notes-data.py] §4
3.119.0/ ← API diffs for the SkiaSharp package bucket [Cake] §3.3
index.md (per-line diff landing — the SkiaSharp API-diff 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/ ← HarfBuzzSharp API-diff folders only [Cake] §3.4
8.3.0/
index.md (per-line diff landing — the HarfBuzz API-diff link target, §3.4)
HarfBuzzSharp/HarfBuzzSharp.md (+ .breaking.md)
HarfBuzzSharp.NativeAssets.Android/… …
TOC.yml, index.md ← rendered aggregates over SkiaSharp pages [release-notes-render.py] §3.5
The harfbuzzsharp/<hb-line>/ folders are kept because SkiaSharp release pages link
them. The old harfbuzzsharp/<hb-line>.md hub pages and harfbuzzsharp/_sources/
inputs are retired by release-notes-render.py --all (§3.5/§4.6).
Only SkiaSharp lines get human release-notes pages (§1.5). They live at the
releases/ root:
- Landing / released page:
releases/<line>.md— the rendered "what's new" hub built from_sources/<line>.data.json+_sources/<line>.prose.json(semantics in §4.2). When the line is released, its data includes a foldedharfbuzzblock and the page renders a## HarfBuzzSharp X.Y.Zsection (§4.5). - Unreleased page:
releases/<line>-unreleased.md— the small head delta; omitted when empty (semantics in §4.2). It has noharfbuzzblock because there is no co-shipped HarfBuzzSharp version until the SkiaSharp line is released. - The flat
<line>.mdname 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. - Every page's inputs live one level down in the sibling
_sources/folder:_sources/<stem>.data.jsonfromrelease-notes-data.py,_sources/<stem>.prose.jsonfrom the Polish AI, and optional_sources/<stem>.notes.mdfrom a maintainer. Rendered pages stay at the root so page discovery remains a non-recursive*.mdwalk.
There are no HarfBuzzSharp human page paths. releases/harfbuzzsharp/ is reserved for
Cake-generated API-diff folders (§3.4).
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.Windowsfrom bothSkiaSharp.Views.WinUIandSkiaSharp.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 ../<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 releases/harfbuzzsharp/ tree is not a release-notes family. It contains only
Cake-generated API-diff folders for HarfBuzzSharp-versioned packages, keyed by their
own version core. The lowercase folder name marks the API-diff tree root and is
distinct from the HarfBuzzSharp package id one level down.
- API-diff folders (Cake):
releases/harfbuzzsharp/<hb-line>/<package>/<assembly>.md(+.breaking.md), using the same package-namespaced, per-assembly shape as §3.3. - Each folder also gets a generated
index.md(same package list shape as §3.3). This is the target of the SkiaSharp page banner's[HarfBuzzSharp API diff]link (§4.4/§4.5). - Repeated SkiaSharp releases may ship the same HarfBuzzSharp line. They all point to
the same
harfbuzzsharp/<hb-line>/index.mdAPI-diff landing through their owndata.harfbuzzblocks; no duplicate human HarfBuzz pages are created.
release-notes-render.py --all deletes old releases/harfbuzzsharp/<hb-line>.md and
<hb-line>-unreleased.md hub pages and any harfbuzzsharp/_sources/ JSON/prose left
from the retired model, but it does not delete the API-diff folders.
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 |
release-notes-render.py |
permanent once shipped stable — never pruned; release-notes-render.py --all only rewrites its content (§4.2) |
releases/<line>-unreleased.md |
release-notes-render.py |
release-notes-data.py removes empty head deltas during generation; release-notes-render.py --all prunes stale live-head pages per index.json's live_unreleased (§4.2) |
releases/_sources/<stem>.data.json |
release-notes-data.py |
with the owning SkiaSharp page (release-notes-data.py empty deltas, release-notes-render.py --all stale -unreleased) |
releases/_sources/<stem>.prose.json |
Polish AI | with the owning SkiaSharp page; otherwise never script-rewritten |
releases/_sources/index.json |
release-notes-index.py |
release-notes-index.py (rewritten each Prepare run) |
releases/_sources/co-release-map.json (§3.6) |
Cake | Cake (merged/updated each Prepare run; a full forced run recomputes the whole map) |
releases/TOC.yml, releases/index.md |
release-notes-render.py --all |
release-notes-render.py --all (regenerated from the finished SkiaSharp page set) |
releases/<line>/<package>/… (SkiaSharp API diffs) |
Cake | Cake (generated files only, §5.2) |
releases/harfbuzzsharp/<hb-line>/… (HarfBuzzSharp API diffs) |
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 when that line is rebuilt, preserved when skipped, §5.2/§5.3) |
releases/harfbuzzsharp/<hb-line>.md, releases/harfbuzzsharp/<hb-line>-unreleased.md, releases/harfbuzzsharp/_sources/* |
retired legacy HarfBuzz hub artifacts | release-notes-render.py --all deletes them; Cake-owned API-diff folders are kept |
releases/_sources/<stem>.notes.md (manual additions sidecar, §3.7) |
Maintainer (hand-authored input) | Never machine-cleared — read (path+hash) by release-notes-data.py, read (content) by the Polish AI; docfx-excluded, not rendered |
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:
- Its first line starts with the
# API diff:marker. This distinguishes a generated<assembly>.mdfrom a hand-authored file shaped like one (e.g.1.68.0/SkiaSharp/gpu-migration.md), which a plain*.mdglob could not. - It is not a
*.humanreadable.mdfile. This leaves the retired legacy*.humanreadable.mdformat 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
release-notes scripts never clear maintainer-owned _sources/*.notes.md files. Empty
directories are pruned after deletion. The deterministic page→folder links are facts
in data JSON and Markdown emitted by release-notes-render.py (§2.2), not decisions made by
the AI.
TOC.yml groups SkiaSharp lines into Version X.Y.x minor nodes (each nesting its
patch releases), split into support tiers below. There is no HarfBuzzSharp TOC node:
HarfBuzzSharp notes live as sections inside each SkiaSharp page (§4.5), and the
HarfBuzzSharp API-diff folders are reached through the SkiaSharp page's script-owned
API-changes link (§4.4), not surfaced as independent navigation destinations. From a
SkiaSharp page it is one click to the line's SkiaSharp diff index and, for released
pages, one click to the co-shipped HarfBuzzSharp diff index.
index.md (at the releases/ root) is the top-level list of SkiaSharp release lines,
not a HarfBuzzSharp release index. Both aggregates are assembled by
release-notes-render.py --all from the finished SkiaSharp page set; the release-cadence
timeline reads the Chrome schedule from the committed releases/_sources/index.json
that release-notes-index.py wrote during Prepare.
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
stableline(s) and thepreviewline(s). These render as the top-levelVersion X.Y.xnodes in the TOC and under the Supported versions heading inindex.md(each taggedStableorPreview). Inindex.mdthey 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 asupportblock 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 inindex.md. - Obsolete — the 1.x and 2.x lines, folded under the Obsolete Versions TOC node
and a collapsed
<details>block inindex.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 override 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 apreviewline 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/_sources/co-release-map.json is the inter-engine contract that carries
the deterministic SkiaSharp→HarfBuzzSharp co-ship mapping across the engine boundary.
It is written by Cake (which already reads package dependencies) and read by
release-notes-data.py (which folds the HarfBuzz facts into each released SkiaSharp page's
data.json). It is a pure input sidecar: not rendered, not edited by the AI, and not
inverted into a separate HarfBuzzSharp release-notes family.
The current format is a plain JSON object: one property per SkiaSharp line, whose value is the single HarfBuzzSharp line it ships.
{
"3.119.0": "8.3.0",
"4.148.0": "14.2.0"
}- The object key is the SkiaSharp line at §1.1 core granularity.
- The object value is the authoritative HarfBuzzSharp line at full §1.1 granularity
(never truncated to
Major.Minor.Patch, so a 4-part stable like8.3.1.5is preserved).release-notes-data.pycopies it todata.harfbuzz.version. - The API-diff link is derived, not stored.
release-notes-data.pybuildsdata.harfbuzz.api_diff_linkasharfbuzzsharp/<hb-line>/index.md, so the map contains only the dependency fact and never a duplicate link string. - Each SkiaSharp line maps to exactly one HarfBuzzSharp line — its representative
SkiaSharp.HarfBuzzpackage's (§5.2)HarfBuzzSharpdependency. An intermediate HarfBuzz bump within a single SkiaSharp line's previews is attributed to that line's final HarfBuzzSharp version; the map records no finer-grained history.
The source of truth stays in Cake: for published lines, Cake reads the
HarfBuzzSharp dependency from each emitted SkiaSharp.HarfBuzz package's nuspec; for
the in-flight unpublished line, it falls back to the working tree's scripts/VERSIONS.txt
values so the next page can link the HarfBuzz API-diff folder before the package is on
the feed.
Cake merges the sidecar instead of overwriting it blindly. A scoped or incremental
run only recomputes the lines it actually processed, so it loads the committed object,
overlays the newly discovered entries, and preserves the rest. That is safe because a
shipped SkiaSharp.HarfBuzz package's HarfBuzz dependency is immutable. A full forced
run (--force with no version scope) processes the whole back-catalogue, so the merged
result is effectively a full authoritative refresh.
release-notes-data.py reads the sidecar by SkiaSharp line. For a released SkiaSharp page,
it adds a harfbuzz block containing the version, derived API-diff link, and the PR
numbers in that same SkiaSharp git window that touched HarfBuzz-owned paths
(§1.5/§4.5). For an -unreleased head page, it writes no harfbuzz block: the head
has not co-shipped a HarfBuzzSharp version yet.
The sidecar is cross-engine; it is distinct from a page's own data sidecar (§4.3),
the deterministic facts release-notes-data.py writes for the renderer and the AI.
releases/_sources/<line>.notes.md is an optional, maintainer-authored companion
to a SkiaSharp release page. It is the one place a human injects hand-written material
— a breaking-change call-out, a migration note, an editorial "bring this out" highlight
— that must survive regeneration and re-polish. Editing the final <line>.md directly
does not survive, because release-notes-render.py --all rewrites it from JSON (§4.4); the
sidecar does, because no engine ever writes it.
It is freeform Markdown, not a schema. There is no JSON and no required structure:
the maintainer writes natural-language notes and the Polish AI parses, understands, and
weaves them into prose.json (§4.7). Not all of it is "breaking" — some is simply
material to surface.
- Maintainer-owned input. Only a human creates or edits it. Neither engine ever writes, renames, clears, or reformats it — it is an input/artifact, like a source image, not a generated file (contrast the co-release map §3.6, which Cake owns and rewrites).
- Co-located with the other inputs, never a page. It lives in the page's
_sources/folder and shares the page stem, so it is discoverable but never appears in the non-recursive page globs. It is excluded from the docfx render (**/*.notes.md) so it never becomes its own published page. - Read by
release-notes-data.py(hash only), read by the AI (content).release-notes-data.pyrecords its page-relative path (_sources/<stem>.notes.md) plus asha256of its bytes inbreaking_candidates[](§4.3/§4.7), so editing it changes the timestamp-free data sidecar (§4.6) and re-polishes exactly that page.release-notes-data.pynever parses its content. The Polish AI opens and reads it and summarizes / weaves it into the prose (§4.7). - Orphan handling. A
_sources/<stem>.notes.mdwith no matching SkiaSharp page one directory up (neither<stem>.mdnor<stem>-unreleased.mdexists for its stem) is a maintainer typo;release-notes-data.pywarns and ignores it, writing nothing on its behalf.
This sidecar is a maintainer→engine input; it is distinct from the co-release map
(§3.6, Cake→release-notes-data.py) and from a page's own data sidecar (§4.3), which
release-notes-data.py writes.
The release-notes pipeline has three script-owned producers, split by artifact:
release-notes-data.py(Prepare, network-capable) — per-page facts. Inputs aregit logover a diff range (merged-PR subjects… (#1234)), publishedv*release tags (for preview milestones + dates),versions.json, the §1.5 co-release map sidecar (§3.6) written by Cake, and companion-file hashes (§4.7). It emits_sources/<stem>.data.jsonfor each changed SkiaSharp page and writes the Files-to-polish list tooutput/files-to-polish.txt(or--polish-list). On released pages it addsdata.harfbuzzfrom the co-release map and the HarfBuzz-owned path filter (§4.5). It owns the shared low-level helpers (git/version parsing), the page-set discovery helperget_version_files, andcadence_milestones().release-notes-index.py(Prepare, network-capable) — aggregate index data. Inputs are the remote branch list and the live Chromium Dash schedule. It emits onlyreleases/_sources/index.json:{"chrome_schedule": {"<milestone>": {beta, early_stable, stable_cut, stable}}, "live_unreleased": ["<version>", …]}— the schedule for the two milestones in flight, and the version cores whose-unreleasedpage is still a live head (the setrelease-notes-render.py --allprunes against, §4.2). It writes no Markdown, deletes nothing, and emits no timestamps.release-notes-render.py(Polish, offline) — all Markdown. Inputs are committed_sources/*.data.json, agent-authored_sources/*.prose.json, and_sources/index.json. It renders every SkiaSharp human page (including folded HarfBuzz sections),TOC.yml, andindex.md, and retires legacy HarfBuzz hub pages. It is pure stdlib: no network and no install-time dependencies.
The Polish AI is not a script producer. It reads a page's data sidecar and its
bounded companion files, writes _sources/<stem>.prose.json (schema:
release-notes-schema/prose.schema.json), and runs release-notes-render.py to validate / render. It
never creates, names, prunes, or links pages.
There is no separate HarfBuzz release-notes pass. cmd_generate iterates SkiaSharp
branches only; a version-scoped run (--min-version / --max-version) naturally
covers the folded HarfBuzz section for those SkiaSharp pages because the HarfBuzz facts
are stored in the same data.json.
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.
A SkiaSharp 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), supersede banners, API-diff links, and (when the co-release map has an entry) the folded HarfBuzzSharp section (§4.5). Its milestones come from the line'sv*tags (§4.1) while the commit range comes from the matchingrelease/X.Y.Zbranch 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 servicingrelease/X.Y.x) — "what may ship next". It is not a rollup: it ignorescompare_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. Its H1 isVersion X (Unreleased)so it is distinguishable from the released page of the same version core. It has noharfbuzzblock because there is no co-shipped HarfBuzzSharp version yet.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. release-notes-data.py removes an empty head delta while generating that head.
Stale live-head pruning is a two-step, network-then-offline split: release-notes-index.py
records live_unreleased in index.json (the version cores that are still live
heads — from the remote branch list), and release-notes-render.py --all then removes any
-unreleased page whose version is not in that set — i.e. once the head advances to
a higher version (in the same minor or a higher minor), unless a servicing
release/X.Y.x branch still makes that line live. A released <line>.md is never
pruned this way — 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), so its released
page is permanent and never pruned.
When a released page rolls up several prerelease tags, release-notes-data.py 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.
The committed _sources/<stem>.data.json is the deterministic fact model the page is
built from. It is timestamp-free and includes, at minimum:
format(_DATA_JSON_FORMAT_VERSION),version,family, andstatus.banner,supersedes,superseded_by, andapi_links— all script-owned link / banner facts.harfbuzzon released pages —{ "version", "api_diff_link", "prs" }for the co-shipped HarfBuzzSharp section (§4.5). It is absent on-unreleasedpages.prs— the flat PR map, including title, URL, author,community, and the deterministictag(product,mixed, orinternal). Each entry may also carryfixes— the sorted list of issue numbers the PR closes — emitted only when non-empty so pages with no issue-closing PRs stay byte-identical. It is the union of GitHub's linked-issue graph (closingIssuesReferences, the source of truth, batched and cached in_sources/pr-fixed-issues.json) and theFixes/Closes/Resolves #NNNkeywords in the PR body (the offline fallback). Downstream post-release tooling readsfixesto apply the release milestone to the closed issues; the renderer ignores it.contributors— the authoritative non-maintainer, non-bot roster the renderer uses for the community table.previews— per-preview/RC buckets, when present. Each carries akey, the humanlabel,date,changelog_url, and itsprs. Thekeyis the milestone's real git tag name (e.g.v4.150.0-rc.1.1,v3.118.0-preview.1.1), used verbatim incl. the leadingv. git guarantees tag names are globally unique, so a key never collides across rolled-up lines, and downstream tooling can map a preview straight to its exact milestone/release with no key-parsing. It is also the stable handle the prose file'spreview_summariesmaps a summary onto, so it MUST be unique within a page.release-notes-data.pyraises if two buckets ever produce the same key, andrelease-notes-render.pyvalidates one summary per preview (not per unique key), so a collision can never silently ship a shared or missing summary. (A handful of very old committed pages predate git-tagging their previews and therefore keep a synthetic<core>-<stage><num>key such as1.49.1-p1; new pages always use the real tag.)talliesandbreaking_candidates— companion source paths and hashes the AI reads for breaking-change prose (§4.7).
Companion content is referenced, never embedded. release-notes-data.py records only
page-relative paths and sha256 values for the manual notes sidecar and breaking
API-diff files, so the data sidecar stays small and a companion edit flips the
whole-file change key (§4.6).
| Owner | Responsibility |
|---|---|
| Scripts | Everything structural and deterministic: every filename, diff range, released-vs-unreleased split, rollup-vs-delta, supersession banner, stale-page pruning, every link (including both API-diff links and the HarfBuzz section placement), every heading, table, banner shape, @handle, ❤️, PR link, TOC.yml, and index.md. release-notes-data.py computes facts; release-notes-index.py computes network index data; release-notes-render.py assembles Markdown and validates prose caps. |
| AI / skill | Only writes _sources/<stem>.prose.json: theme, Highlights prose, breaking summaries, category bullets, contributor summaries, preview summaries, and harfbuzz_summary when required. It may read (never write) the companion files named in data JSON — _sources/<stem>.notes.md and *.breaking.md — and summarize them into prose (§4.7). On any anomaly (a missing/unexpected page, data that looks wrong, a renderer validation error it cannot fix) 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 for the prose contract. The renderer is the
checklist: per-page render mode owns page layout and mechanically rejects violations
such as a missing theme, Highlights over the word cap, an unrecognized category
heading, a missing contributor summary, a missing preview summary, or a missing
harfbuzz_summary when the data says HarfBuzz-specific PRs shipped.
The scripts decide what pages exist and how they link; these principles govern the
prose fields the AI fills. They exist so the notes read like a product changelog,
not a repository activity log. The operational detail lives in SKILL.md; the
principles are fixed here.
-
Write for the consumer, not the contributor — product over project. The test for every PR is whether the shipped SkiaSharp/HarfBuzzSharp library — its public API, runtime behavior, native binary, or NuGet package — changed for a consumer. Prepare makes this deterministic:
release-notes-data.pytags every PR indata.jsonasproduct,mixed, orinternalby the files it changed. The path→tag mapping is not hardcoded in the script — it lives inscripts/infra/docs/release-notes-paths.json, the single deterministic place to edit it. It is a short list of ordered tiers (each atag+patterns) plus adefault; the first tier with a matching file wins. A pattern matches by prefix (str.startswith), or as a glob if it contains* ? [.product— touches shipped code with a real API / behaviour / native change:binding/+source/(managed API & Views) andexternals/skia(the native Skia submodule, with its vendored HarfBuzz). Written up.mixed— affects the shipped package but is not itself an API/behaviour change, so Polish judges from the title:native/(per-platform build config — compile flags/gn args that shape the native binaries, usually infra) anddocs(the mdoc API-docs submodule that ships as IntelliSense XML — doc content, not behaviour).internal— thedefault: touches none of those (CI, workflows, agent skills, docs site, tests, samples, build/meta, and theexternals/depot_toolsbuild-toolchain submodule). Dropped into the one collapse line.
native/shapes the shipped binaries anddocsships as doc XML, so neither isinternal; but neither is a direct API/behaviour change, so both aremixed(inspected from the title) rather than firmproduct.docsandexternals/skiaare submodules, so in the parent repo they appear as bare gitlink paths (docs,externals/skia) and the prefixes match those exactly — theexternals/skiaprefix is deliberately not justexternals/, which would sweep in the siblingexternals/depot_toolsbuild-toolchain submodule (internal) andexternals/.gitignore; thedocsprefix is slash-less so it hits the gitlink without colliding withdocumentation/. Polish dropsinternal, writes upproduct, and inspectsmixed; moving the classification out of the LLM (and into the JSON) makes product-focus reliable run-to-run. -
Highlights are a hook, not a summary. The
## Highlightssection always exists and is assembled byrelease-notes-render.py. The prose targets ~80 words and is hard-capped at 100 words total acrosshighlights_headline+highlights_body, naming only the biggest items — the engine jump, headline feature, or breaking changes to review. It never enumerates every API, dependency bump, fix, or contributor. -
Attribution is linked, and the maintainer is not credited. The renderer turns PR numbers into links, emits community ❤️ credits on inline bullets, and renders the
## Community Contributors ❤️table from thecontributorsroster. The AI supplies only short summaries keyed by roster login. Bot accounts (github-actions[bot],Copilot,dependabot, any*[bot]) are excluded from the roster and never credited. -
Categories are closed.
release-notes-render.pyaccepts only the headings listed inSKILL.md(Engine,API Surface,Bug Fixes,Lifecycle & Internals,Platform,Security) and renders them in that order. The AI chooses which apply; it does not invent headings. -
HarfBuzz prose is summary-only. When
data.harfbuzz.prsis non-empty, the AI writes one shortharfbuzz_summaryparagraph. It does not dump every HarfBuzz PR: the API-diff link is in the banner, and community credit is in the page's contributor table. Whendata.harfbuzz.prsis empty, the AI sets the field tonull/omits it and the renderer emits the fixed no-changes line (§4.5).
The API-diff links are required, fixed, and non-AI. Every emitted released
SkiaSharp page whose line has an API-diff folder carries the script-owned
> **API changes** · … line in its structural header (above the prose). Each banner
row is its own blockquote, separated by a blank line, so Markdown renders the rows as
stacked blocks rather than one lazy-continuation paragraph.
- On a released SkiaSharp page it points at this line's
<line>/index.md(§3.3) and, whendata.harfbuzzis present, at the co-shipped HarfBuzzSharp API diffharfbuzzsharp/<hb-line>/index.md(§3.4):[SkiaSharp API diff](<line>/index.md) · [HarfBuzzSharp API diff](harfbuzzsharp/<hb-line>/index.md). - A page with no API-diff folder — typically an
-unreleasedhead delta, whose commits are not yet in any published line's folder — carries no such line; the folder's presence drives the SkiaSharp link, and the co-release map drives the HarfBuzz link for released pages. The AI does not decide whether to include it, where to put it, how to word it, or what it links to.
HarfBuzzSharp release notes are not a separate pass and not separate pages.
release-notes-data.py folds them into each released SkiaSharp page as deterministic data, and
release-notes-render.py turns that data into one section.
For a released SkiaSharp page, _write_page looks up the page's SkiaSharp line in
releases/_sources/co-release-map.json (§3.6). When it finds an entry, it writes:
"harfbuzz": {
"version": "<hb-line>",
"api_diff_link": "harfbuzzsharp/<hb-line>/index.md",
"prs": [1234, 5678]
}The prs list is the page's own SkiaSharp git window filtered to HarfBuzz-owned paths
(§1.5). Those PR numbers refer back into the same data.prs map as the rest of the
page; the section does not have its own PR records.
harfbuzz is absent from -unreleased head pages. A head page has no co-shipped
HarfBuzzSharp version yet, even if Cake recorded the working-tree dependency in the
co-release map for the next released page.
When data.harfbuzz.version exists, release-notes-render.py emits:
## HarfBuzzSharp <hb-line>Then it renders exactly one paragraph:
- If
data.harfbuzz.prsis non-empty, the paragraph is the AI-authoredprose.harfbuzz_summary. The prose schema allowsharfbuzz_summaryto be a string ornull, and validation requires a non-empty string only in this case. - If
data.harfbuzz.prsis empty, the renderer emits the fixed line: No HarfBuzzSharp binding changes shipped in this release — it rebuilds the same HarfBuzz as the previous line.
This section is intentionally summary-only. It does not dump each HarfBuzz PR, does
not duplicate the API-diff link, and does not render a separate contributor table. The
HarfBuzz version and API-diff link are already in the page banner's API changes line
(§4.4), and community credit remains in the page's single ## Community Contributors ❤️
table.
cmd_generate has no HarfBuzz family pass. It iterates SkiaSharp branches only, so a
version-scoped run (--min-version / --max-version) covers HarfBuzz automatically for
those SkiaSharp pages. The old helpers that inverted the co-release map into a
HarfBuzz page set were removed; the map is now only an input to the SkiaSharp page data.
release-notes-render.py --all retires old standalone HarfBuzz hub pages under
releases/harfbuzzsharp/ and deletes their retired _sources JSON/prose inputs. It
keeps the Cake-generated releases/harfbuzzsharp/<hb-line>/ API-diff folders, because
SkiaSharp pages still link them.
Always the same incremental Prepare + offline Polish sequence:
- Prepare fetches refs and regenerates deterministic inputs.
prepare.shruns Cake API diffs, thenrelease-notes-data.py, thenrelease-notes-index.py(§2.2). It accepts only--force,--min-version, and--max-version; those flags are passed through to Cake andrelease-notes-data.py, whilerelease-notes-index.pyalways refreshes its repo-global index data.release-notes-data.pyfetchesmain+ everyrelease/*, regenerates each selected SkiaSharp line's data JSON (§4.3), folds in the HarfBuzz block for released pages (§4.5), and writes only pages whose wholedata.jsondict changed. When a page'sdata.jsonchanges,release-notes-data.pyalso DELETES that page's_sources/<stem>.prose.json(the human-owned<stem>.notes.mdsidecar is left in place): the facts moved, so the committed prose is stale by definition and there must be nothing for Polish to keep or judge as "still matching" (§4.6 point 4). This is scoped to a genuine data change — a bare--forcere-render does not discard prose.release-notes-index.pywrites timestamp-free_sources/index.json(the Chrome schedule + thelive_unreleasedset thatrelease-notes-render.py --allprunes against, §4.2). data.jsonis the release-notes change-detection key. It has no timestamp, so an identical run yields an identical dict and the page is skipped. Any change to the PR map, diff range, preview buckets, contributor roster, supersession metadata, API-link facts, the folded HarfBuzz facts, or a companion's foldedsha256changes the dict and lists the page inoutput/files-to-polish.txt. Editing_sources/<stem>.notes.mdor changing a*.breaking.mdfile flips the relevantbreaking_candidates[].sha256and re-polishes exactly that page (§4.7). The full non-breaking API diff is deliberately not folder-hashed — its change signal is already carried by the PR set and the API-changes link.- The
formatfield rolls out data-shape changes. The content key detects data changes but not a schema/instruction change that leaves the facts identical. Theformatfield is a single integer stamped into every data sidecar and compared as part of the dict. Bump_DATA_JSON_FORMAT_VERSIONinrelease-notes-data.pywhenever the data JSON shape or its Polish contract changes materially; a forced run rewrites the pages to the new format, then the pipeline settles back to idempotent. - Polish writes prose and renders offline. The Files-to-polish list names only
genuinely changed pages, and each of them has had its
prose.jsondeleted by Prepare (point 1), so the AI re-authors that page from scratch against the freshdata.json— it never inspects an old prose file to decide whether it "still matches" (that soft judgment silently dropped brand-new product PRs). After the AI writes each_sources/<stem>.prose.jsonand validates the page withrelease-notes-render.py <data.json> <prose.json> [out.md], it runsrender.sh(or the equivalentrelease-notes-render.py --all) once to regenerate every SkiaSharp page, retire old HarfBuzz hub pages,TOC.yml, andindex.mdfrom committed JSON.--allhard-fails if anydata.jsonhas no matchingprose.json— i.e. a changed/new page the AI forgot to author — so a changed page can never ship with stale (or missing) prose. When Prepare's patch is empty, the workflow skips Polish and opens no PR (§2.3).
A ranged run (--min-version / --max-version) is scoped by SkiaSharp version core
and therefore includes HarfBuzz automatically through the selected pages' harfbuzz
blocks; there is no separate HarfBuzz range or skipped HarfBuzz family.
This section ties together the two problems that motivate the companion model: how a
breaking change reaches the notes, and how a maintainer injects hand-written
material that survives re-polish. Both are solved the same way — data.json
references companion files by path, and the Polish AI reads and summarizes
them into prose.json. Content is referenced, never embedded.
For a given SkiaSharp release page, release-notes-data.py records whichever of these exist:
| Companion source | Path (page-relative) | Owner | Present when |
|---|---|---|---|
| Manual additions (§3.7) | _sources/<stem>.notes.md |
maintainer (freeform md) | a human wrote one |
| API breaking diff (§3.3) | <line>/<pkg>/<assembly>.breaking.md |
Cake | real breaking changes exist (Cake deletes an empty one, §5.2) |
The full SkiaSharp API diff folder and the co-shipped HarfBuzzSharp API diff folder are
already linked from the page (§4.4). What §4.7 adds is: (a) the maintainer-authored
manual additions companion (§3.7); (b) teaching the Polish AI to open and summarize
the manual notes and every breaking diff named by data.json; (c) hashing those
companions into data.json (§4.6) so a companion-only edit re-polishes exactly that
page.
Because .breaking.md exists only when real breaking changes exist (§5.2), its
mere presence in breaking_candidates is the signal that this line broke something,
and its ### member headings are the changed/removed list the AI summarizes from.
For each present companion, release-notes-data.py writes an entry into
breaking_candidates[]:
{"source": "notes-sidecar", "path": "_sources/<stem>.notes.md", "sha256": …}for the manual additions sidecar.{"source": "api-breaking-diff", "path": "<line>/<pkg>/<assembly>.breaking.md", "sha256": …}for each breaking diff file.
The breaking-diff sha256 is a combined, order-stable hash over every breaking file
for that line. Every path keeps its package folder prefix, so two assemblies that
share a file name (e.g. SkiaSharp.Views.Windows.breaking.md under both
SkiaSharp.Views.Uno.WinUI/ and SkiaSharp.Views.WinUI/) stay distinct.
release-notes-data.py never inlines companion content — only the page-relative path and a
sha256 of the bytes. This keeps the sidecar small even when a breaking diff is
large, and removes any need to escape Markdown content inside JSON.
The AI reads the page's data sidecar, then opens and reads each companion named in
breaking_candidates[] and writes a short summary, not a dump. Four rules cover
it:
- Manual notes (
_sources/*.notes.md) — merge neatly. Read it and fold its points into the prose: editorial "bring this out" notes into Highlights (kept close to the maintainer's wording where it matters), behavioral and interop breaks under Breaking Changes. This sidecar is the only channel for breaks the signature diff cannot see — behavioral changes (same signature, different runtime behavior) and interop / native structs. - Breaking diff (
.breaking.md) — summarize as a few bullets. Open every file the data sidecar lists (one per broken assembly) and write a handful ofbreakingentries naming the affected types/areas, with a small migration example where it helps. Readers follow the API-diff link (already on the page, §4.4) for the exhaustive member list. - Summarize, don't transcribe. Never paste a diff or list every member; a short human summary is the goal.
- Always render the section.
release-notes-render.pyalways emits## Breaking Changes; whenprose.breakingis empty, it renders "None in this release." (or preview wording for preview/unreleased pages).
The AI still writes no links or structure (§4.4), never edits a companion, and
never touches git or the gh API. Richer highlights may draw on the full API-diff link
for context, but the PR list and companion paths in data.json remain the primary
sources for "what's new" and "what broke".
Reading a companion does not by itself change the page; deterministic re-polish is
preserved by the hashes in data.json (§4.6). Editing _sources/<stem>.notes.md
flips the notes-sidecar candidate hash; a breaking change appearing, disappearing,
or changing flips the api-breaking-diff candidate hash. Either re-polishes only
the affected page. The full non-breaking API diff is deliberately not
folder-hashed (§4.6): its change signal is already carried by the PR set and the
api_links entry, so a routine diff refresh does not force a spurious re-polish.
There is exactly one API-diff target: docs-api-diff. It is the committed
release-notes/API-diff engine that reads the published NuGet feed and writes the
releases/<line>/ and releases/harfbuzzsharp/<hb-line>/ trees. It is incremental
and scoped; it is also the Cake step that prepare.sh runs before release-notes-data.py.
This target, defined in build.cake, is the shared Path 1 contract and uses
RunCake to isolate the heavy API-diff addins in
scripts/infra/docs/api-diff.cake.
The former unpublished-package validation path was removed. The build/test pipeline no
longer runs an API-diff stage, and build.cake exposes only this single target.
- Inputs: every published version of each package in
TRACKED_NUGETS(defined inscripts/infra/shared/shared.cake; add a package by adding it there) from nuget.org, prereleases included, diffed withMono.ApiTools.NuGetDiff;versions.json; and — for the co-release map's in-flight entry (§3.6) — the working-treescripts/VERSIONS.txtSkiaSharp/HarfBuzzSharp versions, so the next released SkiaSharp page can carry the right HarfBuzzSharp version (§4.5) before that package is published. - Outputs: the API-diff trees defined in §3.3 / §3.4, per-line
index.mdlanding pages, transient log copies underoutput/logs/api-diffs/, and the §1.5 co-release map sidecar underreleases/_sources/co-release-map.json(§3.6) forrelease-notes-data.py.
The target applies the §1 model per API-diff package bucket (§1.5): collapse each bucket'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.
For every line it actually rebuilds, the engine runs the standard two-pass diff:
breaking-only first, then full/non-breaking, copying the resulting Markdown into the
package-namespaced line folder (§3.3/§3.4). Empty breaking diffs are deleted; the
per-line index.md is regenerated from the folder contents and marks assemblies with a
*.breaking.md sibling as breaking.
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.)
docs-api-diff mirrors release-notes-data.py's operational controls. It accepts Cake
arguments --force=true, --minVersion, and --maxVersion (Cake's --force is a
boolean argument); prepare.sh translates its shell flags to those names.
- Default incremental run. If a line's API-diff folder already has an
index.md, the target skips the diff work for that line. A shipped version's public API diff is immutable, so the committed folder is a cache. Missing folders are computed. - Scoped run.
--minVersion/--maxVersionrestrict which line cores are rebuilt, inclusive. Out-of-range lines are skipped as outputs but remain in the full emit list for baseline resolution, so a selected line can still roll up past a skipped/out-of-range predecessor correctly. - Forced line rebuild.
--forcerebuilds selected lines even when their folder already exists. - Full forced rebuild.
--forcewith no version scope is authoritative: it clears all owned generated API-diff files up front (respecting the history floor) and rebuilds the whole back-catalogue. This is the only way to force old committed API diffs to regenerate after the diff tooling itself changes. - Incremental/scoped clearing. Any run that is not a full forced rebuild leaves
cached lines untouched. For each line it does rebuild, it clears that line's generated
files immediately before copying the new diff, so stale
*.breaking.mdfiles cannot survive while unrelated cached lines remain intact.
The target clears only generated API-diff files as defined in §3.5 — files whose
first line starts with # API diff: and that are not retired *.humanreadable.md files.
Human release pages, TOC.yml, root index.md, maintainer sidecars, and hand-authored
files nested in old line folders are preserved.
The co-release map uses the same incremental principle (§3.6): Cake loads the committed object, overlays the SkiaSharp→HarfBuzzSharp entries discovered during this run, and writes the merged object back. A scoped run therefore does not drop mappings for lines it did not process; a full forced run recomputes and overlays the whole catalogue.
Path 1's shared entry point is the Cake target itself:
dotnet cake --target=docs-api-diff --nugetDiffPrerelease=true [cake args...]--nugetDiffPrerelease=true is required so preview/RC packages are available as
representatives for active lines; emission is still collapsed to one folder per line
inside the target. For the unified release-notes workflow, prepare.sh runs
dotnet tool restore, invokes this Cake target directly as its first step, and
forwards the shared flags as Cake arguments. The Docker api-diffs subcommand does
the same target invocation for local reproducibility:
.agents/skills/release-notes/scripts/prepare.sh --force --min-version 4.148.0 --max-version 4.150.0
# forwards to Cake as: --force=true --minVersion=4.148.0 --maxVersion=4.150.0There 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=1 run
when you want to re-prove that equivalence.
Everything in §1 — line collapsing, the previous-emitted-line baseline,
compare_to/supersession overrides, and which SkiaSharp lines emit — is shared by both
engines. The API-diff engine additionally applies the same bucket rules to
HarfBuzzSharp-versioned packages, whose release notes are folded into SkiaSharp pages.
They differ only here:
| Concern | Release notes | API diffs |
|---|---|---|
| Generator | release-notes-data.py + release-notes-index.py JSON, release-notes-render.py Markdown (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 — writes prose JSON only; renderer owns Markdown (§4.4) | no |
| HarfBuzzSharp | folded ## HarfBuzzSharp X.Y.Z section on each released SkiaSharp page; no hub pages/TOC node (§4.5) |
API-diff folders under harfbuzzsharp/<hb-line>/ (§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 SkiaSharp
release page links straight to its SkiaSharp API diff and, when co-shipped, its
HarfBuzzSharp API diff.
- Spec first. Change this document, then make the code implement it.
- Define paths once. §3 is the only place that defines filesystem paths. Engine sections reference it; never restate a divergent path elsewhere.
- Keep the two engines aligned on §1. If you change a shared rule, change it in
both the release-notes scripts (
release-notes-data.py/release-notes-render.pyas applicable) andapi-diff.cake, and re-state it here.
-
No mode toggles. These are agent/CI tools with one uniform control surface (
--force,--min-version,--max-versionon the shell orchestrators; Cake's camel-case equivalents underneath). They always run the same Prepare/Polish sequence and rely on incremental engines to skip safe work. Don't add convenience modes or per-item switches. -
versions.jsonis the only override surface (§1.2). No new auto-detection magic. -
Respect ownership (§3.5). Each producer clears only the paths it owns;
release-notes-data.pyandrelease-notes-index.pyown JSON,release-notes-render.pyowns all Markdown and links, and the AI only writes prose JSON (§4.4). Neither engine edits the other's files. -
Prove no regression. Both engines 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.txtlist), 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:
- 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 byStageSelfDepsFromNuspecAsyncand removed afterwards.
The comparer runs with
IgnoreResolutionErrors = truesolely 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 realAddDep/AddPackageDir, never treat the churn as a real API change. - third-party references by a version pinned in
-
Support tiers are config-driven (§3.5). The TOC/index support grouping comes only from the
supportblock inversions.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 reportsok/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. -
Companion files are referenced, never embedded (§4.7). A hub page's
data.jsonrecords each present companion (manual additions sidecar §3.7, breaking diff §3.3) inbreaking_candidates[]as a page-relative path +sha256, never inlined content. Those hashes join the content key (§4.6) so a companion-only edit re-polishes exactly that page; the full non-breaking diff is linked but not folder-hashed. The_sources/*.notes.mdsidecar is a maintainer-owned freeform-Markdown input: never machine-written, renamed, or cleared; docfx-excluded; skipped by page discovery. The Polish AI may read the referenced companions (a bounded allow-list) and summarize them, but writes only prose JSON — never a companion, never git/gh (§2.2/§4.4).