Building & Shipping

My Auto-Changelog Has a Do-Not-Market List

Brett Ridenour Brett Ridenour · Published September 2026

Every solo builder eventually writes a script that turns git log into a changelog. I did too. Then I used it for a week and hated it.

The problem is that git log is a lie by omission of context. A commit called fix: reservation total recalc after refund looks fine on the page and reads like a feature announcement to a customer who didn’t know the total was ever wrong. A commit called harden webhook signature check reads like a security disclosure the second you publish it. refactor: extract quote-version service shouldn’t be on the marketing site at all.

Dumping every merge into a public changelog isn’t a shortcut. It’s a liability with a nice CSS card wrapper.

So the interesting part of my release pipeline — the one command that runs after every main→production promotion on Freebo — isn’t the changelog it writes. It’s the private ledger of everything it refuses to write.

The one command

/freebo-ship auto is a Claude Code skill that runs inside the Freebo repo. It does one thing: turns the git range between the last published release and origin/production into whatever content the marketing site owes the world for that range. New feature page, updated feature page, roadmap flip from “planned” to “shipped”, release entry, occasional announcement blog post. Opens the whole thing as a PR against main. I merge, production promotes, the notification handoff runs. That’s the loop.

1
Preflight
Fetch, find newest release date, load suppression ledger + fixes-log tail.
2
Scan & cluster
Walk commits. Bucket each into STORY / FIX / INTERNAL / SUPPRESSED.
3
Write
STORY clusters become release + feature-page changes in a fresh worktree.
4
Fixes log
FIX commits appended to the private, in-repo, never-rendered log.
5
Gate & PR
Astro build must succeed. One commit, gh pr create against main. Never merges itself.

Steps 1, 3, and 5 are the boring parts. Step 2 is the whole product.

Four buckets, one rule per commit

Every commit in the range gets classified into exactly one of four buckets. The rule I care about isn’t which bucket a commit goes to. It’s that a bucket other than STORY exists at all.

  • STORY — a real new capability or a meaningful upgrade a guest or operator can actually see. Clusters into a release. Becomes/updates a feature page. Sometimes earns a blog post.
  • FIX — a bug fix with user-visible effect. Never a release. Never a blog post. Never marketed. Appended to a private log instead.
  • INTERNAL — observability, tooling, refactors, agent skills, CI, docs. Ignored. Listed one-line in the run report so I can see they existed.
  • SUPPRESSED — matches the do-not-market ledger. Ignored plus noted.

The FIX rule has one carve-out and I made it explicit because I kept trying to cheat it: a fix cluster so substantial that it changes what the product credibly promises — “rescheduling overhaul” is the example — can be reframed as an IMPROVED story. The reframing has to be capability-forward. “We fixed bugs” is never a marketing sentence, even when it’s the honest one.

The four buckets

The private fixes-log

The fixes go somewhere. That’s the part I’m proud of.

There is a file at docs/content-system/fixes-log.md in the Freebo repo. It’s tracked in git. It’s greppable. It has one dated section per shipping run. Every FIX-bucket commit gets one line: date, one-line user-facing effect, PR reference, commit SHA. It never renders on the website. It’s not in the sitemap. It’s not linked from anywhere public.

Listed somewhere, but not obvious.

— Freebo /freebo-ship skill

That phrase is load-bearing. It’s what I want a support conversation to look like when an operator asks “did you ever fix that thing where refunds double-counted in the ledger”. I want to answer yes and paste the SHA. I don’t want a “Refund Fixes 2026” tag page ranking for the phrase “refund bug” on Google.

Every fix ships. Every fix is recorded. Every fix is auditable. No fix is a marketing announcement.

The suppression ledger

Next to the fixes log there’s another file: docs/content-system/do-not-market.md. This one is the actual do-not-market list. It’s a small set of rules the shipping skill has to load and obey before it writes anything:

  • PRs marked “internal only” for reasons that aren’t obvious from the diff.
  • Systems that exist but that I’m not ready to talk about publicly yet.
  • Whole topic areas that don’t belong on a marketing site regardless of how shippable the underlying work is — security posture is the obvious one. The public statement about a security fix is “hardened X.” The mechanics never ship. The vulnerability details never ship. Not because I’m being cagey; because the read on the other side is “attackers now know exactly what to look for in older customer instances.”

The ledger is small on purpose. If it grows past a page or two, something is wrong upstream — either I’m shipping things I shouldn’t be shipping under the current product framing, or the marketing site’s positioning has drifted from what the product actually does. It’s a smell more than a wall.

The gotcha that almost broke it

The subtle failure that took a whole run to catch: I had the pipeline reading the newest published release date from the local working tree instead of from origin/main. My local checkout was one commit behind, so the “last release” date it computed was a week older than what was actually live. Result: it re-marketed a bunch of features that had already shipped, in a new release entry, with new page copy.

The fix is a one-liner in principle and important in practice:

git ls-tree --name-only origin/main apps/website/src/content/releases/
git show origin/main:<path> | grep ^date:

Always read the state from the remote. Never trust the working tree to know what “shipped” means.

Shipping ends with content

The framing I keep coming back to: shipping isn’t done at the deploy. It’s done when the site, the changelog, the roadmap, and the notifications reflect what shipped. If those are a separate chore, they don’t happen — I’ll always pick the next feature over updating a marketing page. If they’re a byproduct of shipping, they happen for free.

But “for free” is doing a lot of work in that sentence. Nothing about turning a git log into a public site is free. The commits don’t know what they mean. The classification is the whole product. The privacy is the whole product.

The changelog you can see on the marketing site is the boring output. The interesting file is the one you can’t see — the one recording every bug the platform ever quietly fixed. Both files got written by the same command, from the same commit range, in the same run. One earned a page. The other earned an SHA and a paragraph in a file only I read.

The world gets what the world can use. The rest gets recorded and moves on.