DEV Community

Sam Rivera
Sam Rivera

Posted on

Keep the Next Tag Out of the Prompt

The unreleased work was three commits and a README still waving at "soon." Tag 0.3.2 sat on the remote, quiet and accurate. The maintainer needed a short CHANGELOG paragraph before Monday, not a new product story.

A free remote editor was within reach. Grammar was not the risk. The risk was a model that would mint 0.4.0 because the notes sounded like a moment.

This writeup is a two-pass stitcher. Git remains the historian. The model, when it answers, is only a copy editor. Glue at the end reasserts the tag, the date, and the SHAs, even if the editor slept through the job.

Three commits past a quiet tag

Picture a tiny Python CLI, the kind a solo maintainer ships when the week has leftover hours. git log 0.3.2..HEAD --oneline shows a flag rename, a help-text fix, and a README blurb. None of that is a minor bump unless the maintainer says so.

The clock for this experiment is sixty minutes and zero dollars. If facts cannot be extracted from git without a network, the job is abandoned and CHANGELOG.md is left untouched. That abandon line is the whole point of splitting the work.

Vibe-written release notes fail in a specific way. They read well. They also invent milestones. Calling that output engineering is how a weekend paragraph becomes a lying tag.

Historian versus copy editor

A historian may only read git. Subjects, bodies, dates, and tags are facts. A copy editor may only change cadence and word choice of sentences that already exist on the facts tape.

The stitcher is glue. Glue is allowed to be ugly. Glue is not allowed to be creative about versions.

The split sounds fussy until a sleeping server enters the picture. Remote copy-edit is a convenience. If the box is down, the facts tape still prints a dry, honest paragraph. Silence is worse than a stiff sentence.

A solo builder can park that copy-edit step on MonkeyCode's free model access and free server option, treating the box as a disposable editor rather than a source of history. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The stitcher does not care which editor is used. It cares that pass two cannot mint a tag.

Do not bake a vendor hostname into the facts file. Declare one optional endpoint in the environment, then keep git as the only writer of versions.

A sixty-minute, zero-dollar box

The working directory is a throwaway clone. Cost stays at zero because pass one and the stitcher are local Python. The remote call is optional sugar. If it fails, the job still writes a facts-only draft under out/changelog.draft.md, never under CHANGELOG.md.

Rollback is a one-liner: delete out/ and leave the git tag alone. The maintainer does not git add the draft. The human copies a paragraph later, or they do not.

People who should skip this approach already have a release train, signed tags as a legal artifact, or a changelog that customers parse with a schema. This is for a one-person CLI that needs a paragraph, not a product org that needs a process.

Pass one writes facts.json from git

Pass one talks to git and nothing else. No prompts. No HTTP. The script below is a worked example a maintainer can run in a throwaway repo.

#!/usr/bin/env python3
"""extract_facts.py — local historian. No network."""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path


def git(*args: str) -> str:
    r = subprocess.run(
        ["git", *args], check=True, capture_output=True, text=True
    )
    return r.stdout.strip()


def main() -> int:
    tag = git("describe", "--tags", "--abbrev=0")
    date = git("log", "-1", "--format=%cs", tag)
    raw = git("log", f"{tag}..HEAD", "--format=%H%x09%s")
    commits = []
    for line in raw.splitlines():
        if not line.strip():
            continue
        sha, subject = line.split("\t", 1)
        commits.append({"sha": sha[:12], "subject": subject})
    if not commits:
        print("no commits after tag; abandon the draft", file=sys.stderr)
        return 2
    facts = {
        "current_tag": tag,
        "current_tag_date": date,
        "next_tag": None,
        "commit_count": len(commits),
        "commits": commits,
        "breaking": False,
    }
    Path("out").mkdir(exist_ok=True)
    Path("out/facts.json").write_text(json.dumps(facts, indent=2) + "\n")
    print(f"wrote out/facts.json for {tag} + {len(commits)} commits")
    return 0


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

next_tag stays null on purpose. Semver is a human decision. The model never receives a blank to fill. A Friday-night editor asked "what version is this" will answer with confidence and a wrong number.

A maintainer can seed a tiny repo if they want a replayable fixture.

mkdir -p /tmp/stitch-demo && cd /tmp/stitch-demo
git init -q
git config user.email "dev@example.com"
git config user.name "dev"
printf 'print("ok")\n' > app.py
git add app.py && git commit -qm "initial"
git tag 0.3.2
printf 'print("ok")\n# flag --quiet\n' > app.py
git add app.py && git commit -qm "rename --silent flag to --quiet"
printf 'print("ok")\n# flag --quiet\n# help\n' > app.py
git add app.py && git commit -qm "fix help text for --quiet"
printf '# tiny CLI\n' > README.md
git add README.md && git commit -qm "mention --quiet in README"
python3 extract_facts.py
Enter fullscreen mode Exit fullscreen mode

The facts tape should list three subjects and the tag 0.3.2. If next_tag is anything but null, the historian script has already failed.

Pass two may rewrite tone, never facts

Pass two reads out/facts.json and may produce out/tone.json with a single field: sentences, an array of strings, one per commit subject, same length, same order. No tag field. No date field. No SHA field. Extra keys are discarded by the stitcher, not merged.

The request is boring on purpose. A worked example that is allowed to miss the network looks like this.

#!/usr/bin/env python3
"""request_tone.py — optional copy editor. Network is allowed to fail."""
from __future__ import annotations

import json
import os
import urllib.error
import urllib.request
from pathlib import Path


def local_echo(facts: dict) -> dict:
    sentences = [c["subject"].rstrip(".") + "." for c in facts["commits"]]
    return {"sentences": sentences, "editor": "local-echo"}


def main() -> int:
    facts = json.loads(Path("out/facts.json").read_text())
    endpoint = os.environ.get("TONE_ENDPOINT", "")
    payload = {
        "instruction": (
            "Rewrite each subject as one calm customer sentence. "
            "Do not mention versions, tags, dates, or SHAs. "
            "Keep array length identical."
        ),
        "subjects": [c["subject"] for c in facts["commits"]],
    }
    tone = None
    if endpoint:
        try:
            req = urllib.request.Request(
                endpoint,
                data=json.dumps(payload).encode(),
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=20) as resp:
                tone = json.loads(resp.read().decode())
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
            tone = None
    if not isinstance(tone, dict) or not isinstance(tone.get("sentences"), list):
        tone = local_echo(facts)
    if len(tone["sentences"]) != facts["commit_count"]:
        tone = local_echo(facts)
    Path("out/tone.json").write_text(json.dumps(tone, indent=2) + "\n")
    print(f"wrote out/tone.json via {tone.get('editor', 'remote')}")
    return 0


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

The free server can sit behind TONE_ENDPOINT. It can also be absent. Local echo is a stiff copy editor that only adds a period. That is enough to keep Monday's draft honest when the remote box is asleep.

A length mismatch is treated as a failed edit, not as partial art. Models like to merge two bugfixes into one "several improvements" sentence. The stitcher would then lose a SHA. The echo fallback puts the missing subject back.

Glue that reasserts the tag

The stitcher is the only writer of the draft paragraph. It prints the current tag as the baseline, lists SHAs from facts, and uses tone sentences only as the human-readable clause. If a tone sentence contains a version-like token, that sentence is dropped and the original subject is used instead.

#!/usr/bin/env python3
"""stitch.py — glue. Reasserts history."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

VERSIONISH = re.compile(r"\bv?\d+\.\d+(?:\.\d+)?\b", re.I)


def clean(sentence: str, fallback: str) -> str:
    text = " ".join(sentence.split())
    if VERSIONISH.search(text):
        return fallback.rstrip(".") + "."
    return text.rstrip(".") + "."


def main() -> int:
    facts = json.loads(Path("out/facts.json").read_text())
    tone_path = Path("out/tone.json")
    sentences = [c["subject"] for c in facts["commits"]]
    if tone_path.exists():
        tone = json.loads(tone_path.read_text())
        got = tone.get("sentences") if isinstance(tone, dict) else None
        if isinstance(got, list) and len(got) == len(sentences):
            sentences = [
                clean(str(s), facts["commits"][i]["subject"])
                for i, s in enumerate(got)
            ]
        else:
            print("tone ignored: length mismatch", file=sys.stderr)
    lines = [
        f"## Unreleased (based on {facts['current_tag']})",
        "",
        (
            f"Recorded {facts['commit_count']} commits after "
            f"{facts['current_tag']} ({facts['current_tag_date']}). "
            "Next tag is unset."
        ),
        "",
    ]
    for commit, sentence in zip(facts["commits"], sentences):
        lines.append(f"- {commit['sha']}: {sentence}")
    Path("out/changelog.draft.md").write_text("\n".join(lines) + "\n")
    print("wrote out/changelog.draft.md")
    return 0


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

Run the three passes in order. The last command is the only one that still matters if the editor vanished.

python3 extract_facts.py
python3 request_tone.py
python3 stitch.py
cat out/changelog.draft.md
Enter fullscreen mode Exit fullscreen mode

The draft header still says Unreleased (based on 0.3.2). That sentence is boring. Boring is the quality bar for version lines.

The invented 0.4.0 tape

Here is the failure fixture the stitcher must survive. A copy editor, vibing, returns a heroic paragraph and a version.

python3 - <<'PY'
import json
from pathlib import Path
Path("out/tone.json").write_text(json.dumps({
    "sentences": [
        "This 0.4.0 release finally renames the silent flag.",
        "Help text is clearer for quiet mode.",
        "README now documents the flag for new users."
    ],
    "editor": "fixture-invented-tag"
}, indent=2) + "\n")
PY
python3 stitch.py
grep -n '0.4.0' out/changelog.draft.md && echo 'FAIL: invented tag leaked' || echo 'ok: invented tag stripped'
grep -n '0.3.2' out/changelog.draft.md
Enter fullscreen mode Exit fullscreen mode

The first sentence contained 0.4.0, so glue falls back to the git subject. The other two sentences may stay. The header still names 0.3.2. If grep finds 0.4.0 in the draft, the stitcher is theater and should be deleted rather than patched with another prompt.

A small keep-or-discard table keeps arguments short when a maintainer is tired.

Field Source of truth Copy editor may touch
current tag git describe no
tag date git log no
commit SHA git no
subject git tone only, same index
next tag human, or null never
breaking human boolean in facts.json never
CHANGELOG.md human copy-paste never

The last row is the one people skip. A draft that lands in CHANGELOG.md from a cron job will eventually ship an invented milestone. Keeping the file under out/ is not a workflow religion. It is a cheap way to make git status look loud.

Rollback, and who should skip this

Rollback stays local. Remove out/, keep the tag, keep the commits. Nothing in this job should have pushed a tag or opened a GitHub release. If a maintainer already ran git tag 0.4.0 because a paragraph sounded ready, that is outside the stitcher. Delete the tag only if it never left the laptop.

rm -rf out
# git tag -d 0.4.0   # only if the tag was local, never pushed, and actually created
Enter fullscreen mode Exit fullscreen mode

Skip this stitcher when the project already has release-please, semantic-release, or a human editor who writes CHANGELOG by hand faster than git can be parsed. Skip it when commit subjects contain customer names or secrets. Pass one will copy those subjects into facts.json with no redaction. That is a reason to stop, not a reason to add a clever filter on night one.

The free model path is optional. The free server may sleep without a courtesy note. Pass two times out and local echo takes over. That limitation is acceptable for a weekend CLI. It is not acceptable for a paid changelog product.

What the stitcher does not prove

This job does not score prose quality. It does not pin a model name, because free editors move. It does not prove that --quiet was a good rename. It only proves that the version line still belongs to git after a copy-edit pass.

It will not turn vibe output into engineering. It will keep a confident 0.4.0 from landing in a file a tired maintainer might commit. For a one-person CLI, that is the small thing worth doing in an hour.

If a free editor is already in the loop, copy the stitcher and the invented-tag fixture rather than another prompt. The missing field worth recording next is a human-set breaking boolean in facts.json, so the draft can print a breaking banner even when pass two never returns.

Top comments (0)