# Release Notes

> Write the polished prose for a SkiaSharp release-notes page. Use whenever the release-notes workflow asks you to fill in a version's notes, when you see a `data.json` for a release under `documentation/docfx/releases/_sources/`, or when a user asks to draft, polish, or regenerate release notes / a changelog for a SkiaSharp version. You produce ONE small JSON file of prose (`prose.json`) — website page prose plus, per exact shipment tag, the reviewed GitHub Release summary; a deterministic renderer/updater builds the page and the Release body.

- Skill: `mono/release-notes` (Agent Skill, multi-file: 10 files)
- Install (CLI): `npx skillmds@latest add mono/release-notes`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mono/release-notes/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: mono (https://skillmd.com/u/mono)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mono/release-notes

---


# Release notes — writing the prose

You are writing the human prose for one release-notes page. **You do not build the
page.** A script (`release-notes-render.py`) owns every heading, table, banner, `@handle`,
❤️, and PR link. Your entire job is to fill a small set of prose *slots*, and the
renderer assembles the page from those plus the facts in `data.json`.

The renderer owns headings, handles, contributor lists, and links so those
structural elements remain consistent. Focus on turning the activity facts into
a changelog a **NuGet consumer** wants to read.

## The one test for everything you write

> Would a consumer notice this change without looking inside our repo?

If yes, write about it. If no (CI tweaks, internal refactors, doc/workflow
plumbing, test infra), leave it out — the renderer already collapses that noise.
`data.json` tags every PR `product` / `mixed` / `internal`; treat `internal` as
invisible unless it changed shipped behaviour, and for `mixed` (build config in
`native/`, or a `docs` API-docs bump) judge from the title.

## Running the full pipeline (prepare → write prose → render)

Producing release notes is three steps. Two are scripts you run; the middle one is
the writing this skill is about.

```
prepare.sh   →   (you write prose.json per page)   →   render.sh
 (network)              (this skill)                     (offline)
```

1. **`.agents/skills/release-notes/scripts/prepare.sh`** — regenerates the API diffs
   (Cake), the per-page `_sources/<version>.data.json` facts, and `_sources/index.json`,
   and writes the list of pages needing prose to `output/files-to-polish.txt`. **When a
   page's facts changed, Prepare DELETES that page's `prose.json`** so there is nothing
   stale to keep — every page on the list starts from a blank prose slate.
2. **You** read each listed page's `data.json` and write its `prose.json` from scratch
   (below). Each page in the list has **no `prose.json`** — do not go looking for an old
   one to "check if it still matches"; the facts moved (new/removed PRs, re-tags), so you
   author fresh. Cover every `product` PR you'd expect a consumer to notice — a page that
   silently drops a real change is the failure this design prevents.
3. **`.agents/skills/release-notes/scripts/render.sh`** — renders every page from
   `data.json` + `prose.json` and rebuilds `TOC.yml` + `index.md`. It **fails loudly** if
   any page on the list still lacks a `prose.json` (you missed one) or if prose is invalid.

Both scripts take the **same three flags** — `--force`, `--min-version`, `--max-version`
— and nothing else. Choose them from what was asked:
- "regenerate the release notes **for 4.151.0**" → `--min-version 4.151.0 --max-version 4.151.0`
- "regenerate the release notes" (everything) → no flags
- after changing the **api-diff tools or the page format** → add `--force` (rebuilds even
  cached api diffs / unchanged pages)

Everything is incremental: an unforced run skips work whose output already exists (a
shipped version's api diff never changes), so a routine run is cheap — there is no
"notes-only" mode to reach for.

**Running locally** (needs `dotnet`, `python3`, `git`, `gh`):
```bash
# one version, end to end
.agents/skills/release-notes/scripts/prepare.sh --min-version 4.151.0 --max-version 4.151.0
#   … you write documentation/docfx/releases/_sources/4.151.0.prose.json …
.agents/skills/release-notes/scripts/render.sh  --min-version 4.151.0 --max-version 4.151.0

# everything
.agents/skills/release-notes/scripts/prepare.sh
.agents/skills/release-notes/scripts/render.sh
```

**In CI** a separate `prepare` job runs step 1, and you (the agent) do steps 2 and 3 —
write each page's prose, then run `release-notes-render.py --all` to finalize (the workflow's
tool allowlist permits `python3` for exactly this).

## How to work

You are given a list of pages to write (in CI, `output/files-to-polish.txt`; one
`documentation/docfx/releases/<version>.md` path per line). The list **may be
empty** — that just means no page needs new prose this run, but you must still run
the final render (`render.sh`, or `release-notes-render.py --all` in CI) to materialize the
deterministic pages and rebuild the TOC/index; don't exit early. Every input for a
page lives in a `_sources/` folder beside it — for a page `releases/<version>.md`
the inputs are `releases/_sources/<version>.data.json`,
`releases/_sources/<version>.prose.json` (what you write), and an optional
`releases/_sources/<version>.notes.md`. HarfBuzzSharp is not a separate page — it
ships inside each SkiaSharp release, so it renders as a `## HarfBuzzSharp X.Y.Z`
section on the SkiaSharp page (see `harfbuzz_summary` below). For **each** page:

1. Read its `_sources/<version>.data.json`. It has:
   `prs` (title, author, community, tag), `previews` (each with its PR list),
   `contributors` (the authoritative roster), `breaking_candidates`, `tallies`,
   `shipments` (format 5+; the exact git tag(s) this page rolls up — a preview,
   an rc, and/or the stable release itself, see `release_summaries` below), and
   the banner/link facts.
2. Read every breaking source named in `breaking_candidates`, if present: the
   version's `*.breaking.md` API diffs and every referenced `_sources/*.notes.md`
   sidecar. A cumulative page may reference notes from a skipped preview-only line.
   These are your material for the `breaking` slot — API diffs give signature
   removals, while notes sidecars give *behavioural* breaks (same signature, new
   runtime behaviour) that no diff can detect.
3. Write `documentation/docfx/releases/_sources/<version>.prose.json`
   (schema: `scripts/infra/docs/release-notes-schema/prose.schema.json`).
4. Render the page:
   `python3 scripts/infra/docs/release-notes-render.py _sources/<version>.data.json _sources/<version>.prose.json <version>.md`
   (use the full `documentation/docfx/releases/` paths). If it prints
   `PROSE VALIDATION FAILED`, read the errors, fix that slot, and re-run. A clean
   render — the `.md` written — is the bar.

You never hand-edit the `.md`, `TOC.yml`, or `index.md`, and you never create,
rename, or delete pages — `release-notes-render.py --all` (which `render.sh` runs) owns page
creation and pruning. The per-page render above is just to validate your
prose as you go; **`render.sh` does the authoritative final pass** — it re-renders
every page and rebuilds `TOC.yml` + `index.md` from the committed JSON. Commit the
`_sources/<version>.prose.json` and the rendered `.md` together (the
`_sources/<version>.data.json` is already produced by the Prepare phase).

## The slots

Each slot below lists its purpose, the cap the renderer enforces, and one good +
one bad example. Caps are hard: the renderer rejects an over-long highlight, a
missing contributor, or an unknown category. Stay well under and you never see an
error. Where a slot is nullable or optional, the note says so — reach for `null`
rather than padding.

### `theme` — 2-6 words
What *this* release is about, shown bold in the banner. No punctuation.
- Good: `First stable v4 release`
- Bad: `Version 4.148.0` (that's the title, not a theme) · `Lots of fixes and new APIs` (vague)

### `highlights_headline` — one sentence, ≤20 words
The single most important thing about the release. **Not a list.** Decide it from
the `product`-tagged PRs, the Skia milestone bump, and whether there are breaking
changes — the one thing a consumer would care about most, in a sentence. You are
not summarising every PR here.
- Good: `SkiaSharp 4.148.0 is the first stable v4 release, built on Skia m148.`
- Bad: `This release adds WebP, SKStream.GetData, singleton lifecycle, pixel fixes, WinUI fixes, and more.` (enumeration)

### `highlights_body` — optional, ≤60 words, or `null`
Name the biggest themes to draw the reader in. Prose is best, but a short feature
list is fine for a big release — just keep the whole Highlights block (headline +
body) under ~100 words so it stays a lead-in, not the changelog. No PR links, no
`@handles`. If the headline already says enough, use `null`.
- Good: `It adds variable fonts and animated WebP, and reworks the singleton lifecycle. This is a breaking release — check the changes below before upgrading.`
- Bad: `Includes #4125, #3771, #3772, #4080, #4068 and fixes from @ramezgerges.` (links + handles, and it's just PR numbers, not themes)

### `breaking` — array, one entry per change a consumer must act on
Merge from two sources: signature removals in the `*.breaking.md` diff, and
behavioural breaks described in `breaking_candidates` / the notes sidecar. Empty
array is fine and renders "None in this release." Give each a `title`, a `body`
that says what changed **and what to do**, and the `prs` it came from. Only write
what you can substantiate: a `breaking_candidate` carries a `hint` and sometimes
`prs`, but when its companion file isn't on disk and it lists no concrete change,
fall back to the PR titles in `prs` you can actually read — never invent a removal
you can't point at.
- Good: `{"title": "SKPaint no longer exposes legacy text state", "body": "The paint text/font members obsoleted in v3 are now compile errors — move typeface and text size onto SKFont.", "prs": [4068, 4114]}`
- Bad: `{"title": "Refactoring", "body": "Various changes."}` (no action, not consumer-facing)

### `categories` — array of `{heading, bullets}`
The body of the page. **`heading` must be exactly one of these six** (the renderer
rejects anything else — this is the closed list, in the order they render):

| Heading | What belongs here |
|---|---|
| `Engine` | The Skia milestone bump and upstream engine syncs; bundled-engine changes a consumer would feel. |
| `API Surface` | New or changed public APIs — added types, methods, overloads, options. |
| `Bug Fixes` | Corrected behaviour, crashes, wrong output — even when platform-specific. |
| `Lifecycle & Internals` | Disposal, finalizers, initialization, singleton/handle lifecycle — consumer-visible runtime behaviour, not build plumbing. |
| `Platform` | Platform-**support** changes: a target added or dropped, new native assets, TFM realignment. |
| `Security` | Bundled native-dependency refreshes and security fixes. |

You choose which of the six to include — a section appears only when it has a real
product-facing bullet, and you may use as few as one. Prefer fewer, denser
sections over many one-bullet ones. **Curate, don't enumerate:** each bullet
MERGES related PRs into one product theme (aim 3-5 bullets per section on a big
release; 1-2 is perfectly fine on a servicing release — never merge distinct areas
just to hit a count), with a `lead` (bold summary) + `detail` (what it means for
the consumer) + the `prs`. The renderer adds the PR links and the ❤️ community
credit — never write those yourself. A change with a migration usually belongs in
`breaking`; don't also give it its own thin category section unless it has
independent product value. Placement rule of thumb: ordinary fixes go under **Bug
Fixes** even when platform-specific; use **Platform** only for platform-support
additions or removals.
- Good: `{"heading": "Bug Fixes", "bullets": [{"lead": "Pixel access corrected", "detail": "GetPixelSpan now uses RowBytes for stride and the right axis for offsets.", "prs": [4148, 4128]}]}` (two PRs → one theme)
- Bad: `{"heading": "Bugfixes", …}` (not one of the six) · one bullet per PR restating its title · a section that lists 20 internal PRs.

### `contributor_summaries` — one line per roster login
`data.contributors` is authoritative — every login there needs an entry (the
renderer fails otherwise) and no one else gets one. Summarise that person's work
in prose; the renderer adds their `@handle` and PR links. This is the one place
`internal` work is worth naming — a contributor's sample or CI work still deserves
credit even though it never became a category bullet.
- Good: `"ramezgerges": "Singleton lifecycle rework, the SKPath finalizer fix, and Uno sample updates"`
- Bad: `"ramezgerges": "#4080, #4068, #3796"` (that's data, not a summary)

### `preview_summaries` — one line per preview key
`data.previews` lists each preview/RC with the PRs that first shipped in it. Give
each `key` a 1-2 sentence summary of what that milestone delivered. When a preview
only carried internal work, describe the milestone itself (e.g. "opened the line"
or "cut the release candidate") rather than forcing a product story.
- Good: `"4.148.0-p2": "Preview 2 added animated WebP encoding and the SKPath finalizer fix."`
- Bad: leaving a preview key out (the renderer fails), or restating every PR.

### `harfbuzz_summary` — one short paragraph, or `null`
HarfBuzzSharp ships **inside** each SkiaSharp release, so its notes are a
`## HarfBuzzSharp X.Y.Z` section on this page, not a separate page. `data.harfbuzz`
gives the version and `prs` — the PRs in this release that touched the HarfBuzz
binding (a subset of the page's PRs, so you have already written about most of them
above). Summarise the HarfBuzz-facing story in 1-2 sentences; the renderer adds the
heading, the ❤️ credit and the PR links.
- Required only when `data.harfbuzz.prs` is non-empty. When it is empty the renderer
  writes "No HarfBuzzSharp binding changes shipped…" itself — set `harfbuzz_summary`
  to `null`. When `data.harfbuzz` is absent (e.g. an unreleased head), omit it.
- Good: `"Adds variable-font shaping and an HBColor value type, and refreshes the bundled HarfBuzz to 8.3.0."`
- Bad: re-listing every PR, or repeating the SkiaSharp highlights verbatim.

### `release_summaries` — optional, one entry per exact shipment tag

This slot writes for a **different reader and a different surface** than
everything above: instead of the website page, it converges the reviewed
**GitHub Release** summary for one exact tag. A separate deterministic
updater (`scripts/infra/docs/release_notes/update_github_summaries.py`) renders
each entry into the complete canonical body of that exact tag's GitHub Release.
The updater recreates the entire body from the reviewed prose and committed
exact-shipment facts: deterministic links, all human contributors, the
first-time-human subset, and substantiated automation/AI assistance. It never
emits a PR list, preserves old body text, or asks GitHub to generate a live
region, so an old generated body is safely replaced rather than preserved.
There is no release-critical deadline for it.

The visible body stays short: script-owned shipment label + reviewed headline,
optional reviewed body, one compact Release notes/NuGet/Full changelog line,
then optional `👥 Contributors`, `🎉 First-time contributors`, and
`🤖 Automation and AI assistance` lines. First-timers intentionally appear in
both human lines. There is never a `What's Changed` section or PR bullet list;
the website release notes are authoritative for detail.

`data.shipments` (format 5+, present only on a **released** page) lists every
exact tag this page rolls up — a preview, an rc, and/or the stable release
itself — each with its own `tag` (e.g. `"v4.151.0-preview.1.1"`), `label`
(e.g. `"Preview 1"`), and delta `prs` since the previous tag (globally, not
just this page's). Write one `release_summaries` entry per tag you have
enough to say something crisp about; **omit** a tag entirely rather than pad
it — an omitted tag is simply not converged yet, never an error.

Copy each key verbatim from `data.shipments`; prerelease keys include their
exact Arcade build revision.

Each entry is `{"headline": string, "body": string|null}`:

- `headline` — one plain-language sentence naming what this exact shipment is
  about. The updater prefixes it with the shipment's own script-owned label
  (`**Preview 1**`) and appends deterministic release-notes/NuGet/changelog
  links and exact-shipment attribution lines — never write a heading, link,
  contributor, or `@handle` yourself.
- `body` — optional, 1-3 sentences of extra detail; `null` when the headline
  says enough.

Both strings go through the release-summary safety gate
(`scripts/infra/docs/release_notes/safety.py`): no code fence, no
CVE/security/vulnerability wording (bundled-dependency bumps stay neutral —
`"Updated libpng to 1.6.44."`, never "security fix"), no unwritten placeholder,
no heading/list/table as the opening line, and never the literal text of a
managed marker. A violation fails the updater loudly rather than shipping —
fix the prose in a follow-up PR; it never blocks this one.

- Good: `{"headline": "SkiaSharp 4.151.0 previews the Skia m151 engine update.", "body": "It brings the current upstream renderer into the 4.151 line without changing the managed API surface."}`
- Bad: `{"headline": "Security fix for a bundled library."}` (never name security/CVE details) · `{"headline": "## What's New"}` (that's the renderer's job) · omitting `4.151.0-preview.1` and every other tag just because the stable tag isn't ready yet (converge each tag independently, as its own prose is ready).

## Why this is short

There is no separate template, grouping guide, or checklist to reconcile — the
renderer is the checklist, and this file is the only instructions. If a rule
isn't here, it's because the renderer already guarantees it. Write the prose;
let the script build the page.

