Back to Skiasharp

Release notes & API diffs — behavior spec

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

4.151.093.2 KB
Original Source

Release notes & API diffs — behavior spec

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 offline release-notes-render.py renderer.
  • 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.


Contents

  • §1 — The shared versioning model (the rules)
    • §1.1 One artifact per release line, keyed by the version core
    • §1.2 versions.json — the only override surface
    • §1.3 Default comparison: the previous emitted line
    • §1.4 Which lines get an artifact (emission)
    • §1.5 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)
  • §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

1. The shared versioning model (the rules)

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

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

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

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

1.2 versions.json — the only override surface

scripts/infra/docs/versions.json is the single place that deviates from the defaults. It has 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 support block 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):

json
{
  "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_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) 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 inverse supersedes back-link on the successor. It does not by itself affect baseline selection; pair it with status: "superseded" to actually exclude the line. Chains are single-hop: each line points only at its immediate successor.

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

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

1.3 Default comparison: the previous emitted line

With no compare_to override, a line is diffed against the most recent line before it that was itself emitted (§1.4), skipping any status: "superseded" line (§1.2). Superseded/abandoned lines are therefore transparent: the next emitted line diffs past them and rolls their work up.

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

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

1.4 Which lines get an artifact (emission)

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

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

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.

1.5 HarfBuzzSharp co-ships inside SkiaSharp pages

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.harfbuzz and renders it as a ## HarfBuzzSharp X.Y.Z section (§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).

Package-version schemes still matter for API diffs

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.

HarfBuzz-specific content is a filtered subset of the SkiaSharp range

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:

json
"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.json
  • binding/IncludeNativeAssets.HarfBuzzSharp.targets
  • native/*/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.


2. Skill layout & orchestration

2.1 Where the engines live

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

2.2 Two phases: Prepare → Polish

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:

  1. Cake (docs-api-diffapi-diff.cake) incrementally regenerates the required API-diff folders under releases/ (§3.3/§3.4) from the published feed, and updates the §1.5 co-release map sidecar at releases/_sources/co-release-map.json (§3.6). Deterministic, no AI.
  2. release-notes-data.py regenerates 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's harfbuzz block (§4.5). It also writes the machine-readable Files to polish list to output/files-to-polish.txt (or --polish-list).
  3. release-notes-index.py writes the network-sourced aggregate data to releases/_sources/index.json (§3.5/§4.1) — the live Chrome schedule for the two milestones in flight, plus live_unreleased, the set of version cores whose -unreleased page 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 in release-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.

2.3 One workflow, one PR

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

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

Prepare runs as a standalone job; the agent only polishes. The Prepare phase (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).

2.4 The rendering pipeline

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:

  1. release-notes-data.py emits _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 and community flag), the authoritative contributor roster, per-preview buckets, breaking-change sources (*.breaking.md and _sources/<version>.notes.md), supersede/API links, tallies, family status, and — on released pages — the folded harfbuzz block (version, api_diff_link, and HarfBuzz-path PR numbers). It contains the shared low-level helpers, the page-set discovery helper get_version_files, and cadence_milestones().
  2. release-notes-index.py emits _sources/index.json — network-sourced aggregate data the offline render cannot recompute: the live Chrome schedule for the two milestones in flight, plus live_unreleased (the version cores whose -unreleased page is still a live head, from the remote branch list). It writes no Markdown and deletes nothing — the pruning it informs runs in release-notes-render.py --all (§4.2).
  3. The agent writes _sources/<version>.prose.json — prose only, against release-notes-schema/prose.schema.json: theme, highlights_headline/highlights_body, breaking, categories, contributor_summaries, preview_summaries, and harfbuzz_summary when data.harfbuzz.prs is non-empty. It never writes a heading, table, banner, handle, ❤️, or PR link.
  4. release-notes-render.py renders Markdown — per-page validation with release-notes-render.py <data.json> <prose.json> [out.md], and the final release-notes-render.py --all pass that prunes stale -unreleased pages (per index.json's live_unreleased), retires old HarfBuzz hub pages, then rebuilds every SkiaSharp page plus TOC.yml and index.md from 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.

3. Filesystem layout & naming (canonical)

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

3.1 The releases/ tree at a glance

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

3.2 Human pages (release-notes engine owns)

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 folded harfbuzz block and the page renders a ## HarfBuzzSharp X.Y.Z section (§4.5).
  • Unreleased page: releases/<line>-unreleased.md — the small head delta; omitted when empty (semantics in §4.2). It has no harfbuzz block because there is no co-shipped HarfBuzzSharp version until the SkiaSharp line is released.
  • The flat <line>.md name is kept deliberately: the same-named <line>/ API-diff folder (§3.3) coexists beside it, so adding API diffs requires no rename of existing pages and no TOC href churn.
  • Every page's inputs live one level down in the sibling _sources/ folder: _sources/<stem>.data.json from release-notes-data.py, _sources/<stem>.prose.json from the Polish AI, and optional _sources/<stem>.notes.md from a maintainer. Rendered pages stay at the root so page discovery remains a non-recursive *.md walk.

There are no HarfBuzzSharp human page paths. releases/harfbuzzsharp/ is reserved for Cake-generated API-diff folders (§3.4).

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

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

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

Rules:

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

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

3.4 The HarfBuzz API-diff tree

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.md API-diff landing through their own data.harfbuzz blocks; 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.

3.5 Ownership — who writes and clears what

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

PathOwnerCleared by
releases/<line>.mdrelease-notes-render.pypermanent once shipped stable — never pruned; release-notes-render.py --all only rewrites its content (§4.2)
releases/<line>-unreleased.mdrelease-notes-render.pyrelease-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.jsonrelease-notes-data.pywith the owning SkiaSharp page (release-notes-data.py empty deltas, release-notes-render.py --all stale -unreleased)
releases/_sources/<stem>.prose.jsonPolish AIwith the owning SkiaSharp page; otherwise never script-rewritten
releases/_sources/index.jsonrelease-notes-index.pyrelease-notes-index.py (rewritten each Prepare run)
releases/_sources/co-release-map.json (§3.6)CakeCake (merged/updated each Prepare run; a full forced run recomputes the whole map)
releases/TOC.yml, releases/index.mdrelease-notes-render.py --allrelease-notes-render.py --all (regenerated from the finished SkiaSharp page set)
releases/<line>/<package>/… (SkiaSharp API diffs)CakeCake (generated files only, §5.2)
releases/harfbuzzsharp/<hb-line>/… (HarfBuzzSharp API diffs)CakeCake (generated files only, §5.2)
releases/<line>/index.md, releases/harfbuzzsharp/<hb-line>/index.md (per-line diff landings, §3.3/§3.4)CakeCake (marker-managed; regenerated 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 artifactsrelease-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:

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

So hand-authored files nested inside a <line>/ folder are preserved, and the 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 / index representation

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.

Support tiers (the support block)

Both TOC.yml and index.md are organised by a support tier so the navigation leads with what is actually supported. SkiaSharp ships NuGet packages on two supported paths (it is not a multi-tier channel product), so the tiers are:

  • Supported — the stable line(s) and the preview line(s). These render as the top-level Version X.Y.x nodes in the TOC and under the Supported versions heading in index.md (each tagged Stable or Preview). In index.md they are also summarised at the very top by a Support overview block — a short lifecycle legend (stable / preview / out of support / obsolete) plus a "currently supported" table listing each supported line and a link to its latest release — so the page opens with what to use at a glance. The overview is emitted only when a support block is configured.
  • Out of support — every other 3.x+ line. These fold under a single Out of Support Versions TOC node and a collapsed <details> block in index.md.
  • Obsolete — the 1.x and 2.x lines, folded under the Obsolete Versions TOC node and a collapsed <details> block in index.md.

The tier of a line is not inferred from git or NuGet — it is read from the top-level support block in versions.json (a sibling of the skiasharp / harfbuzzsharp 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):

json
"support": {
  "stable": ["4.148"],
  "preview": ["4.150"]
}
  • stable — the supported stable line(s): normally the current Chrome Stable milestone, or the Chrome Extended-stable milestone during the promotion gap (when a preview line is about to go stable).
  • preview — the in-flight preview/RC line(s): normally the Chrome Beta milestone, or newer when previewing ahead in Dev/Canary.

Either field may be a single string or a list. An absent or empty block makes every 3.x+ line render as top-level/supported (the legacy flat layout), so the feature is purely additive.

Maintained by hand, on purpose (do not auto-sync). The values correspond to Chrome's channels, but the block is a human-curated grouping for the docs site, not a mirror of the live channels — so it is edited by hand and must not be auto-derived from Chromium Dash. The reason is that SkiaSharp does not ship every Chrome milestone: if we skip a bump, blindly copying the live Chrome milestones in would point a tier at a milestone that has no SkiaSharp release line, and the page would then show the actually-released lines as out of support (worst case: "everything is unsupported"). A maintainer therefore sets each list to milestones we actually released. Automating this is only safe once the Skia-update pipeline is fully automated and auto-merging every milestone (so "skipped bump" can't happen); until then, treat these two lists as a manual editorial decision.

Drift is detected, not auto-fixed. The security audit's milestone heads-up (.agents/skills/security-audit/scripts/query-milestone-schedule.py, Step 3) already fetches the live Chrome channels, so it also compares this block to them and emits an ok / warn / drift verdict in its support output section. With E ≤ S ≤ B = Chrome Extended/Stable/Beta and stable*/preview* = our newest stable/preview milestone:

ConditionVerdict
stable* = S🟢 ok — current stable
stable* = E (E<S) and preview* ≥ S🟢 ok — promotion gap
stable* = E (E<S) and preview* < S🔴 drift — stuck on extended, nothing promoting
stable* < E, between E and S, or any stable entry off {E,S}🔴 drift — out of date / off-channel
stable* > S🟡 warn — ahead of Chrome stable, verify
preview* = B or preview* > B🟢 ok — tracks beta / ahead in Dev/Canary
S < preview* < B🟡 warn — trails beta, update soon
preview* ≤ S🔴 drift — not a real preview
preview empty🟡 warn — no preview documented

A drift verdict is an audit finding; the fix is always a manual edit of this block (never auto-written — see the manual-by-design note above).

3.6 The co-release map sidecar (Cake → release-notes-data.py)

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.

Shape

The current format is a plain JSON object: one property per SkiaSharp line, whose value is the single HarfBuzzSharp line it ships.

json
{
  "3.119.0": "8.3.0",
  "4.148.0": "14.2.0"
}

Field meanings

  • 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 like 8.3.1.5 is preserved). release-notes-data.py copies it to data.harfbuzz.version.
  • The API-diff link is derived, not stored. release-notes-data.py builds data.harfbuzz.api_diff_link as harfbuzzsharp/<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.HarfBuzz package's (§5.2) HarfBuzzSharp dependency. 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.

Data source and merge behavior

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.

How release-notes-data.py consumes it

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.

3.7 The manual additions sidecar (maintainer → release-notes-data.py → Polish 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.

Ownership & lifecycle

  • 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.py records its page-relative path (_sources/<stem>.notes.md) plus a sha256 of its bytes in breaking_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.py never 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.md with no matching SkiaSharp page one directory up (neither <stem>.md nor <stem>-unreleased.md exists for its stem) is a maintainer typo; release-notes-data.py warns 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.


4. Release-notes pipeline (release-notes-data.py, release-notes-index.py, release-notes-render.py)

4.1 Inputs & outputs

The release-notes pipeline has three script-owned producers, split by artifact:

  • release-notes-data.py (Prepare, network-capable) — per-page facts. Inputs are git log over a diff range (merged-PR subjects … (#1234)), published v* 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.json for each changed SkiaSharp page and writes the Files-to-polish list to output/files-to-polish.txt (or --polish-list). On released pages it adds data.harfbuzz from 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 helper get_version_files, and cadence_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 only releases/_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 -unreleased page is still a live head (the set release-notes-render.py --all prunes 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, and index.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.

4.2 Released vs unreleased — two coexisting pages

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'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. Its H1 is Version X (Unreleased) so it is distinguishable from the released page of the same version core. It has no harfbuzz block 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.

4.3 Per-preview PR buckets and the data sidecar

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, and status.
  • banner, supersedes, superseded_by, and api_links — all script-owned link / banner facts.
  • harfbuzz on released pages — { "version", "api_diff_link", "prs" } for the co-shipped HarfBuzzSharp section (§4.5). It is absent on -unreleased pages.
  • prs — the flat PR map, including title, URL, author, community, and the deterministic tag (product, mixed, or internal). Each entry may also carry fixes — 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 the Fixes/Closes/Resolves #NNN keywords in the PR body (the offline fallback). Downstream post-release tooling reads fixes to 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 a key, the human label, date, changelog_url, and its prs. The key is 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 leading v. 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's preview_summaries maps a summary onto, so it MUST be unique within a page. release-notes-data.py raises if two buckets ever produce the same key, and release-notes-render.py validates 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 as 1.49.1-p1; new pages always use the real tag.)
  • tallies and breaking_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).

4.4 Division of responsibility — scripts structure, the AI writes prose JSON

OwnerResponsibility
ScriptsEverything 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 / skillOnly 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.

Prose principles (what the AI writes toward)

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.

  1. 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.py tags every PR in data.json as product, mixed, or internal by the files it changed. The path→tag mapping is not hardcoded in the script — it lives in scripts/infra/docs/release-notes-paths.json, the single deterministic place to edit it. It is a short list of ordered tiers (each a tag + patterns) plus a default; 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) and externals/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) and docs (the mdoc API-docs submodule that ships as IntelliSense XML — doc content, not behaviour).
    • internal — the default: touches none of those (CI, workflows, agent skills, docs site, tests, samples, build/meta, and the externals/depot_tools build-toolchain submodule). Dropped into the one collapse line.

    native/ shapes the shipped binaries and docs ships as doc XML, so neither is internal; but neither is a direct API/behaviour change, so both are mixed (inspected from the title) rather than firm product. docs and externals/skia are submodules, so in the parent repo they appear as bare gitlink paths (docs, externals/skia) and the prefixes match those exactly — the externals/skia prefix is deliberately not just externals/, which would sweep in the sibling externals/depot_tools build-toolchain submodule (internal) and externals/.gitignore; the docs prefix is slash-less so it hits the gitlink without colliding with documentation/. Polish drops internal, writes up product, and inspects mixed; moving the classification out of the LLM (and into the JSON) makes product-focus reliable run-to-run.

  2. Highlights are a hook, not a summary. The ## Highlights section always exists and is assembled by release-notes-render.py. The prose targets ~80 words and is hard-capped at 100 words total across highlights_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.

  3. 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 the contributors roster. 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.

  4. Categories are closed. release-notes-render.py accepts only the headings listed in SKILL.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.

  5. HarfBuzz prose is summary-only. When data.harfbuzz.prs is non-empty, the AI writes one short harfbuzz_summary paragraph. 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. When data.harfbuzz.prs is empty, the AI sets the field to null/omits it and the renderer emits the fixed no-changes line (§4.5).

API-diff link rule

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, when data.harfbuzz is present, at the co-shipped HarfBuzzSharp API diff harfbuzzsharp/<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 -unreleased head 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.

4.5 The HarfBuzz section on a SkiaSharp page

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.

Data shape

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:

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

Render shape

When data.harfbuzz.version exists, release-notes-render.py emits:

markdown
## HarfBuzzSharp <hb-line>

Then it renders exactly one paragraph:

  • If data.harfbuzz.prs is non-empty, the paragraph is the AI-authored prose.harfbuzz_summary. The prose schema allows harfbuzz_summary to be a string or null, and validation requires a non-empty string only in this case.
  • If data.harfbuzz.prs is 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.

Generation and retirement

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.

4.6 How it runs

Always the same incremental Prepare + offline Polish sequence:

  1. Prepare fetches refs and regenerates deterministic inputs. prepare.sh runs Cake API diffs, then release-notes-data.py, then release-notes-index.py (§2.2). It accepts only --force, --min-version, and --max-version; those flags are passed through to Cake and release-notes-data.py, while release-notes-index.py always refreshes its repo-global index data. release-notes-data.py fetches main + every release/*, 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 whole data.json dict changed. When a page's data.json changes, release-notes-data.py also DELETES that page's _sources/<stem>.prose.json (the human-owned <stem>.notes.md sidecar 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 --force re-render does not discard prose. release-notes-index.py writes timestamp-free _sources/index.json (the Chrome schedule + the live_unreleased set that release-notes-render.py --all prunes against, §4.2).
  2. data.json is 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 folded sha256 changes the dict and lists the page in output/files-to-polish.txt. Editing _sources/<stem>.notes.md or changing a *.breaking.md file flips the relevant breaking_candidates[].sha256 and 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.
  3. The format field rolls out data-shape changes. The content key detects data changes but not a schema/instruction change that leaves the facts identical. The format field is a single integer stamped into every data sidecar and compared as part of the dict. Bump _DATA_JSON_FORMAT_VERSION in release-notes-data.py whenever 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.
  4. Polish writes prose and renders offline. The Files-to-polish list names only genuinely changed pages, and each of them has had its prose.json deleted by Prepare (point 1), so the AI re-authors that page from scratch against the fresh data.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.json and validates the page with release-notes-render.py <data.json> <prose.json> [out.md], it runs render.sh (or the equivalent release-notes-render.py --all) once to regenerate every SkiaSharp page, retire old HarfBuzz hub pages, TOC.yml, and index.md from committed JSON. --all hard-fails if any data.json has no matching prose.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.

4.7 Manual additions & breaking-change summaries (companion files)

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.

The companion sources

For a given SkiaSharp release page, release-notes-data.py records whichever of these exist:

Companion sourcePath (page-relative)OwnerPresent when
Manual additions (§3.7)_sources/<stem>.notes.mdmaintainer (freeform md)a human wrote one
API breaking diff (§3.3)<line>/<pkg>/<assembly>.breaking.mdCakereal 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.

The breaking_candidates list

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.

What the Polish AI does with them

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:

  1. 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.
  2. Breaking diff (.breaking.md) — summarize as a few bullets. Open every file the data sidecar lists (one per broken assembly) and write a handful of breaking entries 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.
  3. Summarize, don't transcribe. Never paste a diff or list every member; a short human summary is the goal.
  4. Always render the section. release-notes-render.py always emits ## Breaking Changes; when prose.breaking is 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".

Idempotency

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.


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

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.

5.1 Inputs & outputs

  • Inputs: every published version of each package in TRACKED_NUGETS (defined in scripts/infra/shared/shared.cake; add a package by adding it there) from nuget.org, prereleases included, diffed with Mono.ApiTools.NuGetDiff; versions.json; and — for the co-release map's in-flight entry (§3.6) — the working-tree scripts/VERSIONS.txt SkiaSharp/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.md landing pages, transient log copies under output/logs/api-diffs/, and the §1.5 co-release map sidecar under releases/_sources/co-release-map.json (§3.6) for release-notes-data.py.

5.2 Behavior

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.

Deterministic reference resolution

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

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

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

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

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

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

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

5.3 Incremental, scoped, and clearing behavior

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 / --maxVersion restrict 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. --force rebuilds selected lines even when their folder already exists.
  • Full forced rebuild. --force with 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.md files 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.

5.4 How it runs

Path 1's shared entry point is the Cake target itself:

bash
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:

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

There is no AI polish on the diffs themselves — the generated diff is the artifact; the human pages merely link to it (§1.5/§4.4).

Package cache — cache once, run many

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


6. Differences at a glance

Everything in §1 — line collapsing, the previous-emitted-line baseline, compare_to/supersession overrides, 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:

ConcernRelease notesAPI diffs
Generatorrelease-notes-data.py + release-notes-index.py JSON, release-notes-render.py Markdown (Python)api-diff.cake (Cake)
Content diffedmerged PRs (git)public API (assemblies)
Released + unreleased splityes (two-file, §4.2)no (one set per line)
Per-preview bucketsyes (§4.3)no
AI post-processingyes — writes prose JSON only; renderer owns Markdown (§4.4)no
HarfBuzzSharpfolded ## 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.


7. Editing rules

7.1 Editing this spec

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

7.2 Behavioral invariants the code must uphold

  1. No mode toggles. These are agent/CI tools with one uniform control surface (--force, --min-version, --max-version on 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.

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

  3. Respect ownership (§3.5). Each producer clears only the paths it owns; release-notes-data.py and release-notes-index.py own JSON, release-notes-render.py owns all Markdown and links, and the AI only writes prose JSON (§4.4). Neither engine edits the other's files.

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

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

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

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

    The comparer runs with IgnoreResolutionErrors = true solely to skip the single reference that ships in no package — _Microsoft.Android.Resource.Designer, generated into every .NET-Android assembly and absent from SkiaSharp's public surface, hence unresolvable identically on every host. When a regeneration produces unexpected "New Type" churn, the cause is almost always a newly-missing dependency: add it as a real AddDep/AddPackageDir, never treat the churn as a real API change.

  7. Support tiers are config-driven (§3.5). The TOC/index support grouping comes only from the support block in versions.json — never inferred from git tags or NuGet. It is two hand-maintained lists (stable + preview) of milestone lines SkiaSharp actually ships, edited on each release. The security audit's milestone heads-up (Step 3) drift-checks them against the live Chrome channels and reports ok/warn/drift — detection only; the fix is a manual edit, never auto-written. An absent/empty block degrades to the legacy "every 3.x+ line is top-level" layout, so the grouping stays purely additive.

  8. Companion files are referenced, never embedded (§4.7). A hub page's data.json records each present companion (manual additions sidecar §3.7, breaking diff §3.3) in breaking_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.md sidecar 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).