Declarative Changelogs

v0.1 — provisional. A proposal, not a ratified format. This page is the specification in brief, drafted and revised against a working changelog aggregator rather than in the abstract; the open questions at the bottom are where it is most likely to change.

A declarative changelog can be ingested by a machine with no heuristics, no extraction, and no language model — while still reading, to a human, like an ordinary CHANGELOG.md. The name states the contract: every fact a consumer would otherwise infer, the publisher declares. Today every publisher independently invents a format and every consumer independently reverse-engineers it; this format exists so both can stop.

A complete conformant document is at the reference example.

Design principles #

  1. A conformant document is a good CHANGELOG.md. It must read as an ordinary changelog on GitHub, on npm, and in any Markdown viewer. Its two machine-facing constructs — the frontmatter and the escape hatch — render as visible blocks, and that is chosen over invisible metadata on purpose: visible metadata is metadata a human can watch drift.
  2. Structure comes from Markdown people already write. Headings, links, blockquotes, lists — not an attribute microsyntax bolted onto them.
  3. Parsing is deterministic and bounded. No backtracking, no ambiguity, no "usually this means".
  4. Absent beats guessed. A publisher who states no version gets no version — never one inferred from a headline.
  5. Partial adoption pays. Two conformance levels, and most of the value is in the first.

Conformance language #

MUST, SHOULD and MAY are RFC 2119's. Every rule binds one of three roles: the publisher writes the document; a validator checks it against the version it declares, at authoring time, loudly; a consumer ingests documents it cannot fix, and salvages. Every validation error below is the validator's to reject and the consumer's to survive — see Reading a damaged document.

The document #

One Markdown file: YAML frontmatter, one # title, then one ## heading per release in reverse-chronological order.

---
changelog: "0.1"
product:
  name: Kestrel
  vendor: Corvid Labs
  homepage: https://kestrel.example
document:
  updated: 2026-07-28T14:02:00Z
  coverage: complete
---

# Kestrel changelog

## [2.4.0](https://kestrel.example/releases/2.4.0) — 2026-07-09

> Task graphs run in parallel by default, cutting cold builds roughly in half.

### Added

- Tasks with no declared dependency on each other now run in parallel. ([#1204](https://github.com/corvid/kestrel/issues/1204))

### Fixed

- `kestrel watch` no longer misses edits to symlinked files.

YAML, profiled #

Both YAML blocks — the frontmatter and the escape hatch — are read with YAML's failsafe schema: maps, sequences and strings, nothing else. A value's type comes from this specification, never from YAML's implicit typing: 1.10 is the four characters 1.10, 2026-07-09 is a string, no is a string, and quoting is a style choice. Anchors, aliases, custom tags, directives and multi-document streams MUST NOT appear. A key typed list is a YAML sequence; boolean is exactly the string true or false.

Frontmatter #

changelog is required. Everything else is optional, but a document that omits product.name cannot be attributed out of context.

KeyTypeNotes
changelogstringRequired. The format version, "0.1"
product.namestringDisplay name
product.vendorstringWho publishes it
product.homepageURLThe product, not the changelog
product.idstringStable identifier; defaults to name slugged by the fixed algorithm below
product.descriptionstringOne line
product.platformslistDefault platform set for every entry. See Tags
product.versioningsemver | calver | noneNames the version scheme; only semver switches anything on
product.categorystringAdvisory. Any string; consumers treat it as a hint
product.colorhexAdvisory. One opaque #RRGGBB — see below
document.updatedtimestampWhen this document last changed
document.coveragecomplete | partialWhether this is the whole history
document.canonicalURLWhere this changelog is published for people to read
document.localeBCP 47Defaults to en
document.olderURLRequired when coverage: partial. The next document back — itself a document in this format
  • coverage is the field consumers most need: it separates "shipped nothing this quarter" from "this page only goes back three months". Absent means unknown, not complete. Consumers walking the older chain MUST detect cycles.
  • versioning: semver declares SemVer 2.0.0 and switches on the version/content agreement check. calver and none are honest labels that switch nothing on; absent means undeclared, and the check stays off — shape alone never opts a document in, because 24.04.1 looks semver enough to misread.
  • The default id slug is one algorithm: lowercase; Unicode NFKD, combining marks dropped; every run outside a–z 0–9 becomes one -; trim hyphens. A name that reduces to nothing makes product.id required. An explicit id SHOULD match [a-z0-9][a-z0-9-]*.
  • color is the fill a consumer may paint behind the product's name or mark: one opaque sRGB hex, no gradients or alpha, product-level only, no foreground — consumers choose their own ink against it. No logos, icon URLs, or secondary colors; those are a brand kit, not this field. Note that theme_color in a web app manifest answers a different question and is not this.
  • canonical is the address a consumer falls back to when an entry has no link of its own. Fragments are never fabricated against it — there is no cross-renderer anchor algorithm, and a made-up fragment is a guess wearing a URL's clothes.
  • Unknown keys inside product. and document. are validation errors, ignored by consumers; unknown top-level keys draw only a warning. See Evolution.

The release heading #

The load-bearing grammar:

release-heading := "## " (label-part sep)? date tags?

label-part      := "[" label "](" url ")" | label
label           := version | version ": " title | title
version         := "v"? DIGIT+ ("." DIGIT+)+ ("-" pre)? ("+" build)?
pre             := ident ("." ident)*
build           := ident ("." ident)*
ident           := [0-9A-Za-z-]+
sep             := " — " | " – " | " - "
date            := RFC 3339 full-date ("T" partial-time time-offset)?
tags            := " (" tag (", " tag)* ")"
tag             := [a-z] [a-z0-9-]*

Parsed right to left: match date tags? anchored at the end of the line, then require what precedes to be nothing or label-part sep. Nothing scans forward, nothing backtracks. url is a CommonMark link destination; pre and build are semver's own grammars. A label that parses entirely as version is a version; else it splits on the first ": " into version and title if the head parses; else it is all title. All four of these are conformant:

## [2.4.0](https://kestrel.example/releases/2.4.0) — 2026-07-09
## 2.4.0 — 2026-07-09
## Parallel task graphs — 2026-07-09
## 2026-07-09
  • The date is RFC 3339, not "ISO 8601" — no week dates, ordinal dates or basic format. The T is required when a time is present; T and Z are case-insensitive. A date-only value means midnight UTC; it is a lossy sort key, so publishers who can emit a timestamp SHOULD.
  • The permalink is optional and recommended. An entry with no link of its own is addressed by the escape hatch's url:, else document.canonical, else product.homepage.
  • A version is a semver-shaped token or it is not a version. Anything else is a title; versionless and date-only releases are first-class. Identity strips a v prefix: v2.4.1 and 2.4.1 are the same version.
  • Pre-releases are semver's job. 2.5.0-rc.1 is a pre-release; there is no flag to agree or disagree with it. A versionless pre-release uses prerelease in the escape hatch — the only case that key exists for. PEP 440 spellings (1.0rc1) do not match the grammar and read as titles; a Python publisher writes 1.0.0-a1. A validator SHOULD warn when a pre-release part's first identifier is stable, release, final, ga, lts or hotfix — each is a channel wearing a pre-release's syntax, and the channel belongs in the escape hatch.

Tags #

An optional parenthesized run at the end of the heading. The vocabulary is closed at every version of this format:

KindValues
Lifecycleyanked, routine
Platformwindows, macos, linux, ios, android, web, playstation, xbox, switch

An unrecognized tag is a validation error; a consumer that meets one anyway drops the token, keeps the release, and SHOULD say so. Platform tags replace product.platforms outright. There is no channel tag — channels are open-ended and live in the escape hatch, where a typo like lst is rejectable instead of silently accepted.

  • yanked means "do not use this release" — not that it was deleted. The word keeps cargo yank and PEP 592's meaning: withdrawn from recommendation, not unpublished. Consumers render a warning and MUST NOT treat it as a retraction; the entry keeps its notes. A yanked entry SHOULD carry superseded-by. Yanking is retroactive, and adding the tag MUST NOT change the entry's identifier — or every consumer receives the yanked release a second time as a new one.
  • routine marks a release with no user-visible changes worth describing — a dependency bump, a store-mandated rebuild. It is the single most useful thing a changelog can tell a reader deciding whether to read it, and it replaces the boilerplate-detection heuristics consumers otherwise write.

The entry body #

Everything between one ## heading and the next.

ElementRule
SummaryThe blockquote directly after the heading (or its escape hatch). One paragraph, plain prose. Never a blockquote opening with [!
Change sections### whose text is one of the six categories, case-insensitive. Contains a list and nothing else
Change itemOne top-level list item. One distinct change
Everything elseKept verbatim as the entry's body

The six categories are Keep a Changelog's, unchanged, and SHOULD appear in this order — which is not alphabetical:

Added · Changed · Deprecated · Removed · Fixed · Security

The summary is the highest-value optional field in the format: it is what appears in a feed, a notification, and a search result, and one sentence written on purpose beats any extraction. The position rule exists because GitHub's > [!NOTE] alerts are blockquotes by syntax and must never become machine summaries. Unrecognized ### headings (### Terminal, ### Editor) remain legal — those documents reach Level 1 and not Level 2. Two shapes are validation errors: the same category twice in one entry (consumers merge in document order), and change sections in an entry tagged routine.

Change item text #

  1. Take the item's first paragraph — nested lists and later paragraphs are detail.
  2. Detach the reference tail into structured references: the trailing run of parenthesized reference groups, #-prefixed issue references, links whose text is a reference or credit, and credits (@handle, thanks to @handle), with the joining whitespace and commas.
  3. Flatten inline markup in what remains.

A reference is # plus digits, a tracker key (GH-1204, KES-88), or a CVE id. Outside a parenthesized group or a link, only the #-prefixed form counts — that restriction is what keeps HTTP-2, UTF-8 and SHA-256 in the prose. Detached, not discarded: every token becomes a structured reference (issue / cve / link / credit, with its URL when linked). A security identifier must end up more reachable than it was in the prose, not gone.

Breaking changes #

A change item MAY open with the literal marker **Breaking** — capital B, nothing else inside the bold — followed by one of the three separators:

### Removed

- **Breaking** — the `--serial` flag has been removed; use `--jobs 1`.

Inline, inside whichever category the change belongs to, rather than hoisted into a separate section: a breaking removal and a breaking change are different things, and a consumer can build the hoisted view from the inline one but not the reverse. It is a marker, not a severity scale. Validators SHOULD flag near-misses (**BREAKING**, **Breaking:**).

Version and content should agree #

Where the document declares product.versioning: semver and an entry has a predecessor:

An entry containingSHOULD be at least
a **Breaking** itema major bump
### Added itemsa minor bump
only ### Fixed itemsa patch bump

This is Conventional Commits' type-to-SemVer correspondence restated over categories, and the one check a validator gets free. A consumer must never derive a version from content — only flag the disagreement, and only where the publisher declared the scheme.

The escape hatch #

A fenced block with the info string changelog, immediately after the ## heading and before the summary. YAML, under the same profile as the frontmatter. Where a key and the heading state the same fact, the hatch wins — one precedence rule, no exceptions. But version, title and date exist for headings that are wrong and cannot be fixed; a hatch value contradicting a heading that parses cleanly draws a validator warning, because a document whose human and machine readings diverge has reintroduced the disease this format treats.

## [1.129](https://code.example/updates/v1_129) — 2026-06-11

```changelog
covers: ["1.129.1", "1.129.2"]
```

> …

The keys are a closed set:

KeyFor
channelThe release channel: lts, insiders, beta, nightly, anything the publisher runs
urlThe entry's permalink. Overrides a heading link; idiomatic when the heading has none
prereleaseBoolean. Only for a versionless pre-release — on a versioned entry it is a validation error
platformsWhen the tag run would be unwieldy. Replaces the heading's platform tags
coversOther versions this entry documents, which have no entry of their own
superseded-byThe release a reader on this one should move to
idAn explicit stable identifier
version, title, dateExplicit overrides where the heading is ambiguous or wrong

covers and superseded-by are not inverses #

covers is a documentation relationship: the named versions have no entry of their own, and this entry is where they are written down — the vendor who documents patch releases on the minor version's page, said explicitly instead of lost. An entry MAY attribute individual change items to covered versions by opening the item with the version in bold plus a separator — - **2.3.1** — … — where the bold token MUST appear in covers.

superseded-by is a replacement relationship: the named release has its own entry and the publisher is directing readers to it. Three rules: it is meaningful only when the successor is not simply the next entry (a validator may warn otherwise); it MAY point at an older release — at the moment of a yank, the newest good build is usually the previous one; and it MUST resolve — to an entry in this document, to a version this document covers (landing on the covering entry), or through the document.older chain, which offline validators report as a warning rather than an error. Dangling in-document is an error.

Ordering and identity #

Entries MUST appear newest first — a truncated read of a reverse-chronological document is still useful. An entry's identifier is, in order of preference:

  1. id from the escape hatch
  2. <product.id>@<version>, v prefix stripped
  3. <product.id>@<date>, date portion only

The date portion only, even when the heading carries a time: publishers add times to old entries when tooling improves, and a time-bearing identifier would re-deliver the whole history that day. A versionless product releasing twice in one day therefore collides by construction and resolves it with an explicit id — routine, not exceptional, for that publisher. Identifiers are unique within the document and its archive chain, not globally; two vendors picking the same product.id is the consumer's namespacing problem. Identifiers MUST be stable across edits — an entry whose identifier changes when its prose is corrected is re-delivered to every consumer as new.

Skipped headings #

A ## heading is a release-heading candidate if its text contains an RFC 3339 full-date (\d{4}-\d{2}-\d{2}) anywhere, and not otherwise.

  • Not a candidate## Unreleased, any editorial heading — MUST be skipped by consumers, and costs nothing at any level. This is what lets Keep a Changelog's ## [Unreleased] coexist with the format.
  • A candidate that does not parse## Release notes for 2026-05-02, a no-break space in the separator, an unpadded date — is skipped too, but is a conformance failure and a validator error. A heading with a date in it was almost certainly meant to be a release, and a silent skip would let one typo delete it from every aggregator.

Consumers MUST count the headings they skip and SHOULD surface the count — "three releases" and "three releases, four candidates skipped" are different statements about the same document.

Conformance #

Level 1 — Structured. The frontmatter parses under the profile and declares a version whose major the checker understands; every candidate heading parses; no duplicate identifiers; entries newest first; no entry's body is only a pointer to notes that live elsewhere. Eliminates date parsing, version extraction, pre-release inference, title cleaning, identity invention, and the index-versus-body problem.

Level 2 — Categorized. Level 1, plus no top-level list outside a recognized ### section in any entry. The check is structural because no machine can decide whether a paragraph is a change; an entry with no lists at all passes vacuously and contributes no changes, which is honest. Eliminates the enrichment stage entirely.

Level 1 is a find-and-replace; Level 2 is a decision about how release notes are organized. Most of the value is in the first. Addressability — every entry having its own URL — is a property validators SHOULD report, not a third level; it cuts across the other two.

One rule carries more weight than it looks: an entry's content MUST be the release notes, not a link to them. A source that resolves is not a source that answers, and this failure is invisible to every structural signal.

Reading a damaged document #

Validators reject; consumers salvage. A validator MUST fail on: YAML outside the profile; an unknown non-x- key, tag or category for the declared version; a candidate heading that does not parse; duplicate identifiers; entries out of order; an in-document dangling superseded-by; prerelease: on a versioned entry; change sections in a routine entry; a duplicated category section; an attribution naming a version the entry does not cover. It SHOULD warn on the legal near-misses, and diagnose the character when it rejects one.

A consumer never rejects a document it can partly read. Damage costs the smallest enclosing unit: an unparseable candidate heading costs that entry; a malformed hatch costs the hatch; an unrecognized token costs the token; frontmatter that fails the profile costs the document. What was dropped, the consumer counts and SHOULD surface.

Evolution, and unknown vocabulary #

Every closed set is closed per version. Within a major version, a later minor may add vocabulary and MUST NOT change the meaning of existing vocabulary. A validator treats vocabulary from a newer minor as the typo it cannot distinguish it from; a consumer MUST ignore unknown vocabulary — drop the token or key, keep the construct — and MUST NOT refuse a document over its minor version. Keys beginning x- are permitted everywhere, never validated, ignored unless recognized.

Media and raw HTML #

Images SHOULD carry meaningful alt text and MUST NOT be load-bearing — consumers routinely render them as their alt text alone, because remote images are a tracking pixel and an availability dependency. Raw HTML SHOULD NOT appear: a consumer that sanitizes it will remove it, and one that does not has a vulnerability.

Generating this from Conventional Commits #

Conventional Commits classifies the work an author did; this format classifies what changed for the reader. The map between them:

Commit typeCategoryNote
featAdded
fixFixed
perf, revertChanged
securitySecurityExtension — no upstream type exists
deprecateDeprecatedExtension — no upstream type exists
removeRemovedExtension — no upstream type exists
docs, style, refactor, test, build, ci, chorenoneA release of only these is routine

The three extensions exist because stock Conventional Commits cannot say the three categories a reader most needs: a security fix lands as fix: indistinguishable from a typo correction, deprecation has no expression at all, and BREAKING CHANGE conflates removal with breakage — remove: and remove!: separate a removal that breaks people from one that does not. A conventional-changelog preset declares them via the types array and sorts sections in the canonical order, not alphabetically. That the seven hidden types map cleanly onto routine is the strongest independent support that tag has. What generation cannot produce is the summary — an editorial judgement about which of forty commits mattered — so a generated document is a good Level 2 document with the best line missing.

Out of scope for v0.1 #

Named, so the omissions read as decisions:

  • Discovery and hosting — the obvious v0.2, and roughly half the value. The working candidate is a well-known path, /.well-known/changelog.md, plus a rel link; nothing in v0.1 depends on either.
  • Severity beyond the Breaking marker.
  • Localization beyond document.locale.
  • Signing and provenance.
  • How to write good notesCommon Changelog is that guide.

Prior art #

  • Keep a Changelog — the six categories, reverse-chronological order, and the ancestor of yanked. This standard is largely Keep a Changelog with the machine-facing fields made explicit.
  • AppStream release metadata — a decade of structured release data for Linux software centers; its lesson is that such formats get tooling-driven adoption, which is the argument for the Conventional Commits path.
  • GitHub Releases and releases.atom — the de facto machine changelog and the incumbent; its failure modes (bodies that are one URL, tags with no notes) are this format's motivation.
  • cargo yank and PEP 592 — the meaning of yanked.
  • Semantic Versioning — the version grammar, and pre-release semantics deferred to it normatively.
  • Conventional Commits and conventional-changelog — the **Breaking** marker's ancestry, the bump correspondence, and the likeliest generator.
  • towncrier, changesets, release-please — the wider generator ecosystem, each a plausible emitter.
  • Common Changelog — prose discipline; complementary.
  • RSS, Atom, JSON Feed — solved syndication and identity, but carry no categories, versions, platforms, or completeness.
  • Sparkle appcasts — proof publishers maintain machine-readable release data when something they care about depends on it.
  • llms.txt — the argument that one well-known Markdown document is a legitimate machine interface.

Open questions #

Where this is most likely to be wrong:

  1. Is one version grammar worth its cost? PEP 440's 1.0rc1 reads as a title; generators can normalize, but a normalization is a mapping someone must maintain.
  2. Does covers belong in v0.1, or is it a special case dressed up as a field?
  3. Is superseded-by doing two jobs? "This release is bad, go here" and "the fix shipped over there" share one key.
  4. Is routine self-defeating? The best answer so far: generators, not publishers, will set it.
  5. Is a bare-date heading too permissive? Faithful to date-organized publishers, but it gives consumers nothing to display except a date.
  6. Is there a seventh category? revert has no home, and Changed is a poor fit — a gap inherited from Keep a Changelog rather than created here.
  7. Should product.color carry a dark-ground variant? One value chooses ink but cannot always be placed.
  8. Maintenance branches have no field. A channel is not a branch, and consumers reconstruct release trains heuristically; a branch key is the obvious v0.2 candidate.

About #

Drafted by What's New, where it is argued from measurement: the format's fields are the facts this site's ingestion pipeline currently reconstructs, per publisher, by hand. The raw markdown of this page is served at /spec.md. Feedback to contact@whatsnew.fyi.