documentation/dev/release-notes-and-api-diffs.md
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-data.py + release-notes-index.py), AI-authored prose JSON, and the
offline release-notes-render.py renderer.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.
versions.json — the only override surfacereleases/ tree at a glancerelease-notes-data.py, release-notes-index.py, release-notes-render.py)
api-diff.cake)
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.
versions.json — the only override surfacescripts/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:
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):
{
"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.
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.
A line is emitted (gets its own artifact) when any of the following holds — and not otherwise:
versions.json — an intentional line the team tracks (e.g. a
status: "superseded" line like 4.147.0, or any line carrying a compare_to).4.148, 4.150).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:
releases/ root (§3.2).data.harfbuzz and
renders it as a ## HarfBuzzSharp X.Y.Z section (§4.5).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:
docs-api-diff → api-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.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).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.
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.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().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)._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.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.
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.
releases/ tree at a glancedocumentation/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:
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).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.<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._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).
One folder per emitted line, named by the §1.1 core, holding package-namespaced, per-assembly diffs:
releases/<line>/<package>/<assembly>.md — full public-API diff.releases/<line>/<package>/<assembly>.breaking.md — breaking-only sibling.Rules:
SkiaSharp.Views.Windows from both SkiaSharp.Views.WinUI
and SkiaSharp.Views.Uno.WinUI — never collide.releases/3.119.0/SkiaSharp.Views/ holds Android/iOS/Mac/Tizen/tvOS).Each folder also holds a generated index.md — the line's diff landing page and
the target of the hub page's API-changes link (§4.4). It is deterministic and
script-owned: it lists every <package>/<assembly>.md in the folder (flagging any with
a <assembly>.breaking.md sibling as ⚠️ breaking) and links back to the ../<line>.md
hub. It carries the # API diff: marker like every other generated file, so the §3.5
wipe regenerates it each run. Linking the hub at <line>/index.md (not the bare folder)
gives docfx a concrete page to resolve, so the link never dangles.
The 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.
releases/harfbuzzsharp/<hb-line>/<package>/<assembly>.md (+ .breaking.md), using
the same package-namespaced, per-assembly shape as §3.3.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).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.
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:
# API diff: marker. This distinguishes a generated
<assembly>.md from a hand-authored file shaped like one (e.g.
1.68.0/SkiaSharp/gpu-migration.md), which a plain *.md glob could not.*.humanreadable.md file. This leaves the retired legacy
*.humanreadable.md format untouched (and treats all such files consistently
regardless of whether they happen to carry the marker).So hand-authored files nested inside a <line>/ folder are preserved, and the
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.
support block)Both TOC.yml and index.md are organised by a support tier so the navigation
leads with what is actually supported. SkiaSharp ships NuGet packages on two supported
paths (it is not a multi-tier channel product), so the tiers are:
stable line(s) and the preview line(s). These render as the
top-level Version X.Y.x nodes in the TOC and under the Supported versions heading
in index.md (each tagged Stable or Preview). In index.md they are also
summarised at the very top by a Support overview block — a short lifecycle legend
(stable / preview / out of support / obsolete) plus a "currently supported" table listing
each supported line and a link to its latest release — so the page opens with what to use
at a glance. The overview is emitted only when a support block is configured.<details> block in index.md.<details> block in index.md.The tier of a line is not inferred from git or NuGet — it is read from the
top-level support block in versions.json (a sibling of the skiasharp /
harfbuzzsharp 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
a preview line is about to go stable).preview — the in-flight preview/RC line(s): normally the Chrome Beta milestone,
or newer when previewing ahead in Dev/Canary.Either field may be a single string or a list. An absent or empty block makes every 3.x+ line render as top-level/supported (the legacy flat layout), so the feature is purely additive.
Maintained by hand, on purpose (do not auto-sync). The values correspond to Chrome's channels, but the block is a human-curated grouping for the docs site, not a mirror of the live channels — so it is edited by hand and must not be auto-derived from Chromium Dash. The reason is that SkiaSharp does not ship every Chrome milestone: if we skip a bump, blindly copying the live Chrome milestones in would point a tier at a milestone that has no SkiaSharp release line, and the page would then show the actually-released lines as out of support (worst case: "everything is unsupported"). A maintainer therefore sets each list to milestones we actually released. Automating this is only safe once the Skia-update pipeline is fully automated and auto-merging every milestone (so "skipped bump" can't happen); until then, treat these two lists as a manual editorial decision.
Drift is detected, not auto-fixed. The security audit's milestone heads-up
(.agents/skills/security-audit/scripts/query-milestone-schedule.py, Step 3) already
fetches the live Chrome channels, so it also compares this block to them and emits an
ok / warn / drift verdict in its support output section. With E ≤ S ≤ B = Chrome
Extended/Stable/Beta and stable*/preview* = our newest stable/preview milestone:
| Condition | Verdict |
|---|---|
stable* = S | 🟢 ok — current stable |
stable* = E (E<S) and preview* ≥ S | 🟢 ok — promotion gap |
stable* = E (E<S) and preview* < S | 🔴 drift — stuck on extended, nothing promoting |
stable* < E, between E and S, or any stable entry off {E,S} | 🔴 drift — out of date / off-channel |
stable* > S | 🟡 warn — ahead of Chrome stable, verify |
preview* = B or preview* > B | 🟢 ok — tracks beta / ahead in Dev/Canary |
S < preview* < B | 🟡 warn — trails beta, update soon |
preview* ≤ S | 🔴 drift — not a real preview |
preview empty | 🟡 warn — no preview documented |
A drift verdict is an audit finding; the fix is always a manual edit of this block
(never auto-written — see the manual-by-design note above).
releases/_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"
}
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.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.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.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 consumes itrelease-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.
_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.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)._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.
release-notes-data.py, release-notes-index.py, release-notes-render.py)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.
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.
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).
| 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.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.
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.
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.
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.
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).
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.
<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).-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.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:
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.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.
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.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).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.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.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.
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.
breaking_candidates listFor 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:
_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.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.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".
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.
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.
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.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.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.
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.--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.--force rebuilds selected lines even when their folder
already exists.--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.*.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.
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.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).
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.
release-notes-data.py / release-notes-render.py as applicable)
and api-diff.cake, and re-state it here.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.
versions.json is 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.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.
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.txt list), never a shared runner or stdout capture (§2.3).
API-diff output is host-independent (§5.2). Every assembly reference is satisfied by a real, host-independent dependency, so resolution is contemporaneous and never depends on the build host or cache state:
scripts/VERSIONS.txt
(AddDep/AddPackageDir); and.nuspec, staged per-diff by StageSelfDepsFromNuspecAsync and
removed afterwards.The comparer runs with IgnoreResolutionErrors = true solely to skip the single
reference that ships in no package — _Microsoft.Android.Resource.Designer, generated into
every .NET-Android assembly and absent from SkiaSharp's public surface, hence unresolvable
identically on every host. When a regeneration produces unexpected "New Type" churn, the
cause is almost always a newly-missing dependency: add it as a real AddDep/AddPackageDir,
never treat the churn as a real API change.
Support tiers are config-driven (§3.5). The TOC/index support grouping comes
only from the support block in versions.json — never inferred from git tags or
NuGet. It is two hand-maintained lists (stable + preview) of milestone lines
SkiaSharp actually ships, edited on each release. The security audit's milestone
heads-up (Step 3) drift-checks them against the live Chrome channels and reports
ok/warn/drift — detection only; the fix is a manual edit, never auto-written.
An absent/empty block degrades to the legacy "every 3.x+ line is top-level" layout,
so the grouping stays purely additive.
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).