DEV Community

Avery Lin
Avery Lin

Posted on

Cap What a Model May Draft: Lanes, an Edit Budget, and Three Publish Gates for Release Notes

Core conclusion: a documentation-generation workflow is only as trustworthy as the boundary you draw before the model starts drafting, and for release notes that boundary reduces to lanes, an edit budget, and three machine gates. The pipeline below lets a model draft mechanical bullets, section grouping, and omission rows, while breaking changes, security advisories, and lifecycle copy stay human-owned by construction. The artifact is one script plus a pytest suite, so you can reconcile every published line against git log weeks later.

Why release notes are the honest test case

Release notes have a frozen input, a dated output, and a small number of checkable statements per line. That combination makes them measurable: you can diff the published file against the commit range and see exactly which lines a machine proposed. Generative documentation usually fails here in a predictable way, because a plausible draft hides its own provenance. A breaking change gets reworded into a friendly feature bullet, and nobody notices until a customer integration breaks.

The fix is a boundary problem rather than a prompting problem. Decide which lanes the model may touch, cap how much it may write per release, and make the human-owned lanes structurally unreachable.

The lane manifest

Four lanes cover almost every commit range, and each lane has a different drafting right and a different signer.

Change in the range Lane Model may draft Human owner Machine gate
Dependency bump mechanical Version bullet Release bot [sha:xxxxxxx] token required
docs / ci / test / refactor mechanical (omitted) Omission row only Release lead vetoes omissions Commit-count reconciliation
feat / fix visible to users skeleton Labeled first pass Docs on-call rewrites or approves approved-by: marker
BREAKING CHANGE: human-owned Nothing Release lead Lane blocked before drafting
Security advisory (CVE-...) human-owned Nothing Security lead Lane blocked
Deprecation, sunset, support window human-owned Nothing Product owner Lane blocked

The important design choice sits in the last column of the human-owned rows. Those entries are not merely flagged as sensitive; they never receive a drafted string at all, so no reviewer has to unlearn a confident sentence.

Step-by-step pipeline

  1. Freeze the commit range and hash it. Run git log over base..head with merges excluded, and record a hash of the ordered SHAs. This hash travels into the draft packet and into the published notes header.
  2. Classify every commit into one lane. Check human triggers first, mechanical patterns second, and feat/fix third. Anything unrecognized falls back to the human lane, which is the conservative default for unknown commit styles.
  3. Draft only inside the mechanical and skeleton lanes. The mechanical lane is pure string assembly from commit metadata, which needs no model at all. The skeleton lane is where a drafting pass helps, because it produces section grouping, ordering, and a labeled first pass over user-visible bullets.
  4. Enforce the edit budget before a human reads anything. Sum the proposed lines per lane and compare against a per-release cap; a release that blows the cap should be split rather than given a bigger allowance.
  5. Emit a signature sheet. List every human-owned entry with its SHA, its trigger, and the person who has to write it, so ownership is assigned before the release freeze.
  6. Gate the publish. Refuse to merge notes that still contain placeholders, bullets without provenance tokens, or user-visible bullets without an approval marker.

Step three is a good place to remove an API key from the loop. One option is MonkeyCode's free model access, with the free server option covering a scheduled drafting job over the open range; both are operator-supplied availability claims here, so verify current details on the product's own pages before you depend on them. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The gates stay in your repository and remain indifferent to which model produced the draft.

The classifier and budget script

The script below is a proposal artifact, so run the suite before trusting it on a real range. It uses only the standard library, and the --gate mode runs in CI against the notes file itself.

#!/usr/bin/env python3
'''docs_budget.py: freeze a commit range, sort it into lanes, cap the draft.

Exit codes
    0  packet written, budget respected
    2  proposed draft exceeds the per-release edit budget
    3  the publish gate rejected the notes file
'''
from __future__ import annotations

import argparse
import dataclasses
import hashlib
import json
import pathlib
import re
import subprocess
from typing import Sequence

MECHANICAL, SKELETON, HUMAN = 'mechanical', 'skeleton', 'human-owned'

HUMAN_TRIGGERS: Sequence[tuple[re.Pattern[str], str]] = (
    (re.compile(r'BREAKING[ -]CHANGE:', re.I), 'breaking-change'),
    (re.compile(r'\bCVE-\d{4}-\d{4,}\b'), 'security-advisory'),
    (re.compile(r'\b(deprecat\w*|sunset|end[- ]of[- ]life)\b', re.I), 'lifecycle-copy'),
    (re.compile(r'\b(drops? support|no longer supports?)\b', re.I), 'support-window'),
)

MECHANICAL_PATTERNS: Sequence[tuple[str, re.Pattern[str]]] = (
    ('dependency-bump', re.compile(r'^(?:chore|build)\(deps\)!?: .+')),
    ('omitted-docs', re.compile(r'^docs(?:\([^)]*\))?!?: .+')),
    ('omitted-internal', re.compile(r'^(?:test|ci|refactor|perf)(?:\([^)]*\))?!?: .+')),
)

SKELETON_PATTERN = re.compile(r'^(?:feat|fix)(?:\([^)]*\))?!?: .+')

LINES_PER_KIND = {
    'dependency-bump': 1, 'omitted-docs': 0, 'omitted-internal': 0,
    'skeleton': 3, 'unclassified': 0,
}


@dataclasses.dataclass(frozen=True)
class Commit:
    sha: str
    subject: str
    body: str = ''


@dataclasses.dataclass(frozen=True)
class Entry:
    lane: str
    kind: str
    sha: str
    subject: str
    drafted: str
    lines: int
    signer: str


def classify(commit: Commit) -> tuple[str, str]:
    haystack = f'{commit.subject}\n{commit.body}'
    for pattern, kind in HUMAN_TRIGGERS:
        if pattern.search(haystack):
            return HUMAN, kind
    for kind, pattern in MECHANICAL_PATTERNS:
        if pattern.match(commit.subject):
            return MECHANICAL, kind
    if SKELETON_PATTERN.match(commit.subject):
        return SKELETON, 'skeleton'
    return HUMAN, 'unclassified'  # conservative default: no auto-draft


BUMP = re.compile(r'bump\s+(?P<pkg>[\w.@/\-]+)\s+from\s+(?P<old>\S+)\s+to\s+(?P<new>\S+)', re.I)


def mechanical_line(kind: str, commit: Commit) -> str:
    if kind != 'dependency-bump':
        return ''  # omission row: logged in the packet, never published
    match = BUMP.search(commit.subject)
    if match:
        return (f"- `{match.group('pkg')}` {match.group('old')} -> "
                f"{match.group('new')} [sha:{commit.sha[:7]}]")
    return f'- dependency update [sha:{commit.sha[:7]}]'


def skeleton_line(commit: Commit, owners: dict[str, str]) -> str:
    return (f'- <FILL:user-impact> [sha:{commit.sha[:7]}] '
            f"[owner:{owners.get('skeleton', 'unassigned')}]")


def build_packet(commits, owners, budget):
    entries, proposed = [], 0
    for commit in commits:
        lane, kind = classify(commit)
        if lane == HUMAN:
            drafted, lines = '', 0
        elif lane == SKELETON:
            drafted, lines = skeleton_line(commit, owners), LINES_PER_KIND['skeleton']
        else:
            drafted, lines = mechanical_line(kind, commit), LINES_PER_KIND.get(kind, 0)
        proposed += lines
        entries.append(Entry(lane, kind, commit.sha[:7], commit.subject, drafted,
                             lines, owners.get(lane, 'unassigned')))
    packet = {
        'range_hash': hashlib.sha256(''.join(c.sha for c in commits).encode()).hexdigest()[:16],
        'commit_count': len(commits),
        'proposed_lines': proposed,
        'budget': budget,
        'entries': [dataclasses.asdict(e) for e in entries],
    }
    return packet, (2 if proposed > budget else 0)


FILL = re.compile(r'<FILL:[^>]*>')
BULLET = re.compile(r'^\s*[-*]\s+')
SHA = re.compile(r'\[sha:[0-9a-f]{7,40}\]')
APPROVED = re.compile(r'approved-by: @[\w-]+')
USER_VISIBLE = '## User-visible changes'


def gate(path: pathlib.Path) -> list[str]:
    problems, section = [], ''
    for number, line in enumerate(path.read_text(encoding='utf-8').splitlines(), 1):
        if line.startswith('## '):
            section = line.strip()
        if FILL.search(line):
            problems.append(f'{path}:{number}: unresolved placeholder')
        if BULLET.match(line):
            if not SHA.search(line):
                problems.append(f'{path}:{number}: bullet without provenance token')
            if section == USER_VISIBLE and not APPROVED.search(line):
                problems.append(f'{path}:{number}: user-visible bullet lacks approved-by')
    return problems


def git_commits(base: str, head: str) -> list[Commit]:
    raw = subprocess.run(
        ['git', 'log', '--no-merges', '--format=%H%x1f%s%x1f%b%x1e', f'{base}..{head}'],
        check=True, capture_output=True, text=True).stdout
    commits = []
    for record in raw.split('\x1e'):
        record = record.strip('\n')
        if not record:
            continue
        sha, subject, body = (record.split('\x1f') + ['', ''])[:3]
        commits.append(Commit(sha.strip(), subject.strip(), body.strip()))
    return commits


def main(argv=None) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument('--base')
    parser.add_argument('--head', default='HEAD')
    parser.add_argument('--out', type=pathlib.Path)
    parser.add_argument('--owners', type=pathlib.Path)
    parser.add_argument('--budget', type=int, default=40)
    parser.add_argument('--gate', type=pathlib.Path)
    args = parser.parse_args(argv)

    if args.gate:
        problems = gate(args.gate)
        print('\n'.join(problems) or f'{args.gate}: gate passed')
        return 3 if problems else 0

    owners = json.loads(args.owners.read_text()) if args.owners else {}
    packet, code = build_packet(git_commits(args.base, args.head), owners, args.budget)
    if args.out:
        args.out.write_text(json.dumps(packet, indent=2) + '\n', encoding='utf-8')
    print(f"{packet['commit_count']} commits, {packet['proposed_lines']} proposed lines "
          f"against a budget of {packet['budget']} (range {packet['range_hash']})")
    return code


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

The owner map is a small JSON file that names a human for each lane, so an unclassified commit still lands on somebody.

{
  "mechanical": "release-bot",
  "skeleton": "docs-oncall",
  "human-owned": "release-lead",
  "unclassified": "release-lead"
}
Enter fullscreen mode Exit fullscreen mode

Tests that pin the boundary

Four tests are enough to keep the boundary from drifting, and none of them needs a git repository.

# tests/test_docs_budget.py
from docs_budget import (Commit, HUMAN, build_packet, classify, gate)


def commit(sha, subject, body=''):
    return Commit(sha=sha, subject=subject, body=body)


def test_breaking_change_in_body_beats_subject_lane():
    c = commit('a' * 40, 'feat(api): add cursor pagination',
               'BREAKING CHANGE: ?page= is replaced by ?cursor=')
    assert classify(c) == (HUMAN, 'breaking-change')


def test_dependency_bump_produces_one_line():
    packet, code = build_packet(
        [commit('b' * 40, 'chore(deps): bump requests from 2.31.0 to 2.32.2')],
        {'mechanical': 'release-bot'}, budget=40)
    assert code == 0
    assert packet['entries'][0]['drafted'] == '- `requests` 2.31.0 -> 2.32.2 [sha:bbbbbbb]'


def test_skeleton_budget_is_enforced():
    commits = [commit(f'{i:040x}', 'fix(cli): correct exit code') for i in range(14)]
    packet, code = build_packet(commits, {'skeleton': 'docs-oncall'}, budget=40)
    assert code == 2
    assert packet['proposed_lines'] == 42


def test_gate_rejects_placeholder_and_missing_approval(tmp_path):
    notes = tmp_path / 'v1.5.0.md'
    notes.write_text('## User-visible changes\n'
                     '- <FILL:user-impact> [sha:aaaaaaa] [owner:docs-oncall]\n'
                     '- Cursor pagination replaces offset paging [sha:bbbbbbb]\n',
                     encoding='utf-8')
    problems = gate(notes)
    assert len(problems) == 3  # placeholder, plus two bullets without approved-by
Enter fullscreen mode Exit fullscreen mode

Worked run

The command below is the shape of a release job, and the printed line is illustrative output rather than a benchmark.

$ python docs_budget.py --base v1.4.0 --head v1.5.0 --budget 40 \
    --owners .github/docs-owners.json --out out/v1.5.0-draft.json
<illustrative> 86 commits, 19 proposed lines against a budget of 40 (range 4f1c9ab73d2e6c05)

$ python docs_budget.py --gate docs/releases/v1.5.0.md
docs/releases/v1.5.0.md:12: user-visible bullet lacks approved-by
exit status 3
Enter fullscreen mode Exit fullscreen mode

A packet entry for a human-owned change carries no text at all, only the trigger and the signer, which is what makes the lane real rather than advisory.

{
  "lane": "human-owned",
  "kind": "security-advisory",
  "sha": "9c1e0f4",
  "drafted": "",
  "lines": 0,
  "signer": "release-lead"
}
Enter fullscreen mode Exit fullscreen mode

The three publish gates

The budget gate fails the run when proposed lines exceed the release allowance, and it reports the largest contributors so the fix is visible. The placeholder gate refuses to publish while any <FILL: marker survives, which forces a human to write the user-visible wording instead of approving a template. The provenance gate requires a [sha:...] token on every bullet, and it requires an approved-by: marker inside the user-visible section only, where wording risk actually lives.

None of these gates claims that a statement is true. They only prove that a human wrote the risky lines, that every published bullet traces to a commit, and that the release did not quietly grow past its allowance.

Limitations and who should skip this

The classifier is regex-based, so conventional commit hygiene is a hard prerequisite, and teams that let merge commits through will misclassify freely. A forty-line budget is a starting number, not a measurement; ranges that bundle several teams will exceed it, and the correct response is to split the release rather than raise the cap. Omission rows are the riskiest automation in the whole pipeline, because a docs-only commit occasionally deserves a line in the notes, so keep a veto path for the release lead.

Skip this approach if you have no named documentation owner, since the lanes then have nowhere to land. Skip it if your release notes pass through legal or compliance review, because the gates reduce review effort but never replace it. Skip it as well if your commits do not follow a recognizable prefix convention, because an unclassified-heavy range means every entry lands in the human lane and the budget buys nothing.

If you want to run the drafting pass on a schedule without wiring a paid key first, MonkeyCode's free model access is one place to try it, with the free server option covering the periodic job; verify both against the product's current documentation before you commit to them. The lanes, the budget, and the three gates stay in your repository either way, which is the part that survives a model swap.

Top comments (0)