How a code change becomes a live build that the user gets a banner / sheet about, end to end.
All nine components ship independently. There is no atomic "release"; each subproject bumps its own
components.<X>.buildon its own cadence. The convergence point isversion.jsonat the repo root — a manifest read by/api/version(dashboard) andios:build(Realtime) and consumed by every client that cares. (The component list is version.json-driven — when a component is added there, add a row here too.)
The components
version.json currently defines nine components:
| Component | Manifest key | Display label | Source of truth | Public host |
|---|---|---|---|---|
| iOS app (BusymateHelper) | components.ios | ios | ios/BusymateHelper.xcodeproj/project.pbxproj (CURRENT_PROJECT_VERSION × 6 configs) + TestFlight | ios.busymate.net → 302 → TF deep-link |
| Dashboard | components.dashboard | dash | web/dashboard/package.json build | dash.busymate.net |
| Docs site | components.docs | docs | web/docs/package.json build | docs.busymate.net |
| Proxy server | components.proxy-server | proxy | web/proxy-server/package.json build | proxy.busymate.net |
| Supabase | components.supabase | api | supabase/migrations/* + Edge Functions | api.busymate.net |
cdp-connector (bmc) | components.cdp-connector | cdp | cli/cdp-connector/package.json build | cdp.busymate.net (installer + tarball; the CLI runs on your machine) |
| Status board | components.status | status | web/status/package.json build | status.busymate.net |
| Marketing site | components.marketing | web | web/marketing/package.json build | busymate.net (apex) |
| BusyBro | components.busybro | bro | busybro/package.json build (mirror) + the busybro-bot / ask / busybro-threads Edge fns | bro.busymate.net |
version.json's top-level build field is the max(components.*.build). It exists for the Telegram notifier headline + legacy single-counter consumers; nothing on the hot path reads it.
Two-layer build identity (post-build-124)
There are two different "build" numbers in version.json. They mean different things and they're allowed to disagree.
- Top-level
build— monorepo deploy counter. The max of all component builds. components.<X>.build— per-component "what is actually deployed for X". Authoritative for/api/versionconsumers (iOS BuildUpdateChecker, dashboard BuildUpdateWatcher).
The split exists because of two prior incidents:
- Build 114→115 (TF auto-increment) — local archive said 114, ASC processed as
1.0 - 115(TF had reserved 114 for an earlier failed upload). We bumped the monorepo to 115 to reconcile. - Build 117 (web-only ship) — top-level was bumped to 117 for a dashboard fix, but iOS hadn't shipped. The iOS app's update checker read the top-level value and prompted users to install build 117 from TestFlight — which didn't exist.
The new field surgically fixes #2: components.ios.build only moves when fastlane beta succeeds and ASC reports a build number.
The "inc before deploy" rule
Every ship MUST start with a build bump. Concretely:
| You're shipping | Bump first |
|---|---|
| iOS | pbxproj CURRENT_PROJECT_VERSION × 6 configs (Debug + Release × main app + tunnel ext + LA ext). Do NOT touch version.json until fastlane confirms — see below. |
| Dashboard | version.json components.dashboard.build AND web/dashboard/package.json build. Recompute top-level build = max(components). |
| Docs | version.json components.docs.build AND web/docs/package.json build. Same recompute. |
| Proxy server | version.json components.proxy-server.build AND web/proxy-server/package.json build. Same recompute. |
| Supabase | version.json components.supabase.build. (No nested package.json; the function deploy + migration apply is the build counter.) |
| cdp-connector | version.json components.cdp-connector.build AND cli/cdp-connector/package.json build. Same recompute. The dashboard rebuilds + serves the tarball, so a connector ship also needs a dashboard deploy (see Path B). |
Mismatches between these locations are bugs. The notifier diffs against notes/.last-ship-state.json and flags every component on every fire.
Parallel sessions — ship.mjs + the pre-push gate
When several Claude Code sessions run in parallel (one per component), conflicts
only ever arise in the shared files — version.json, README.md,
notes/next-ship.md, notes/.last-ship-state.json. Component subtrees and
deploy targets are disjoint and never collide. Three tools keep everyone in sync:
scripts/sync-version.mjs—version.jsonis the one source of truth for every component's name/label/host/description/version/build. Edit it, run this, and thepackage.jsonmirrors (+ the iOS pbxproj display name) match.--checkfor CI.scripts/gen-readme.mjs— regenerates the README banner + status table fromversion.json(between<!-- VERSION:banner -->/<!-- VERSION:table -->markers). README is no longer hand-edited, so it can't collide.--checkfor CI..githooks/pre-push— blocks any push that would drift (sync-version --check+gen-readme --check+sync-audit). Activate per clone withscripts/setup-hooks.sh(git config core.hooksPath .githooks). Bypass only withgit push --no-verify.
The one command — scripts/ship.mjs <component> serializes a release behind a
repo-local lock (.git/ship.lock) so two sessions can't race the shared files:
node scripts/ship.mjs dashboard # bump → sync → gen-readme → audit → commit → rebase → push → deploy → notify
node scripts/ship.mjs docs --no-deploy # everything except the deploy
node scripts/ship.mjs proxy-server -m "fix: ..."It bumps only that component's build, propagates, regenerates the README, hard-
gates on sync-audit, stages only that component's subtree + the shared files
(guarding against png/env), commits, git pull --rebasees onto latest main
(retrying the push once if the remote moved), deploys that component, and fires
notify-telegram --component=<x> (so the iOS APNs push stays deferred). iOS is
not a ship.mjs target — its build is ASC-authoritative; ship it with
fastlane beta (which now runs sync-version.mjs pre-archive to source the
display name from version.json).
Working rules for parallel sessions: stay on main, one session owns one
component subtree, write notes/next-ship.md before shipping, and let ship.mjs
do the bump/push/deploy — never hand-edit version.json top build or the README
banner/table.
Recovery from a runaway session / OOM. Long Claude Code sessions grow the
terminal's memory; when it balloons you have to quit and restart. scripts/gen-warp-config.mjs
writes a Warp Launch Configuration (~/.warp/launch_configurations/busymate-devtools.yaml)
with one tab per component (sourced from version.json components.<x>.path) —
each tab opens in the component's directory and runs claude --continue, resuming
that directory's most recent session. After a restart: quit Warp → ⌘P → "Open a
Launch Configuration" → busymate-devtools, and every component session comes
back in place. Re-run the generator whenever a component is added/renamed/moved
(--no-resume to cd-only, --cmd "…" for a custom per-tab command). It doesn't
fix the memory growth — it makes recovery one launch.
Prune oversized sessions with scripts/sessions.mjs — lists every
session for the repo's project(s) by size (with its customTitle name + an
⏱active flag for anything touched in the last 15 min), and safely archives the
big ones (--prune --over 50M --yes → moves the .jsonl + subagent dir to
~/.claude/.session-archive/, never the active session, never a hard delete).
A monorepo-root session can reach 300M+; archiving those is the direct fix for
the terminal OOM. Going per-component (one small session per dir, via the launch
config) keeps them small in the first place.
Path A — iOS ship (the hard path)
Step-by-step
1. Bump pbxproj. Six CURRENT_PROJECT_VERSION lines need updating in lockstep — main app + tunnel ext + LA ext × Debug + Release configs. sed -i '' 's/CURRENT_PROJECT_VERSION = X;/CURRENT_PROJECT_VERSION = X+1;/g' ios/BusymateHelper.xcodeproj/project.pbxproj. Verify with grep -c.
2. Update changelog notes. Two files are read by the ship hooks:
notes/next-ship-tf.md— iOS-only "What to test" changelog. Surfaced in TestFlight's tester email. Keep it user-facing — no implementation prose, no cross-component scope.notes/next-ship.md— cross-component summary read by the Telegram notifier when the channel falls back from the per-row diff to a freeform body. Optional but recommended.
3. Run fastlane beta. From ios/. Requires ~/.config/appstoreconnect/{env, key.p8} (ES256-signed API JWT).
Output you're looking for: Successfully finished processing the build 1.0 - N for IOS. That N is what ASC accepted. If your local pbxproj said N-1, ASC auto-incremented — reconcile both locations to N.
4. Update version.json. Set components.ios.build = N (ASC reality). Recompute top-level build = max(components.*.build).
5. Redeploy the dashboard. Even on iOS-only ships. The dashboard reads version.json from its on-disk deployed copy and exposes it at /api/version. Without this redeploy, the iOS update checker (and the Realtime broadcast) still see the old build, so the prompt won't fire on devices.
cd web/dashboard && npm run deploy6. Fire the notifier. node scripts/notify-telegram.mjs ship does four things:
- Diffs
version.json.componentsagainstnotes/.last-ship-state.json. - If nothing changed → silent skip (idempotent — safe to call after every deploy regardless of what shipped).
- Renders the Telegram HTML table and posts.
- If the iOS row diffed, calls
notify-ios-buildover HTTPS — broadcasts on Realtime AND fans APNs alert pushes. - Saves the new state file (sha + sentAt + components snapshot).
7. The fan-out. notify-ios-build (supabase/functions/notify-ios-build/index.ts) runs in Deno on the Supabase Edge Runtime:
- Validates
Authorization: Bearer <NOTIFY_IOS_BUILD_SECRET>. - POSTs to
${SUPABASE_URL}/realtime/v1/api/broadcastsigned with the service role key — payload lands on Realtime channelios:buildas eventbuild-available. - Queries
device_push_tokensfor every active row withkind='device', signs an APNs ES256 JWT, fansapns-push-type: alertrequests toapi.push.apple.com(production tokens) orapi.sandbox.push.apple.com(debug tokens) withapns-collapse-id: ios-build-availableso a re-fire doesn't queue duplicate banners. - Returns
{ broadcast: {ok, status}, apns: {sent, total, results: [...] } }.
8. iOS receives through one of three paths:
- Foreground:
BuildUpdateChecker.startRealtime()subscribed toios:buildat app init. TheonBroadcast(event: "build-available")handler decodespayload.build, callsapplyServerBuild(), flipsshowSheetifN > localBuild. Sheet pops within ~100 ms of the broadcast landing. - Background: System receives the APNs alert and renders the banner — no app code runs (unless the alert had
content-available: 1, which ours doesn't). User sees "Build N is on TestFlight. Tap to install." Tap → returns to the app where the next.taskcycle callscheckNow()and the sheet pops. - Terminated: Same APNs path. The system delivers the banner without the app process; tap launches the app cold,
BuildUpdateChecker.init()runs,.taskmodifier firescheckNow(), and the inline banner renders on first frame once the server build lands.
The convergence point in all three paths is BuildUpdateChecker.applyServerBuild(N) setting serverIOSBuild = N, from which isOutdated = N > localBuild derives. When outdated, the home screen shows an inline update banner above the readiness card (SetupGuideView.updateBanner) — there is no modal sheet. Tapping Update opens https://ios.busymate.net, which 302-redirects to the current /v1/app/<APP_ID>?build=N deep-link.
Path B — Web component ship (the easy path)
Dashboard / docs / proxy-server / supabase / cdp-connector all follow the same shape (cdp-connector has no service of its own — it rides a dashboard deploy, which rebuilds and serves its installer + tarball):
The notifier hook is iOS-specific. Web-only ships fire Telegram but never the broadcast / push path — that's correct: nobody's iOS app needs to update because the iOS build didn't move.
Per-component deploy commands
# Dashboard
cd web/dashboard && npm run deploy
# → ssh ubuntu@busymate.net 'cd ~/busymate-devtools && git pull && cd web/dashboard && npm install && npm run build && sudo systemctl restart busymate-dashboard'
# Docs
cd web/docs && npm run deploy
# → same shape; systemd unit kept as `busymate-md-server` for backward compat
# Proxy server
ssh ubuntu@busymate.net 'cd ~/busymate-devtools && git pull && cd web/proxy-server && npm install && sudo systemctl restart busymate-proxy'
# Supabase
supabase db push # migrations
supabase functions deploy <fn> --project-ref xfjplaganjqowkcnznbr # edge functions
# (or via the Supabase MCP `deploy_edge_function` tool)
# cdp-connector (bmc) — no systemd unit of its own
cd web/dashboard && npm run deploy
# the dashboard build rebuilds /install.sh + /bmc.tar.gz from cli/cdp-connector/,
# so deploying the dashboard publishes the new connector. Users then self-update:
# bmc upgrade (or re-run the install one-liner from dash.busymate.net/install.sh)
# Status board (systemd busymate-status)
cd web/status && npm run deploy
# Marketing site (systemd busymate-marketing, apex busymate.net)
cd web/marketing && npm run deploy
# BusyBro — code lives under supabase/functions/; busybro/ is the home + build mirror
cd busybro && npm run deploy
# → deploys the busybro-bot, ask, and busybro-threads Edge functionsThe post-ship table (three surfaces)
After every ship, the same component table renders in three places — by design:
- Telegram — emitted by
scripts/notify-telegram.mjs. Monospaced cells inside<code>for column alignment, hostname per row as the clickable<a href>. - README.md top — first thing in the file. Bumped in the same commit that bumps
version.json. - Chat reply — when the developer reports a successful ship, the same table closes the message.
Same row order everywhere, fixed in notify-telegram.mjs's COMPONENT_ORDER so the table reads identically each fire — by label: web, api, ios, cdp, bro, dash, docs, proxy, status (manifest keys marketing, supabase, ios, cdp-connector, busybro, dashboard, docs, proxy-server, status). Any future component falls to the end in natural order. ● for components that bumped this fire; ○ for unchanged. Build cell shows (N) or (prev → curr).
The state file
notes/.last-ship-state.json holds the previous-ship snapshot:
{
"sha": "<HEAD SHA at time of last fire>",
"sentAt": "<ISO timestamp>",
"components": { ... full components map ... }
}The notifier reads this on every ship invocation, computes diffComponents(manifest, prevState), and exits silently if nothing differs. After a successful fire, it writes the new state.
This file is committed to git — checked in alongside the version bump so the next ship's diff is deterministic regardless of which machine fires the notifier.
Endpoints under the hood
| URL | Purpose |
|---|---|
https://busymate.net/version | Manifest read by anyone who cares; same JSON as /api/version. nginx apex proxies to dashboard :3838/api/version. |
https://dash.busymate.net/api/version | Internal canonical endpoint. iOS BuildUpdateChecker polls here. |
https://ios.busymate.net | 302-redirect to current TF ?build=N deep-link. Backed by dashboard /api/ios-redirect route. |
https://<ref>.supabase.co/functions/v1/notify-ios-build | Edge function — bearer-authed POST → Realtime broadcast + APNs fan-out. |
wss://<ref>.supabase.co/realtime/v1 + channel ios:build | Public broadcast channel. iOS app subscribes anonymously (with publishable key). |
wss://<ref>.supabase.co/realtime/v1 + channel ws:<workspace_id> | Per-workspace entries firehose — unrelated to shipping, separate plumbing. |
Recovery / edge cases
ASC auto-incremented past your pbxproj
Happens when a prior failed upload reserved a build number. The fix:
- Set
components.ios.build = N(what ASC reported). sed -i '' 's/CURRENT_PROJECT_VERSION = <pbx>;/CURRENT_PROJECT_VERSION = N;/g' ios/BusymateHelper.xcodeproj/project.pbxprojso the next archive isN + 1.- Redeploy dashboard.
- Fire the notifier — it'll show
(prev → N)correctly.
Notifier fires twice for the same ship
Idempotent by design. The state file holds prev_build = N. The second ship call diffs N against N → nothing changed → silent skip.
To force a re-fire (e.g., testing): node scripts/notify-telegram.mjs ship --force=1. Telegram + push both go out again; the APNs collapse-id ensures the banner doesn't duplicate on a user's lock screen.
Realtime is down
The APNs path is independent — devices still get banners. When Realtime comes back, the next bump's broadcast goes out normally. There's no "replay" of missed broadcasts; FG devices that missed the broadcast still see the sheet via the .active polling on the next foreground tick.
A device is force-quit and has notifications disabled
The user never gets a banner. They'll see the sheet on the next app launch via .task { await buildUpdate.checkNow() }. This is intentional — BuildUpdateChecker is the floor, not the ceiling.
The dashboard never deploys after a fastlane succeeds
/api/version continues to advertise the old components.ios.build. The iOS app's polling sees server == local and stays silent. The TF tester emails fire normally, but no in-app banner. Always redeploy the dashboard after an iOS ship.
What lives where
| Concern | File / location |
|---|---|
| Manifest | version.json (repo root) |
| State (last fire) | notes/.last-ship-state.json |
| Notifier | scripts/notify-telegram.mjs |
| Edge function | supabase/functions/notify-ios-build/index.ts |
| Function secret | Supabase project env NOTIFY_IOS_BUILD_SECRET + ~/.config/busymate/telegram.env (notifier-side) |
| iOS update checker | ios/BusymateHelper/Services/BuildUpdateChecker.swift |
| iOS update banner | ios/BusymateHelper/Views/SetupGuideView.swift (updateBanner, driven by BuildUpdateChecker.isOutdated) |
| iOS APNs delegate | ios/BusymateHelper/Services/BusymateAppDelegate.swift |
| iOS push token register | ios/BusymateHelper/Services/PushTokenRegistrar.swift |
| iOS push tokens table | public.device_push_tokens (Postgres) |
| nginx apex | /etc/nginx/sites-enabled/busymate.net.conf (VPS) |
| nginx ios redirect | /etc/nginx/sites-enabled/ios.busymate.net.conf (VPS) |
| iOS Fastfile | ios/fastlane/Fastfile |
| ASC API creds | ~/.config/appstoreconnect/{env, key.p8} |
| APNs auth key (server) | APNS_AUTH_KEY_P8 Supabase secret (P8 PEM) |