explanation
How a change ships
Written by the build · 2 September 2026 · owner @farhan-s · reviewed 2026-09-02
How a change ships
From typing code to a customer reading about it. One page, end to end.
The shape of it
Four things move, and they move at different speeds:
| What it is | Updated when | By whom | |
|---|---|---|---|
| Reference docs | docs/generated/ — jobs, env vars, API, permissions, data model |
every merge | a script, never a person |
| Internal changelog | CHANGELOG.md |
every release | release-please |
| Internal docs site | docs.bundul.io |
every docs change | CI, mirroring this repo |
| Customer changelog | the public page | when a person publishes | a person, from your one sentence |
The customer one is deliberately the only one a human gates.
Step by step
1. You open a pull request
You do four things, and nothing else:
| # | What | Why it matters |
|---|---|---|
| 1 | Write the PR title as a conventional commit | The type decides the version bump and which changelog section it lands in |
| 2 | Tick one Customer impact box | This alone decides whether a customer note exists, and when |
| 3 | Write one plain sentence for customers | It becomes the first draft of the release note |
| 4 | Add the headline label, only if it deserves a push and an email |
Without it, a feature is a normal improvement |
feat(payments): add dunning emails for failed collection
Customer impact: live-on-release
Customer summary: If a payment fails, we now email you straight
away with what to do.
Write that sentence now, not later. It takes fifteen seconds while the context is in your head, and it is archaeology in three weeks.
2. CI runs
| Check | Blocks? |
|---|---|
| PR title is a conventional commit | yes |
| Typecheck, production code | yes |
| ESLint, on the lines you changed only | yes |
docs/generated/ matches the code |
yes |
Smoke: the built entrypoint exists and the app boots and answers /health |
yes |
| Backend ↔ mobile contract | reports |
| Vocabulary drift — a live doc explaining a system the code no longer has | yes |
| Jest | no — 31 known failures |
If the docs check fails, run pnpm docs:generate and commit. CI will not do it
for you. That is deliberate: a bot committing to your branch is confusing, and the
regenerated file is something you should look at before it ships.
3. You merge
Squash-merge, so the PR title becomes the commit message. Then, automatically:
- dev deploys
docs/generated/is re-verified- if
docs/changed, the docs are pushed into the docs store, which is what the docs app reads - release-please opens or updates a release PR carrying the version bump and the written changelog
4. You cut a release
Merge the release PR. That tags the version and publishes a GitHub Release.
Production does not deploy. Nothing automatic deploys production — not a merge, not a release, not a tag. You ship it yourself:
Actions → Deploy to Production → Run workflow → pick the tag → Run
Pick the tag release-please made, so what ships is exactly what the changelog describes. Re-running against an older tag is a rollback.
5. The customer note
Only if you ticked something other than none or silent.
Add one markdown file to apps/public/content/changelog/ in
release-notes-and-docs, seeded from the sentence you wrote in step 1. Open a PR,
look at the preview, merge. That one file becomes the page, the permanent URL, the
RSS item and the JSON the app reads.
What determines what
Everything downstream is derived from these. Nothing else is consulted.
| Input | Decides |
|---|---|
PR title type (feat, fix, perf, chore…) |
version bump; changelog section; whether it is customer-facing at all |
| Customer impact field | whether a note is written, and when it can be published |
| Customer summary sentence | what the note says |
headline label |
push and email, or page only |
| files you touched | which generated pages change |
covers: frontmatter |
which hand-written docs get flagged as drifted |
| contract check | whether a backend feature is reachable by the app yet |
tools/docs/watched-terms.json |
which systems are checked for vocabulary drift |
The Customer impact field, in full
| You tick | What happens | When it publishes |
|---|---|---|
none |
Internal only. No note is ever written | never |
live-on-release |
Works on the app customers already have | as soon as you ship |
needs-app: <ver> |
Needs a mobile release that has not happened | held until that version ships |
silent |
Customers are affected but we are not announcing it — security fixes, pricing changes | never on the page |
Unsure? Pick needs-app: TBD. A held note costs nothing. A note published
before customers can use the thing costs support tickets.
What generates the content
| Content | Generated by | From |
|---|---|---|
docs/generated/*.md |
tools/docs/generate.ts |
the code itself — @Cron sites, the GraphQL schema, EmailTemplates, the push catalog, admin-permissions.ts, @Schema classes, .env.example, task definitions |
CHANGELOG.md |
release-please | commit messages since the last tag |
| version, tag, GitHub Release | release-please | the conventional commit types |
| internal docs site | tools/sync-internal-docs.mjs then a static build |
this repo's docs/, mirrored — nothing is authored there |
| customer changelog entry | you, seeded by your Customer summary | one markdown file |
| the customer page, RSS and app feed | the site build | that same one file |
Reference docs are never written by hand. If one is wrong, the generator is
wrong — fix tools/docs/generate.ts, not the page.
Three worked examples
A fix nobody outside will notice
fix(trueup): stop reconcile force-failing live transactions · impact none.
Merge → dev deploys, generated docs unchanged, release-please adds a line under Fixed. Ship when you like. No customer note is written at all. Your cost: one checkbox.
A backend change customers feel immediately
feat(payments): add dunning emails for failed collection · impact
live-on-release.
The generator notices four new entries in the EmailTemplates map, so
docs/generated/customer-emails.md changes and you commit it. Internal docs site
rebuilds. After you ship, you add one markdown file to the changelog. Tier is
Improvement, so it goes on the page and in the feed, and no push fires.
A feature that needs the app to catch up
feat(utility-insights): bill history and price-change alerts · impact
needs-app: TBD.
Internal docs update in full — new API entries, new push notifications, new required env vars. You ship the backend. The contract check reports the app cannot route the new deeplink yet.
So the customer note is written now and held. Open it as a draft PR on the
changelog repo. Later releases add to that same draft. When the app ships the
screen and the contract check goes green, fill in minAppVersion, merge, and
announce — at the moment customers can actually use it.
That is not hypothetical: utility bill insights is in exactly this state today. The backend sends a push whose deeplink the app does not handle, so it is deliberately absent from the changelog.
What is automated, and what is not
Being honest about this, because the diagram in your head is probably tidier than the system.
| Step | Today |
|---|---|
| Commit format enforced | automatic — local hook, plus CI on the PR title |
| Reference docs generated | automatic to check; you run pnpm docs:generate to update |
Version, tag, GitHub Release, CHANGELOG.md |
automatic |
| Dev deploy | automatic |
| Production deploy | manual, on purpose |
| Docs pushed to the store | automatic, once DOCS_SYNC_SECRET is set |
| Documentation updated after a code change | AI proposes, a person approves. Only when the vocabulary check finds real drift |
| Customer note drafted from your sentence | AI drafts, a person publishes. It also recommends whether to publish at all |
| Customer note published | manual, on purpose |
| Editing a written doc | in the docs app — no pull request, no git |
| Push / email announcement | not built — the page and feed are the only channels today |
| In-app What's New | not built — the feed it reads exists; the mobile sheet does not |
The two rules worth remembering
Nothing customer-facing publishes itself. Not a merge, not a release. A person writes the sentence and a person merges the note.
Nothing is announced that customers cannot reach. If the app cannot get to it, the note waits. The contract check is what tells you.