A generated changelog stays trustworthy only when every version, date, breaking flag, and issue identifier already lives in a human-owned ledger. The drafting model may explain those pinned rows, but it may not add, rename, or soften them. A validator should fail the documentation build whenever drafted prose cites an unknown identifier or states a date the ledger does not list. That split keeps narrative assistance useful without letting a draft become the system of record for release facts.
Changelog pages fail in a narrower way than general reference pages, because readers treat each version line as an upgrade fact. Support staff, release managers, and downstream maintainers often copy those lines into tickets, pins, and migration plans. An invented patch number or a shifted date can therefore move work onto the wrong build. The remedy is not a longer prompt, but a ledger that the prose is forbidden to outgrow.
The ownership split
Fields a human must pin
A human editor should pin the version, the date, the breaking flag, the public status, and each issue identifier. The same editor should pin a successor when one release replaces another, because that pointer is a commitment rather than a style choice. The ledger should sit in review beside the product repository, and a named owner should approve every row. Until that approval exists, a fluent paragraph can look finished while the release facts remain unsettled.
Fields a model may narrate
The model may draft impact paragraphs, upgrade context, and short headings that repeat a ledger version exactly. Every drafted sentence that names a release should cite a version or an issue identifier from the published allowlist. The model may reorder explanatory clauses for readability, provided the cited facts stay unchanged and the status stays published. It should not add customer names, quotas, or legal promises, because those claims sit outside this ledger and need separate owners.
How to classify a changelog claim
The table below is a decision aid for reviewers, not a measurement from a production corpus. A claim passes the review only when its class is human-pinned or explicitly allowed as narrative. Mixed sentences should be split so the factual half can be scanned and the explanatory half can be edited. If a sentence cannot be classified, keep it out of the public page until the owner adds a row or deletes the claim.
| Claim class | Required owner | Model permission | Gate result |
|---|---|---|---|
| Version identifier | Human ledger | Cite the exact string | Fail if absent |
| Calendar date | Human ledger | Cite the exact date | Fail if absent |
| Breaking flag | Human ledger | Repeat a fixed label only | Fail if that label is missing |
| Issue identifier | Human ledger | Cite the exact string | Fail if absent |
| Successor version | Human ledger | Cite only a pinned pointer | Fail if absent |
| Impact narrative | Model draft | Explain rows already pinned | Pass when citations match |
| Heading text | Model draft | Anchor on a pinned version | Pass when the version matches |
Six steps that keep the draft inside the ledger
1. Freeze a ledger fixture before prose exists
Create a ledger file and treat published rows as the only changelog facts the narrative may cite. The YAML below is a proposed fixture for reviewers, not a record of a shipped release and not a benchmark. Each row includes a status field so planning notes cannot be narrated as if they were public history. A reviewer should reject any row whose date or issue list is still described as a guess.
# Proposed fixture. Not a record of a shipped release.
schema: release-ledger/1
owner: release-editor
entries:
- version: "1.4.0"
date: "2026-09-02"
breaking: true
status: published
issues: ["GH-1042", "GH-1066"]
successor: null
- version: "1.4.1"
date: "2026-09-18"
breaking: false
status: published
issues: ["GH-1104"]
successor: null
After the fixture is approved, store it at a stable path such as releases/ledger.yaml and review later edits like code. A changed date is a product fact change, so it should not hide inside a documentation wording commit. The owner field is an accountability label for humans, and the model should copy it into no public sentence. This first step is complete only when a second reviewer can point to the file that wins every conflict.
2. Bind the draft to a ledger digest
Compute a digest of the ledger bytes and require the drafted page to name that digest in a comment. The binding prevents an older narrative from shipping after someone corrects a date or removes an issue. The command below is a local pattern for a repository that already has Python 3, and it states no hosted size. If the digest comment is missing, the build should fail closed rather than assume the latest ledger was used.
python -c "import hashlib,pathlib; p=pathlib.Path('releases/ledger.yaml'); print(hashlib.sha256(p.read_bytes()).hexdigest())"
The draft should begin with two comments that record the ledger path and the current digest. A mismatch means the prose was not regenerated against the current pin, even when the sentences still read well. Writers should update the comment only by rerunning the digest command, not by typing a hash from memory. That mechanical rule is what removes an entire class of unreviewed edits from the public release page.
<!-- ledger: releases/ledger.yaml -->
<!-- ledger-sha256: REPLACE_WITH_DIGEST -->
3. Build an allowlist from published rows
The scanner should load the YAML ledger and keep only those rows whose status equals published. Draft rows may remain in the file for planning, but their versions and issues must stay off the public page. The function below is unexecuted example code, and a maintainer should review it before wiring it to a required check. Separate sets for versions, dates, and issues let a failure name the claim class instead of a vague mismatch.
# Unexecuted example. Requires PyYAML. Review before use in a build.
from pathlib import Path
import re
import yaml
DATE_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
ISSUE_RE = re.compile(r"\b[A-Z]{2,}-\d+\b")
VERSION_RE = re.compile(r"\b\d+\.\d+\.\d+\b")
def allowlist(path: Path) -> dict:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
rows = [row for row in data["entries"] if row["status"] == "published"]
return {
"versions": {row["version"] for row in rows},
"dates": {row["date"] for row in rows},
"issues": {issue for row in rows for issue in row["issues"]},
"breaking": {row["version"]: row["breaking"] for row in rows},
}
4. Reject unknown versions, dates, and issue identifiers
A second function should scan the Markdown after the binding comments and compare every match with the allowlist. The check is a consistency gate, so it cannot prove that a human typed the true calendar date. It can prove that the page does not cite a version, date, or issue that the published rows omit. Unknown matches should become build errors that include the offending string, so reviewers need not hunt through the page.
# Unexecuted example. Consistency only; not a truth check.
def scan(markdown: str, allowed: dict) -> list[str]:
errors = []
rules = (
("version", VERSION_RE, "versions"),
("date", DATE_RE, "dates"),
("issue", ISSUE_RE, "issues"),
)
for kind, pattern, key in rules:
for hit in sorted(set(pattern.findall(markdown))):
if hit not in allowed[key]:
errors.append(f"unowned {kind}: {hit}")
return errors
The sample scanner above does not read free-form claims about breakage, because those sentences are too easy to soften. A second helper should require a fixed label such as 1.4.0 breaking: yes for each published version. The helper below is also unexecuted example code, and it checks presence of the label rather than the quality of nearby prose. If the label is absent, the build fails even when the surrounding paragraph never uses the word breaking.
# Unexecuted example. Requires the exact label, not a paraphrase.
def missing_breaking_labels(markdown: str, allowed: dict) -> list[str]:
errors = []
for version, is_breaking in allowed["breaking"].items():
word = "yes" if is_breaking else "no"
label = f"{version} breaking: {word}"
if label not in markdown:
errors.append(f"missing fixed label: {label}")
return errors
Consider a constructed mismatch, not an observed production incident, when judging whether the gate is specific enough. The ledger lists version 1.4.1 on 2026-09-18 with issue GH-1104, and those three strings are the only allowed citations for that row. The draft instead announces version 1.4.2 on 2026-09-20 and cites GH-1200, which the published rows never contained. The scanner should report those three unowned hits and should refuse to repair any of the sentences.
5. Request narrative that cites the allowlist and nothing else
Send the model only the published rows, the heading pattern, and a ban on new identifiers, dates, and breaking claims. The prompt below is a template for reviewers to adapt, not a transcript of a finished run and not a quality score. Store the template beside the ledger so the constraint is visible in the same change as the facts. If the returned draft contains an identifier outside the rows, discard it and rerun the scan instead of patching facts in the prose.
You are drafting changelog narrative for published ledger rows only.
Cite version strings, dates, and issue identifiers exactly as listed.
Include the fixed breaking label for every published version.
Do not add versions, dates, issues, customer names, or breaking claims.
If a row is not in the ledger, omit it rather than inferring it.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode's free model access fits this narration step, because the ledger already holds the facts and the scanner decides what may ship. The free server option fits when the checker should run away from one laptop, after the team confirms the current offering can execute it. Those availability notes are operator-supplied, not quotas, model names, hardware sizes, or duration promises, and they should be rechecked. If hosted access is absent, the ledger, the prompt template, and the local scanner still form a complete workflow.
Reviewers should prefer the owned ledger file over any generated sentence whenever the two sources disagree. That preference is the operational point of the gate, and it does not depend on which editor produced the draft. A passing narrative style review is not a substitute for a matching digest and a clean citation scan. Keep both checks, because a readable paragraph can still smuggle a version the ledger never approved.
6. Make the intended exit contract explicit
The checker command should stay boring, visible, and identical in local review and in any later hosted run. The line below is the proposed interface for the unexecuted script, and a team should implement it before trusting the exits. Exit codes belong in the interface notes so a documentation failure is not mistaken for a style complaint. No timing or cost figure is attached, because this article does not report an executed benchmark.
python scripts/check_changelog.py releases/ledger.yaml docs/changelog.md
The intended contract, which remains a proposal until the script is implemented and run, uses three exits. Exit 0 means the digest matches and every detected citation is already in the published allowlist. Exit 1 means the digest matches, but an unknown version, date, or issue identifier appeared in the draft. Exit 2 means the digest comment is missing or does not match the ledger bytes, so the narrative is unbound.
Limitations that the gate does not remove
Regular expressions miss implied dates such as mid-September and miss versions that are spelled as words rather than digits. A human can still approve a wrong date, after which the scanner will enforce that wrong date with perfect consistency. A new issue prefix will fail until the pattern is updated, which protects the allowlist but can block a legitimate key. Pages that announce security fixes should not share this ledger, because embargo timing and vulnerability identifiers need a separate owner.
This approach also does not judge whether the impact paragraph is clear, complete, or fair to affected users. A citation can be valid while the surrounding explanation hides a breaking change behind soft language. The breaking flag should be rendered with fixed wording, such as a required label, rather than left entirely to paraphrase. Teams that need legal review of release notes should add that review after the gate, and should not treat a passing scan as approval.
Who should not use this workflow
Skip this workflow when no person will approve ledger rows before a model is asked to write. Skip it when annotated history is already the system of record and the public page is a direct render of that history. Skip it when the goal is to discover unreleased work, because discovery is research and this gate only checks citations. A repository that ships several products should keep one ledger per product, or unrelated version lines will share one allowlist.
What to do with one file first
The useful conclusion is narrow enough to test against one unpublished changelog page in the current repository. Pin the published facts, bind the draft to the ledger digest, and reject unknown citations before the page becomes public. Narrative editing remains a human task, but that edit should not be the only control on dates and identifiers. A team that already reviews release facts can apply the checker to one current file, then decide whether older notes deserve the same pin.
Top comments (1)
tr.ee/dev-to