Release notes become untrustworthy when a language model is allowed to assert impact, compatibility, and upgrade urgency. A safer pipeline compiles a fact ledger from a git range, then confines any model to unlabeled prose. Humans still sign customer impact, breaking status, audience, and rollback in a reviewed overlay. The docs build must fail when those owned cells are missing, guessed, or overwritten by generated text.
This article describes a proposed workflow, not a production case study with measured traffic or conversion. The extractor, validator, and renderer below are labeled examples you can run against any repository you already own. Public debate about generated coding assistance does not change ownership for customer-facing claims. If a sentence tells a user whether to upgrade tonight, a human must sign it.
The failure mode mixed-lane notes create
Most release drafts collapse three different evidence types into one Markdown file. Git already knows hashes, paths, and conventional commit tokens, while issue trackers already know ticket identifiers. None of those sources know whether a storage format change strands last quarter's backups. A model can write fluent highlights from the same commit list, and still invent a support window that nobody staffed.
The practical risk is not awkward wording. The practical risk is an unsigned compatibility promise that support, legal, and on-call cannot defend. Treating generated prose as if it were a signed contract is how release notes quietly become incident reports. Separate compilation, drafting, and signature before the notes ever reach a changelog page.
Three lanes, one publishable document
Keep every field in exactly one lane. Compilation reads the repository. Drafting may propose unread prose. Signature records a human decision that the docs build can prove.
| Field | Lane | Allowed source | Build rule |
|---|---|---|---|
range, tag_from, tag_to
|
compile |
git describe / tag arguments |
fail if unresolved |
commit_sha, subject, files
|
compile | git log |
fail if empty range |
conv_type, ticket_ids
|
compile | subject parser | warn if unparsed |
draft_summary |
model draft | ledger JSON only | drop if owned keys appear |
draft_highlights |
model draft | ledger JSON only | never copy into impact cells |
customer_impact |
human sign | overlay YAML | fail if empty or UNSIGNED
|
breaking |
human sign | overlay YAML | fail unless true or false
|
audience |
human sign | overlay YAML | fail if not in allow-list |
rollback |
human sign | overlay YAML | fail if breaking and blank |
upgrade_required |
human sign | overlay YAML | fail if blank |
That table is the artifact this workflow protects. If a later tool wants to help, it may write only into draft cells. Anything that tells a customer to migrate, wait, or ignore the release stays in the overlay.
Proposed repository layout
Use a dedicated docs-build directory so generated files never become the source of truth. The following layout is a proposal, not a claim about an existing product repository.
release-ledger/
extract_range.py
validate_overlay.py
render_notes.py
prompt_draft.txt
overlays/
v2.4.0.yaml
generated/
v2.4.0.facts.json
v2.4.0.draft.json
out/
v2.4.0.md
generated/ is disposable output. overlays/ is reviewed input. out/ is publishable only after validation exits zero. Do not commit model drafts as if they were signed impact statements.
Workflow
- Choose an inclusive tag range that already exists in the repository you are documenting.
- Compile a fact ledger from
git log, path lists, and a conservative conventional-commit parser. - Open or refuse an overlay file whose keys are owned cells only, never drafted summaries.
- Optionally ask a model to fill
draft_summaryanddraft_highlightsfrom the ledger JSON. - Reject any model object that contains owned keys, upgrade verbs, or copied SHA lists presented as impact.
- Fail the docs job when overlay cells are missing, typed wrongly, or still marked
UNSIGNED. - Render Markdown from facts plus signed cells, and include draft prose only as unsigned narrative.
Each step is mechanical except overlay authorship. That is intentional. Fluency is cheap; compatibility claims are not.
Step 1 and 2: compile facts, do not narrate them
The extractor below is a labeled example. It records what git already asserts, then stops. It does not guess customer impact from file names, and it does not promote a feat token into a marketing sentence.
# labeled example: extract_range.py
from __future__ import annotations
import json, re, subprocess, sys
from pathlib import Path
CONV = re.compile(r"^(?P<type>feat|fix|docs|chore|refactor|test|perf)(?:\((?P<scope>[^)]+)\))?(?P<break>!)?:\s+(?P<summary>.+)$")
TICKET = re.compile(r"\b([A-Z]{2,}-\d+|#[1-9]\d*)\b")
def git(*args: str) -> str:
return subprocess.check_output(["git", *args], text=True).strip()
def parse_subject(subject: str) -> dict:
match = CONV.match(subject)
tickets = TICKET.findall(subject)
if not match:
return {"conv_type": "unknown", "breaking_token": False, "tickets": tickets}
return {
"conv_type": match.group("type"),
"breaking_token": bool(match.group("break")),
"tickets": tickets,
}
def extract(tag_from: str, tag_to: str) -> dict:
rng = f"{tag_from}..{tag_to}"
raw = git("log", rng, "--name-only", "--format=%H%x09%s")
commits, current = [], None
for line in raw.splitlines():
if not line.strip():
continue
if "\t" in line and not line.startswith(" ") and line[0].isalnum():
sha, subject = line.split("\t", 1)
current = {"sha": sha, "subject": subject, "files": [], **parse_subject(subject)}
commits.append(current)
elif current is not None:
current["files"].append(line.strip())
return {
"tag_from": tag_from,
"tag_to": tag_to,
"range": rng,
"head": git("rev-parse", tag_to),
"commit_count": len(commits),
"commits": commits,
}
if __name__ == "__main__":
tag_from, tag_to, out = sys.argv[1], sys.argv[2], Path(sys.argv[3])
out.write_text(json.dumps(extract(tag_from, tag_to), indent=2) + "\n")
A conventional ! token is a compile-time hint, not a signed breaking claim. Plenty of repositories misuse that token, and plenty of breaking changes hide under fix. The overlay remains authoritative even when the parser is confident.
Run it only against tags you already understand:
python release-ledger/extract_range.py v2.3.0 v2.4.0 \
release-ledger/generated/v2.4.0.facts.json
If git log returns no commits, stop. Do not ask a model to invent a release narrative for an empty range.
Step 3: hand-write the overlay before any draft exists
Create the overlay from a stub that cannot pass validation. Humans replace UNSIGNED values. Models never receive this file as writable output.
# overlays/v2.4.0.yaml — human-owned cells only
release: v2.4.0
customer_impact: UNSIGNED
breaking: UNSIGNED
audience: UNSIGNED # allow-list: public-api | operators | internal
rollback: UNSIGNED
upgrade_required: UNSIGNED # allow-list: yes | no | conditional
signer: UNSIGNED
A completed overlay might look like the following reviewed example. Treat it as a format illustration, not as evidence from a shipped product.
release: v2.4.0
customer_impact: >
Queue consumers must accept the new envelope.old_id field before
disabling the compatibility shim in v2.5.0.
breaking: true
audience: operators
rollback: >
Redeploy v2.3.x and keep both envelope fields for 14 days.
Do not restore the v2.2.x on-disk segment format.
upgrade_required: conditional
signer: release-owner@example.invalid
Notice the overlay does not restate commit subjects. Restating subjects duplicates the ledger and invites the draft pass to argue with git. The overlay records decisions git cannot make.
Step 4: constrain the optional draft pass
A model may summarize subjects for readers who will not inspect the ledger. It may not fill breaking, rollback, or upgrade_required. The prompt file should state that prohibition in operational terms, not as a style preference.
You receive a JSON fact ledger from git.
Write JSON with only two keys: draft_summary, draft_highlights.
draft_summary: at most 80 words, no upgrade orders, no dates you cannot see.
draft_highlights: at most five strings, each tied to a subject already present.
Do not output customer_impact, breaking, audience, rollback, upgrade_required, or signer.
Do not claim a change is safe, required, or backwards compatible.
If the ledger is empty, output {"error": "empty-ledger"} instead of prose.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. If you want that constrained draft pass off your laptop, MonkeyCode's free model access can write draft_summary from the ledger JSON, and the free server option can host extract_range.py as a docs-build step. Neither capability authorizes the model to sign impact cells, and this workflow stays useful if you skip the hosted draft entirely.
Keep the model input limited to generated/*.facts.json. Do not paste the overlay into the prompt, because the model then has a template to imitate and a chance to rewrite signed cells. After the draft returns, parse JSON and discard the object when unexpected keys exist.
# labeled example: accept_draft.py
ALLOWED = {"draft_summary", "draft_highlights"}
OWNED = {"customer_impact", "breaking", "audience", "rollback", "upgrade_required", "signer"}
def accept_draft(obj: dict) -> dict:
keys = set(obj)
if keys & OWNED:
raise ValueError(f"draft touched owned keys: {sorted(keys & OWNED)}")
extra = keys - ALLOWED - {"error"}
if extra:
raise ValueError(f"unexpected draft keys: {sorted(extra)}")
if "error" in obj:
raise ValueError(obj["error"])
if not isinstance(obj.get("draft_highlights"), list):
raise ValueError("draft_highlights must be a list")
return {k: obj[k] for k in ALLOWED}
Step 5 and 6: fail the docs build on unsigned cells
Validation is the product. Rendering without it is how fluent notes escape review. The checker below encodes the decision table rather than a taste preference about writing quality.
# labeled example: validate_overlay.py
from __future__ import annotations
import json, sys
from pathlib import Path
import yaml # labeled example; pin the library in your own docs image
AUDIENCE = {"public-api", "operators", "internal"}
UPGRADE = {"yes", "no", "conditional"}
def fail(msg: str) -> None:
print(f"overlay-invalid: {msg}", file=sys.stderr)
sys.exit(1)
def main(facts_path: str, overlay_path: str) -> None:
facts = json.loads(Path(facts_path).read_text())
overlay = yaml.safe_load(Path(overlay_path).read_text())
if facts["commit_count"] == 0:
fail("empty git range")
if overlay.get("release") != facts["tag_to"].lstrip("v") and overlay.get("release") != facts["tag_to"]:
fail("overlay release does not match tag_to")
for key in ("customer_impact", "rollback", "signer"):
value = str(overlay.get(key, "")).strip()
if not value or value == "UNSIGNED":
fail(f"{key} is unsigned")
breaking = overlay.get("breaking")
if breaking not in (True, False):
fail("breaking must be a boolean")
if breaking and len(str(overlay.get("rollback", "")).split()) < 8:
fail("breaking release needs an explicit rollback paragraph")
if overlay.get("audience") not in AUDIENCE:
fail("audience not in allow-list")
if overlay.get("upgrade_required") not in UPGRADE:
fail("upgrade_required not in allow-list")
print("overlay-valid")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
Wire the same exit code into CI. A green docs job that published UNSIGNED is worse than a red job that blocked the tag. If your changelog pipeline currently renders first and reviews later, invert that order before adding any draft model.
python release-ledger/validate_overlay.py \
release-ledger/generated/v2.4.0.facts.json \
release-ledger/overlays/v2.4.0.yaml
Step 7: render with visible lane markers
Published Markdown should still be readable, but it should not launder unsigned prose as policy. Label the draft block, and keep signed cells in their own section. Readers who skip labels still see the overlay text first if you place it above the draft.
# labeled example: render_notes.py
from __future__ import annotations
import json, sys
from pathlib import Path
import yaml
def render(facts, overlay, draft) -> str:
lines = [
f"# {overlay['release']}",
"",
"## Signed impact",
f"- Audience: `{overlay['audience']}`",
f"- Breaking: `{overlay['breaking']}`",
f"- Upgrade required: `{overlay['upgrade_required']}`",
"",
overlay["customer_impact"].strip(),
"",
"## Rollback",
overlay["rollback"].strip(),
"",
f"_Signer: {overlay['signer']}_",
"",
"## Unsigned draft narrative",
draft.get("draft_summary", "_no draft_"),
"",
]
for item in draft.get("draft_highlights", []):
lines.append(f"- {item}")
lines += ["", "## Compiled facts", f"- Range: `{facts['range']}`", f"- Commits: {facts['commit_count']}", ""]
for commit in facts["commits"]:
lines.append(f"- `{commit['sha'][:12]}` {commit['subject']}")
return "\n".join(lines) + "\n"
The compiled fact list is not optional decoration. It lets a reviewer match a dramatic draft highlight to an actual subject line in minutes. If the highlight cannot be mapped, delete the highlight, not the SHA.
Limitations
The parser only understands a small conventional-commit subset and simple ticket tokens. Squashed merges that hide multiple behaviors inside one subject will under-count work and over-trust a single overlay row. Monorepos that ship several products from one tag need one overlay per product, which this example does not encode.
Git history is also not a privacy boundary. Subjects and paths can contain customer names, hostnames, or unreleased codenames. Compilation does not redact; a human still reviews the ledger before any draft pass or public changelog. Empty, private, or embargoed ranges should never leave the build image.
A fluent draft can still smuggle an upgrade order through verbs such as "must" inside draft_summary. The accept step does not fully solve that, because natural language is not a schema. Reviewers should delete draft sentences that issue commands, even when JSON keys look clean.
Who should not use this approach
Skip this pipeline when the repository has no public users and no operator contract. An internal prototype that tags whenever someone remembers does not need signed rollback cells. Skip it when legal already owns a release-note process that forbids generated narrative in the same file, because adding a draft section creates a second, unofficial voice.
Also skip it when nobody will fail the build. An overlay full of UNSIGNED values is not documentation debt; it is a false sense of process. If your team cannot name a signer, do not add a model. Compile the fact ledger and stop there.
What this does not claim
This workflow does not claim that generated notes are accurate, faster to ship, or cheaper to review. It claims that git-range facts, model prose, and human impact statements are different evidence types. Mixing them in one untitled paragraph is the defect. Keeping them in separate cells is the fix, whether or not a draft model runs at all.
If you already fail CI on unsigned doc overlays, a hosted draft pass is optional scaffolding rather than the point of the ledger.
Top comments (0)