Generated release notes fail when they treat narrative bullets as contracts that users can follow during an upgrade. A model can inventory commits, paths, and symbols, but it cannot honestly promise a migration path. This workflow keeps those jobs in separate files and rejects changelog text that smuggles unsigned promises.
The useful split is mechanical rather than stylistic. Compile a facts file from git history and public symbols, then let a reviewer own every upgrade claim in a second file. Drafted prose may summarize the facts file only. If a sentence implies compatibility, data preservation, or a supported command, it must already exist in the reviewer contract.
Why mixed release notes keep shipping false confidence
Commit subjects describe work that landed, not work that users can rely on after they bump a version. File churn looks like a complete story, yet it never states whether a renamed flag still accepts the old spelling. Models trained to sound helpful will fill that silence with migration language that nobody reproduced.
The failure is not missing adjectives. The failure is a single document that concatenates inventory and warranty until reviewers cannot see which sentences were invented. Teams then paste the draft into CHANGELOG.md, and support tickets become the first test of the upgrade path.
Treat release communication as two artifacts with different authors. The inventory is allowed to be incomplete, noisy, and regenerated on every tag. The contract is small, boring, and owned by a human who can run the migration commands.
Ownership matrix for one tagged release
Use this table as the original gate, not as decoration. Rows marked draft may come from a script or a model. Rows marked own stay empty until a reviewer writes them.
| Row | Source of truth | May a model draft it? | Human must own? |
|---|---|---|---|
| Tag, date, compared ref |
git describe / git log
|
No, compile only | No |
| Commit subjects and hashes | git log --format |
No, compile only | No |
| Paths with line churn | git diff --numstat |
No, compile only | No |
| Added or removed public names | AST or git diff on exports |
Draft list only | Confirm public surface |
| Breaking versus additive label | Not in git | No | Yes |
| Migration command that still works | Not in git | No | Yes |
| “Old flags still accepted” | Not in git | No | Yes |
| Data remaining readable after bump | Not in git | No | Yes |
| Support end for the previous major | Product policy | No | Yes |
| User-facing paragraph in CHANGELOG | Facts file plus contract | Draft from facts only | Approve after gate |
If a cell is blank in the contract, the changelog gate must fail closed. Silence is not a signed “no breaking change” statement.
Workflow in six numbered steps
- Choose the compared refs, usually the previous tag and
HEAD, and refuse to draft against an untagged working tree. - Compile
release_facts.jsonwith tag metadata, commit subjects, path churn, and optional public-name diffs. - Leave
upgrade_contract.ymlempty except for the tag name, then wait for a reviewer to fill owned rows. - Optionally ask a model to draft
CHANGELOG.draft.mdfrom the facts file while the contract is still invisible to it. - Merge draft bullets with contract rows into
CHANGELOG.md, then run the promise-verb gate before merge. - Store the facts file and the contract beside the tag so later audits can see what was compiled versus owned.
The order matters because models should not see the contract while inventing summary bullets. If they see owned claims first, they copy warranty tone into every paragraph. Keep the draft step facts-only, then splice reviewer sentences in a later, boring function.
Step 2: compile a facts file from git
The script below is a labeled, reproducible method. Run it from a clean checkout at the release tag. It does not classify breaking changes and does not emit migration commands.
#!/usr/bin/env python3
"""Compile release_facts.json. Proposal: invoke at a tagged commit."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def git(*args: str) -> str:
result = subprocess.run(
["git", *args],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def compile_facts(previous_ref: str, current_ref: str) -> dict:
tag = git("describe", "--exact-match", "--tags", current_ref)
subjects = git(
"log", f"{previous_ref}..{current_ref}",
"--format=%h\t%s",
).splitlines()
numstat = git("diff", "--numstat", previous_ref, current_ref).splitlines()
paths = []
for line in numstat:
added, deleted, path = line.split("\t", 2)
paths.append(
{
"path": path,
"added": None if added == "-" else int(added),
"deleted": None if deleted == "-" else int(deleted),
}
)
return {
"tag": tag,
"previous_ref": previous_ref,
"current_ref": current_ref,
"commit_count": len(subjects),
"commits": [
{"hash": row.split("\t", 1)[0], "subject": row.split("\t", 1)[1]}
for row in subjects if row
],
"paths": paths,
}
def main() -> None:
if len(sys.argv) != 3:
raise SystemExit("usage: compile_release_facts.py <previous_ref> <current_ref>")
facts = compile_facts(sys.argv[1], sys.argv[2])
Path("release_facts.json").write_text(json.dumps(facts, indent=2) + "\n")
print(f"wrote release_facts.json for {facts['tag']}")
if __name__ == "__main__":
main()
Example commands for a tagged tree:
git fetch --tags --prune
git describe --exact-match --tags HEAD
python compile_release_facts.py v1.4.0 HEAD
Do not pipe git log prose straight into a changelog. Subjects are evidence of work, not evidence of compatibility. The JSON file is the only inventory the later draft step is allowed to read.
Step 3: a small contract the model does not author
Keep the owned file short enough that a reviewer will actually fill it. The schema below is the whole point of the article. Empty strings are invalid for required claim fields once you intend to publish.
# upgrade_contract.yml — humans own every claim row
tag: v1.5.0
compared_to: v1.4.0
breaking: true
migration_commands:
- cmd: "mycli migrate --from 1.4 --dry-run"
verified_on: "2026-09-19"
notes: "Must print the files it would rewrite before writing."
compat_claims:
- "Config key log_format remains accepted through this major."
- "SQLite files written by 1.4.x open read-only without a convert step."
unowned_on_purpose: []
support_previous_major_until: "2026-12-31"
reviewer: ""
A reviewer fills reviewer, the boolean breaking, and every command they actually ran. If there is no migration, they still write an explicit claim such as “no schema rewrite is required for 1.4.x files.” Omitting the row is not equivalent to that sentence.
Step 5: reject changelog sentences that smuggle promises
The gate looks for claim verbs and compatibility phrases in CHANGELOG.md. Matches are allowed only when the exact sentence, or a recorded prefix, appears in upgrade_contract.yml. This is a lint, not a semantic proof, and it will miss clever paraphrases. It still catches the common failure where a draft says “users can keep the old flag.”
#!/usr/bin/env python3
"""Fail if CHANGELOG.md uses claim language absent from upgrade_contract.yml."""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
CLAIM_PATTERNS = [
r"\bcompatible\b",
r"\bstill (works|accepted|supported)\b",
r"\bno breaking\b",
r"\bmigrate\b",
r"\bwill not (remove|break|delete)\b",
r"\bdata remains\b",
r"\bsupported until\b",
]
def sentences(text: str) -> list[str]:
chunks = re.split(r"(?<=[.!?])\s+", text.strip())
return [c.strip() for c in chunks if c.strip()]
def main() -> None:
changelog = Path("CHANGELOG.md").read_text()
contract = yaml.safe_load(Path("upgrade_contract.yml").read_text())
owned = " ".join(
contract.get("compat_claims") or []
+ [row.get("cmd", "") for row in contract.get("migration_commands") or []]
+ [str(contract.get("support_previous_major_until") or "")]
)
failures = []
for sentence in sentences(changelog):
if not any(re.search(p, sentence, re.I) for p in CLAIM_PATTERNS):
continue
if sentence not in owned and not any(sentence.startswith(s) for s in owned.splitlines()):
# Allow a sentence that is copied verbatim into compat_claims.
if sentence not in (contract.get("compat_claims") or []):
failures.append(sentence)
if failures:
print("unsigned claim language in CHANGELOG.md:")
for item in failures:
print(f" - {item}")
raise SystemExit(1)
if not contract.get("reviewer"):
raise SystemExit("upgrade_contract.yml has no reviewer")
print("changelog claim gate passed")
if __name__ == "__main__":
main()
A minimal pytest keeps the gate honest when someone edits the pattern list:
# test_changelog_claim_gate.py — unexecuted example until you add fixtures
from pathlib import Path
def test_unsigned_still_works_fails(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
Path("CHANGELOG.md").write_text(
"### Changed\n\nThe old --format flag still works.\n"
)
Path("upgrade_contract.yml").write_text(
"tag: v1.5.0\nreviewer: avery\ncompat_claims: []\nmigration_commands: []\n"
)
import changelog_claim_gate as gate
try:
gate.main()
except SystemExit as exc:
assert exc.code == 1
else:
raise AssertionError("expected the claim gate to fail")
Wire both scripts to the tag job, not to every push. Facts compilation is cheap. The contract is the scarce review surface, and running it on noisy main-branch commits trains people to ignore it.
Where a free model draft belongs
The model step is optional and comes after release_facts.json exists. Feed only that file, plus a hard instruction to list inventory bullets without compatibility verbs. Do not attach README fragments, support mail, or the contract. Those sources teach the model to sound like a warranty.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option can host that facts-only draft when you do not want a local inference box or a paid API key in CI. The product is doing summarization of compiled git evidence, not ownership of upgrade claims. If the draft introduces “still works” language, delete those sentences rather than negotiating with the model.
A practical prompt shape, which you should keep in the repo next to the facts schema:
You receive release_facts.json only.
Write changelog bullets that mention tags, commit subjects, and paths.
Do not use: compatible, still works, migrate, will not remove, data remains, supported until.
If you lack a fact, omit the bullet. Do not guess user impact.
After the draft returns, a script should splice compat_claims and migration_commands under a heading such as Upgrade contract. Humans remain the authors of that heading even when the inventory bullets were machine-written.
Limitations the gate does not hide
Git subjects omit reverted work, squash-merged context, and private packaging commits that still change user behavior. Numstat treats moved files poorly unless you pass rename detection and then decide how to display it. Public-name extraction, if you add AST later, misses dynamically built exports and re-exports from __getattr__.
The claim regex is brittle on purpose. It will fail safe on “still works” and fail open on “operators should notice no difference.” Reviewers still read the draft. The gate exists to stop the most common paste error, not to certify a release.
Models also compress several path changes into one user-impact sentence. That compression is usually where an unsigned promise appears. Prefer more bullets with file names over one paragraph that explains how users will feel.
Who should not use this approach
Skip the two-file split for throwaway tools with no tagged users and no compatibility story. Skip it when legal warranty text is required, because a YAML claim list is not a contract your counsel would sign. Skip it if nobody will run the migration commands on a copy of production-shaped data.
Also skip model drafting when the facts file is empty or the tag range is wrong. A fluent empty summary is worse than no changelog. Compile first, fail if commit_count is zero, and only then consider a draft.
The core conclusion stays the same after those exclusions. Inventory can be generated; upgrade claims cannot. Keep the files apart, and let CI refuse the helpful sentence that nobody reproduced.
If you already run MonkeyCode free models on the free server option, point them at release_facts.json and leave upgrade_contract.yml to a reviewer who ran the migration.
Top comments (0)