This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Release day goes like this. The code is merged, the tag is pushed, and someone has to tell users what changed. You open the log and you get this:
7c3b9d0 chore(deps): bump vite from 5.2.1 to 5.2.8
3d7f1b2 fix(sync): offline edits were sometimes overwritten when reconnecting
c9e04aa refactor: move toolbar state into a store
f03c2e9 feat!: drop support for Safari 15
Commit messages are written for the person reviewing the diff, not the person using the product. Half of them shouldn't be shown at all, and the other half need rewriting. Most maintainers either paste the raw log or skip release notes entirely.
ShipNotes turns that log into release notes a user can read. You paste commits (or point it at a public GitHub repo and a tag range) and you get:
- a Markdown changelog grouped into New / Improved / Fixed, with breaking changes flagged and PR numbers linked
- a short "What's new" paragraph for a tweet or the top of an email
- a list of every commit it left out, and why
- English or Simplified Chinese output
The model is Gemma 3 1B, open weights, running through llama.cpp on one CPU thread of a small VPS. No API key, no per-token bill, and if you host it yourself your private commit messages never leave your server.
The side hustle: a hosted version at around $3/month for indie developers and OSS maintainers. That only works because the marginal cost of a release is zero. I have no users and no revenue yet. This post is the first time anyone besides me has seen it.
Demo
Live: https://shipnotes.23-95-112-86.sslip.io
Click Write release notes to run the sample, or switch to Try it on a public repo. The repo field is prefilled with simonw/llm; leave the refs empty and it compares the two newest tags.
Please be patient with it. It runs on one shared CPU core and handles one request at a time, so a 15-commit release takes 30 to 50 seconds, and you may wait in line behind someone else. Lines stream in as they're written. The stats row at the bottom shows what the run cost: tokens per second, how much of the prompt came from cache, and $0.00.
Real output
The two newest tags of simonw/llm (0.35 β 0.36): 17 commits, 11 kept. ShipNotes left out Release 0.36, Added types to make mypy happy, Fix for Sphinx warnings, Fix for CRLF test failure, Set Black target-version to 3.10, and a test-fixture refactor. Part of what it wrote:
### New
- Added new models gpt-6-sol and gpt-6-luna.
### Fixed
- Fixed default model options when constructing prompts directly. (#1663)
- Fixed AsyncResponse.log_to_db. (#1668)
- Fixed a KeyError 'options_json' in the 'llm logs -t' markdown output. (#1656)
- Fixed an error in the provider generator. (#1659)
httpie/cli 3.2.3 β 3.2.4: 10 commits in, 4 out the other side (a version bump, Update test.yml, two cert-loading cleanups, and a README edit were dropped):
### New
- Added an alias for HTTPS in fish completions. (#1598)
### Improved
- Improved the SSL context creation process by explicitly loading default certificates. (#1596)
- Improved the JSON data structure for the example. (#1606)
### Fixed
- Fixed a broken link to the CurliPie tool. (#1582)
And the built-in sample, which is made up so it shows every case:
### New
- Added a keyboard shortcut to duplicate a block. (#212)
- You can now export a page as a PDF file. (#219)
### Improved
- Debounced queries, results appear 2x faster.
- Improved the error message when an invite link has expired.
- **Breaking:** Removed support for Safari 15.
### Fixed
- Fixed an issue where images were missing their alt text.
- Fixed potential data loss during offline editing.
- Fixed the dark mode toggle not being saved after reload.
What's new: v1.4.0 is out. This release includes a keyboard shortcut to duplicate blocks, the ability to export pages as PDFs, fixed missing alt text on images, improved offline editing, and a saved dark mode toggle.
It isn't perfect, and I'd rather show you where it slips than have you find out yourself:
-
Fix for CRLF test failure-style commits are dropped, butre.split maxsplit will be positional only in the future(a deprecation fix nobody needs to read about) got through and came out as "Improved the re.split function to only use positional arguments in the future." That's wrong. - "Improved the JSON data structure for the example" was a docs example update. The model made it sound like a code change.
- "Fixed potential data loss during offline editing" is fair, but the commit said "sometimes overwritten when reconnecting". The model rounded it up.
- The "What's new" paragraph is the weakest part. It sometimes describes a fix as a feature ("a saved dark mode toggle"). Chinese output is noticeably weaker than English, and for the sample the Chinese announcement failed validation and fell back to a plain template.
All of the examples are in the repo under examples/, including the raw event streams with timings.
Code
ShipNotes
Release notes your users can read, written by a 1B open model on a $5 server.
ShipNotes takes a list of git commits and gives you back:
- a Markdown changelog grouped into New / Improved / Fixed, in plain English (or Simplified Chinese)
- a short "What's new" announcement you can paste into a tweet or the first line of an email
- a list of the commits it left out, and why
Live demo: https://shipnotes.23-95-112-86.sslip.io
It runs Gemma 3 1B (open weights, Q4_K_M GGUF) through llama.cpp on one CPU thread. There is no API key, no per-token bill, and commit messages from private repos never leave the machine.
How it works
The trick to making a 1B model reliable is to give it as little to decide as possible.
commits βββΊ parse βββΊ rules: drop internal βββΊ rules: pick section βββΊ Gemma: rewrite ONE line βββΊ guards βββΊ Markdownβ¦MIT licensed.
About 900 lines of Python with no pip dependencies (standard library only), one HTML file, two systemd units, an nginx config with rate limits, and an install.sh that downloads llama.cpp and the model.
shipnotes/rules.py parsing, filtering, classification, fallback phrasing (no model)
shipnotes/llm.py prompts, prefill, output guards
shipnotes/pipeline.py commits in, streamed events out
shipnotes/gitsource.py public GitHub compare API
shipnotes/server.py HTTP server, one-at-a-time queue, result cache
static/index.html the whole UI
How I Built It
Start from the hardware, not the model
I started by measuring. On a shared 1 vCPU VPS (Intel Xeon Gold 5220R), llama-bench with Gemma 3 1B Q4_K_M on one thread gave:
| tokens/sec | |
|---|---|
| prompt processing (pp32) | 27.5 |
| generation (tg16) | 5.7 |
Five and a half tokens per second. Watching a sentence appear at that speed sets the design for you. A chain-of-thought prompt, or a "here are 40 commits, write a changelog" prompt, would take minutes and give a 1B model too many chances to wander. So the rule became: code decides, the model only phrases.
Everything that can be a rule is a rule
The model never decides whether a commit is shown or which section it goes in. rules.py does, and it has unit tests:
-
Drop: conventional types
chore,ci,build,refactor,test,style,docs, thedepsscope, merges, version bumps, bot authors (dependabot[bot]), and, for repos that don't use conventional commits, regexes for lint/format/typo, CI files, test fixtures, mypy and Sphinx. -
Keep, always: anything marked breaking (
feat!:or aBREAKING CHANGEfooter), even if its type would be dropped. -
Classify: conventional types map straight to sections. Otherwise the first verb decides:
addmeans New,fixmeans Fixed,improve/makemeans Improved.
The model gets one commit at a time and has to give back one sentence.
The bug my own fix caused
The first version let the model pick its own opening words. It wrote "Fixed an error message when a configuration file is missing" for a commit that improved an error message. Small, but wrong. So I started prefilling the model's answer with a first word chosen by the rules: the response begins Note: Fixed or Note: Added, and the model only continues the sentence.
That fixed the wording. It also produced the scariest output of the whole project:
feat!: drop support for Node 16 β "Added support for Node 16."
It was a feat, so the rules said "New", so I prefilled "Added", and the model did its best to finish the sentence. A changelog that says the opposite of what happened is worse than none. Now removals (drop, remove, delete) get Removed, deprecations get Deprecated, and things a user does (export a page as PDF) get "You can now". That last one also fixed "Added a page as PDF", which I had seen the run before.
The 4x speedup was a single flag
Each call sends the same ~230-token instruction and few-shot prefix, then a ~15-token commit. llama.cpp can cache the prefix, but the logs showed cache_n: 0 on every call. Each commit took 9β10 seconds, all of it spent re-reading instructions it had just read.
The cause: Gemma 3 uses sliding-window attention, and by default llama.cpp keeps a trimmed SWA cache that can't be partly reused. Starting the server with --swa-full fixed it. Now roughly 88% of prompt tokens come from cache, and a commit takes about 2.5 seconds. I would never have guessed this one. I only found it by printing the timing fields on every response.
Guards for when the model cuts corners
A 1B model likes to smooth things over. Fix AsyncResponse.log_to_db came back as "Fixed an error in the asynchronous response log." That reads nicely, but a developer can no longer search for it. So every rewrite is checked: if the commit contains a literal token (a --flag, inline code, a dotted.name or snake_case identifier, a number like 2x), the output has to contain it too. If it doesn't, the line falls back to a rule-based rewrite (Fix X β Fixed X.), and the UI marks it by rules. On the simonw/llm run, 5 of 11 lines went that way. That's the right call for a developer-tools changelog.
A second pass deletes filler like "critical", "seamlessly" and trailing ", ensuring a smoother experience" clauses. One run had called a routine fix "a critical error".
Two more lessons from the same few hours:
- Few-shot examples leak. My Chinese prompt had an example about a sidebar width. The model then described a dark-mode fix as a fix for⦠the sidebar width. I replaced the example with one that shares no words with typical UI commits.
-
Don't let a 1B model spell your product's name. Given the title
httpie/cli 3.2.4, the announcement came back as "3.2.4 of the ieee-cli". Now the prompt never contains the name. The model starts with "This releaseβ¦" and code adds the title in front.
Being a polite tenant
The demo shares a small VPS with other services, so:
- llama-server runs with
-t 1 -c 2048 -np 1,Nice=10,CPUWeight=50andMemoryMax=1300Munder systemd - the web app is standard-library Python:
ThreadingHTTPServer, one generation at a time behind a lock, a queue of four, and an in-memory cache, so repeating a request is instant and nothing is written to disk - nginx allows 6 generation requests per minute per IP, with a 64 KB body cap
Measured numbers
All taken on the live box (1 shared vCPU, Xeon Gold 5220R, llama.cpp b11716, Gemma 3 1B Q4_K_M):
| Generation speed (live runs) | 5.3β5.8 tokens/sec |
| Prompt speed (uncached tokens) | 20β25 tokens/sec |
| Prompt tokens served from cache | ~88% |
Time per commit, before / after --swa-full
|
~9.5 s / ~2.5 s |
| Built-in sample (14 commits, 8 kept) | 29β33 s end to end |
| simonw/llm 0.35 β 0.36 (17 commits, 11 kept) | 43β52 s |
| httpie/cli 3.2.3 β 3.2.4 (10 commits, 4 kept) | 21β23 s |
| Model load time | ~6 s |
| llama-server peak memory | 1.02 GB RSS |
| Web app memory | 24 MB RSS |
| Model file | 806 MB |
| Cost per run | $0.00 |
Why $3/month can work
At about 2.5 seconds of CPU per commit, one core could in theory rewrite around 1,400 commits an hour. That's roughly a million a month, or tens of thousands of typical releases. Real use would be much burstier than that, and the queue would need more than one core long before the theory ran out. But the economics are simple: the box costs a few dollars a month whether it's idle or busy, and two subscribers pay for it. Nothing in the bill grows with each request.
Why Does Open Innovation Matter?
Three reasons. Each one changed how this thing works.
Privacy you can check, not just trust. Commit messages from private repos are full of things you don't want on someone else's server: unreleased features, customer names in branch names, security fixes before disclosure. Because Gemma's weights are open, ShipNotes can run entirely on your own machine. install.sh sets up the same two services the demo runs, and nothing leaves the box except an optional call to the public GitHub API.
Costs that make small products possible. A side project priced at $3 a month can't carry a per-token bill that grows with every user. Open weights on a cheap CPU turn the cost into a flat monthly fee. That's the difference between a tool you can afford to give indie maintainers and one you can't.
You can see inside the whole stack, so you can fix it. The biggest win in this project came from reading llama.cpp's timing output and learning how Gemma's sliding-window cache behaves, then flipping one flag. The second biggest came from controlling the exact prompt bytes and prefilling the answer, which some hosted chat APIs don't allow. Open models, open runtimes and open source code mean that when something is slow or wrong, you can find out why.
ShipNotes is MIT-licensed. If you maintain a project and the rules drop something they shouldn't (or keep something they shouldn't), that's a one-line regex and a unit test. PRs are very welcome.
Prize Categories
Best Use of Gemma. ShipNotes runs Gemma 3 1B (open weights, Q4_K_M GGUF) via llama.cpp on a single CPU thread. The project is built around what a 1B model does well: code handles classification and grouping, the model rewrites one short line at a time, the opening word is prefilled, the prompt prefix stays cached thanks to --swa-full and Gemma's sliding-window attention, and literal-token guards catch its mistakes. Every number in this post was measured on the live demo.


Top comments (0)