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 #
- 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. - Structure comes from Markdown people already write. Headings, links, blockquotes, lists — not an attribute microsyntax bolted onto them.
- Parsing is deterministic and bounded. No backtracking, no ambiguity, no "usually this means".
- Absent beats guessed. A publisher who states no version gets no version — never one inferred from a headline.
- 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.
| Key | Type | Notes |
|---|---|---|
changelog | string | Required. The format version, "0.1" |
product.name | string | Display name |
product.vendor | string | Who publishes it |
product.homepage | URL | The product, not the changelog |
product.id | string | Stable identifier; defaults to name slugged by the fixed algorithm below |
product.description | string | One line |
product.platforms | list | Default platform set for every entry. See Tags |
product.versioning | semver | calver | none | Names the version scheme; only semver switches anything on |
product.category | string | Advisory. Any string; consumers treat it as a hint |
product.color | hex | Advisory. One opaque #RRGGBB — see below |
document.updated | timestamp | When this document last changed |
document.coverage | complete | partial | Whether this is the whole history |
document.canonical | URL | Where this changelog is published for people to read |
document.locale | BCP 47 | Defaults to en |
document.older | URL | Required when coverage: partial. The next document back — itself a document in this format |
coverageis the field consumers most need: it separates "shipped nothing this quarter" from "this page only goes back three months". Absent means unknown, notcomplete. Consumers walking theolderchain MUST detect cycles.versioning: semverdeclares SemVer 2.0.0 and switches on the version/content agreement check.calverandnoneare honest labels that switch nothing on; absent means undeclared, and the check stays off — shape alone never opts a document in, because24.04.1looks semver enough to misread.- The default
idslug is one algorithm: lowercase; Unicode NFKD, combining marks dropped; every run outsidea–z 0–9becomes one-; trim hyphens. A name that reduces to nothing makesproduct.idrequired. An explicit id SHOULD match[a-z0-9][a-z0-9-]*. coloris 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 thattheme_colorin a web app manifest answers a different question and is not this.canonicalis 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.anddocument.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
Tis required when a time is present;TandZare 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:, elsedocument.canonical, elseproduct.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
vprefix:v2.4.1and2.4.1are the same version. - Pre-releases are semver's job.
2.5.0-rc.1is a pre-release; there is no flag to agree or disagree with it. A versionless pre-release usesprereleasein 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 writes1.0.0-a1. A validator SHOULD warn when a pre-release part's first identifier isstable,release,final,ga,ltsorhotfix— 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:
| Kind | Values |
|---|---|
| Lifecycle | yanked, routine |
| Platform | windows, 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.
yankedmeans "do not use this release" — not that it was deleted. The word keepscargo yankand 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 carrysuperseded-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.routinemarks 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.
| Element | Rule |
|---|---|
| Summary | The 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 item | One top-level list item. One distinct change |
| Everything else | Kept 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 #
- Take the item's first paragraph — nested lists and later paragraphs are detail.
- 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. - 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 containing | SHOULD be at least |
|---|---|
a **Breaking** item | a major bump |
### Added items | a minor bump |
only ### Fixed items | a 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:
| Key | For |
|---|---|
channel | The release channel: lts, insiders, beta, nightly, anything the publisher runs |
url | The entry's permalink. Overrides a heading link; idiomatic when the heading has none |
prerelease | Boolean. Only for a versionless pre-release — on a versioned entry it is a validation error |
platforms | When the tag run would be unwieldy. Replaces the heading's platform tags |
covers | Other versions this entry documents, which have no entry of their own |
superseded-by | The release a reader on this one should move to |
id | An explicit stable identifier |
version, title, date | Explicit 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:
idfrom the escape hatch<product.id>@<version>,vprefix stripped<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 type | Category | Note |
|---|---|---|
feat | Added | |
fix | Fixed | |
perf, revert | Changed | |
security | Security | Extension — no upstream type exists |
deprecate | Deprecated | Extension — no upstream type exists |
remove | Removed | Extension — no upstream type exists |
docs, style, refactor, test, build, ci, chore | none | A 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 arellink; nothing in v0.1 depends on either. - Severity beyond the Breaking marker.
- Localization beyond
document.locale. - Signing and provenance.
- How to write good notes — Common 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 yankand PEP 592 — the meaning ofyanked.- 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:
- Is one version grammar worth its cost? PEP 440's
1.0rc1reads as a title; generators can normalize, but a normalization is a mapping someone must maintain. - Does
coversbelong in v0.1, or is it a special case dressed up as a field? - Is
superseded-bydoing two jobs? "This release is bad, go here" and "the fix shipped over there" share one key. - Is
routineself-defeating? The best answer so far: generators, not publishers, will set it. - Is a bare-date heading too permissive? Faithful to date-organized publishers, but it gives consumers nothing to display except a date.
- Is there a seventh category?
reverthas no home, andChangedis a poor fit — a gap inherited from Keep a Changelog rather than created here. - Should
product.colorcarry a dark-ground variant? One value chooses ink but cannot always be placed. - Maintenance branches have no field. A channel is not a branch, and consumers reconstruct
release trains heuristically; a
branchkey 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.