DEV Community

Avery Lin
Avery Lin

Posted on

Stamp Changelog Claims as Extract, Draft, or Sign Before Publishing Notes

Generated release notes fail when a model is allowed to classify breakage or promise an upgrade path. A safer workflow extracts public symbols with ordinary git and AST tooling before any prose is drafted. Models may rewrite extracted inventories into readable summaries, but they must not assign compatibility class or rollback language. Humans still own deprecation dates, data-migration steps, and every sentence a user would treat as a promise.

Why generated changelogs smear authority

Release notes mix three kinds of statement that look similar in Markdown, yet they do not share a source of truth. Symbol lists can be compiled from two git trees with a parser, so they are cheap to regenerate after every tag. Narrative summaries of those lists can be drafted by a model because they only restate already extracted rows. Compatibility claims cannot be compiled from a diff, because a removed helper might be public API or an accidental export.

Teams blur those classes when they paste a chat transcript into CHANGELOG.md and ship the tag. Readers then treat a fluent paragraph as a contract, including sentences about zero downtime or lossless rollback. The failure is not that the prose is ugly; the failure is that authority was never stamped on each claim.

The three authorities

This workflow assigns every changelog row exactly one authority before any drafting work starts in the repository. Mixing those authorities in one Markdown heading is what makes generated notes look finished while remaining unprovable. Keep the labels in the working file, not in a reviewer's memory after the tag exists.

  1. EXTRACT rows must be rebuilt by a script from git objects, AST exports, or collected tests, and never from a prompt transcript.
  2. DRAFT rows may be written by a model as restatements of EXTRACT rows, and they remain unpublished until a reviewer accepts the wording.
  3. SIGN rows must be written by a named human owner, covering breakage class, migration, rollback policy, and deprecation calendar language.

A model that fills a SIGN row is not accelerating documentation but forging a signature the repository cannot prove. Treat that event as a broken build rather than as a wording issue for later cleanup.

Artifact: a stamped changelog map

Keep the working document out of CHANGELOG.md until the checker passes against the stamped map. Store an explicit authority map beside the tag, for example docs/changelog/v2.5.0.yaml in the same repository. Each row names its authority, its evidence path, and whether a model was allowed to touch it.

# docs/changelog/v2.5.0.yaml
version: "2.5.0"
compared_to: "2.4.0"
rows:
  - id: symbols.added
    authority: EXTRACT
    evidence: build/symbol_diff.json
    body: []
  - id: symbols.removed
    authority: EXTRACT
    evidence: build/symbol_diff.json
    body: []
  - id: narrative.summary
    authority: DRAFT
    evidence: "symbols.added,symbols.removed"
    drafted_by: null
    body: ""
  - id: compat.class
    authority: SIGN
    owner: ""
    allowed_values: ["compatible", "breaking", "mixed"]
    body: ""
  - id: migration.steps
    authority: SIGN
    owner: ""
    body: ""
  - id: rollback.policy
    authority: SIGN
    owner: ""
    body: ""
  - id: deprecation.timeline
    authority: SIGN
    owner: ""
    body: ""
Enter fullscreen mode Exit fullscreen mode

Empty SIGN bodies are a passing state, while model-filled SIGN bodies are a failing state that blocks the tag. Operators should treat that distinction as the whole gate, not as optional review advice. The map may still publish without migration text when the extractor reports no removed public names.

Step 1: Extract a public-symbol inventory

Label the following script as a worked example, not as a production coverage claim for every language. It compares two annotated tags in a Python tree and records names that look public at module scope. It does not decide whether a deletion is breaking, because that decision needs product intent the parser cannot see.

#!/usr/bin/env python3
"""Extract public function and class names from two git tags. Proposal example."""
from __future__ import annotations

import ast
import json
import subprocess
import sys
from pathlib import Path


def git_show(tag: str, path: str) -> str | None:
    proc = subprocess.run(
        ["git", "show", f"{tag}:{path}"],
        check=False,
        capture_output=True,
        text=True,
    )
    if proc.returncode != 0:
        return None
    return proc.stdout


def public_names(source: str) -> set[str]:
    tree = ast.parse(source)
    names: set[str] = set()
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            if not node.name.startswith("_"):
                names.add(node.name)
    return names


def list_py_files(tag: str) -> list[str]:
    proc = subprocess.run(
        ["git", "ls-tree", "-r", "--name-only", tag],
        check=True,
        capture_output=True,
        text=True,
    )
    return [
        p for p in proc.stdout.splitlines()
        if p.endswith(".py") and "/tests/" not in p
    ]


def inventory(tag: str) -> set[str]:
    found: set[str] = set()
    for path in list_py_files(tag):
        src = git_show(tag, path)
        if not src:
            continue
        try:
            found |= {f"{path}::{n}" for n in public_names(src)}
        except SyntaxError:
            continue
    return found


def main() -> int:
    if len(sys.argv) != 3:
        print("usage: extract_symbol_diff.py <old-tag> <new-tag>", file=sys.stderr)
        return 2
    old_tag, new_tag = sys.argv[1], sys.argv[2]
    old, new = inventory(old_tag), inventory(new_tag)
    payload = {
        "old_tag": old_tag,
        "new_tag": new_tag,
        "added": sorted(new - old),
        "removed": sorted(old - new),
        "unchanged_count": len(old & new),
    }
    Path("build").mkdir(exist_ok=True)
    Path("build/symbol_diff.json").write_text(json.dumps(payload, indent=2))
    print(json.dumps(
        {k: (len(v) if isinstance(v, list) else v) for k, v in payload.items()},
        indent=2,
    ))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run the extractor against annotated tags, not against an uncommitted working tree, so later reviewers can rebuild the same evidence object. Point it at the tags users actually install, rather than at a feature branch that never becomes an artifact.

python3 extract_symbol_diff.py v2.4.0 v2.5.0
Enter fullscreen mode Exit fullscreen mode

The JSON file is the only input a later drafting step may read for this workflow. Chat logs, issue titles, and marketing copy stay out of the EXTRACT rows because they are not reconstructible from git objects.

Step 2: Attach authority stamps

Load the YAML map and copy added and removed names into EXTRACT bodies without touching SIGN fields. Leave SIGN bodies blank on purpose after a large diff, because silence is cheaper to review than a generated compatibility story. If a removed symbol exists, the checker later demands a human compatibility class so hidden breakage cannot ship as empty prose.

#!/usr/bin/env python3
"""Stamp EXTRACT bodies from symbol_diff.json. Leaves SIGN rows untouched."""
from __future__ import annotations

import json
from pathlib import Path

import yaml

diff = json.loads(Path("build/symbol_diff.json").read_text())
doc = yaml.safe_load(Path("docs/changelog/v2.5.0.yaml").read_text())
for row in doc["rows"]:
    if row["id"] == "symbols.added":
        row["body"] = diff["added"]
    elif row["id"] == "symbols.removed":
        row["body"] = diff["removed"]
Path("docs/changelog/v2.5.0.yaml").write_text(yaml.safe_dump(doc, sort_keys=False))
Enter fullscreen mode Exit fullscreen mode

Operators who extend the map should follow four mechanical rules rather than inventing extra authorities during a release week.

  1. Add a new EXTRACT row only when a command can rebuild it from git objects, AST input, or another checked-in evidence file.
  2. Add a DRAFT row only when every sentence can cite an EXTRACT identifier already present in the same map.
  3. Add a SIGN row for any claim about users, stored data, calendar dates, money, downtime, or compatibility class.
  4. Never convert a SIGN row into DRAFT because the model sounded confident or because the deadline is close.

Step 3: Let a model draft only restated summaries

The drafting prompt should receive build/symbol_diff.json and the empty narrative.summary row, not the live CHANGELOG.md. Ask for a restatement of added and removed names, grouped by path prefix, with no compatibility adjectives attached. Reject output that contains words such as safe, breaking, migrate, rollback, supported, or guarantee, because those tokens belong to SIGN rows.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that constrained drafting step when a team wants the summary written off-laptop. The mention here is only about where a DRAFT row might be produced; it does not change the authority map and does not authorize filling SIGN rows.

After a draft returns, set drafted_by: model on narrative.summary and store the prose in body for review. A reviewer may edit that paragraph, then keep the flag or clear it after a full rewrite that no longer depends on the model. SIGN rows remain empty until a named owner writes them in a later commit that the checker can attribute.

A minimal prompt shape, labeled as a proposal rather than a measured template, looks like the following block.

You may write docs/changelog narrative.summary only.
Use solely the added and removed arrays from symbol_diff.json.
Group names by directory prefix.
Do not classify compatibility.
Do not mention upgrade, rollback, downtime, or data loss.
If a name is ambiguous, quote it and stop.
Enter fullscreen mode Exit fullscreen mode

Step 4: Fail the build when SIGN rows are model-authored

The checker is the artifact that makes the map enforceable instead of ceremonial. It reads the YAML file, confirms EXTRACT bodies match the JSON evidence, and rejects failure classes that show up when generated notes are pasted too early. Wire it to the tag job so the human-readable changelog is rendered only after the gate passes.

#!/usr/bin/env python3
"""Fail CI when changelog authorities are violated. Proposal example."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import yaml

FORBIDDEN_IN_DRAFT = re.compile(
    r"\b(safe|breaking|compatible|migrate|rollback|guarantee|supported|zero downtime)\b",
    re.I,
)
SIGN_IDS = {
    "compat.class",
    "migration.steps",
    "rollback.policy",
    "deprecation.timeline",
}


def fail(msg: str) -> None:
    print(f"authority-check: {msg}", file=sys.stderr)
    raise SystemExit(1)


def main() -> int:
    diff = json.loads(Path("build/symbol_diff.json").read_text())
    doc = yaml.safe_load(Path(sys.argv[1]).read_text())
    rows = {r["id"]: r for r in doc["rows"]}

    if rows["symbols.added"]["body"] != diff["added"]:
        fail("EXTRACT symbols.added does not match symbol_diff.json")
    if rows["symbols.removed"]["body"] != diff["removed"]:
        fail("EXTRACT symbols.removed does not match symbol_diff.json")

    draft = rows["narrative.summary"]
    if draft["authority"] != "DRAFT":
        fail("narrative.summary must remain DRAFT")
    if FORBIDDEN_IN_DRAFT.search(draft.get("body") or ""):
        fail("DRAFT prose used a SIGN vocabulary word")

    removed = diff["removed"]
    compat = rows["compat.class"]
    if removed and not compat.get("body"):
        fail("removed public symbols require a human compat.class signature")
    if compat.get("drafted_by") == "model":
        fail("compat.class cannot be model-authored")
    if compat.get("body") and compat.get("body") not in {
        "compatible",
        "breaking",
        "mixed",
    }:
        fail("compat.class must be one of compatible|breaking|mixed")

    for sid in SIGN_IDS:
        row = rows[sid]
        if row.get("drafted_by") == "model":
            fail(f"{sid} is SIGN and cannot be model-authored")
        if row["authority"] != "SIGN":
            fail(f"{sid} must be SIGN")
        if row.get("body") and not row.get("owner"):
            fail(f"{sid} has body text but no owner")

    print("authority-check: ok")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode
python3 extract_symbol_diff.py v2.4.0 v2.5.0
python3 stamp_extract_bodies.py
python3 check_authority.py docs/changelog/v2.5.0.yaml
Enter fullscreen mode Exit fullscreen mode

Render CHANGELOG.md from the map only after this command exits zero and SIGN owners are present. Until that point, a drafted summary is a restatement of EXTRACT evidence rather than a user-facing release note.

Decision table for draft rights

Claim Authority Model may Human must
Added or removed public names EXTRACT Not write this row Confirm the export surface is intentional
Path-grouped restatement DRAFT Write, without compatibility adjectives Edit for accuracy and keep evidence citations
compatible / breaking / mixed SIGN Leave blank Choose exactly one value
User migration commands SIGN Propose only in a scratch file, never in the map Rewrite, run, and sign
Rollback supported or unsupported SIGN Forbidden State the policy in owner-attributed text
Deprecation end date SIGN Forbidden Set calendar language, or omit the row
Security impact wording SIGN Forbidden Security owner only

Treat any cell marked forbidden as a CI failure, not as a review comment sitting on the pull request. Review comments do not stop a fluent paragraph from reaching users who will treat it as a contract.

Limitations

The extractor only reads top-level function and class names in Python files outside tests/. It misses __all__ re-exports, C extensions, type stubs, CLI entry points, and HTTP routes, so the inventory is incomplete for many libraries. Word-list blocking on DRAFT prose is brittle, because a model can describe breakage without using the blocked tokens. The checker cannot see product intent, so a human can still sign compatible incorrectly after a real behavior change that left names intact.

Git tags must exist and must be the objects users install, or the evidence trail becomes theater. Comparing branches, stash dumps, or generated wheels will stamp rows that release consumers cannot reconstruct from the published tag. Teams that publish notes from issue trackers rather than from tags need a different EXTRACT source, and they should not pretend this script covers that case.

Who should skip this workflow

Skip the map when legal or customer contracts already define changelog language, because a YAML authority field is not a substitute for counsel review. Skip it for security advisories that require coordinated disclosure, since those documents have a different owner and a different embargo clock. Skip it when the project has no public Python surface, or when two tags cannot be fetched inside continuous integration.

Do not use model drafting at all when the release only exists to ship a data-loss fix. In that case there is nothing useful to restate from a symbol list, and the SIGN rows should be written first by the owner who reproduced the defect. Generated summaries add fluency without adding evidence, which is the failure mode this workflow is designed to keep out of user-facing notes.

Top comments (0)