Build channels: dev / beta / store pipeline — design
Build channels: dev / beta / store pipeline — design
Section titled “Build channels: dev / beta / store pipeline — design”Date: 2026-09-28 · Status: approved in conversation (Chris), awaiting spec review
Context: 1.0 (store build 37 = Sim_Const.BUILD 206, tag store-37) is in App Store review.
Once it’s live, the dev → TestFlight → App Store flow has to cope with real players.
The work items are tracked in docs/RELEASE-1.0.1.md.
Problems this fixes (all verified in the repo 2026-09-28)
Section titled “Problems this fixes (all verified in the repo 2026-09-28)”- All four export presets use bundle id
digital.lumara.bulletheaven, so adevicectldev install replaces the TestFlight/App Store app on Chris’s devices and shares its saves. - There are two build counters:
Sim_Const.BUILD(206), which telemetry and remote config use, and the store build (37), which App Store Connect uses.-Devpresets are stale at version 17. - Dev-only modes are
constflags inmain.gd(PERF_BENCH,AUTO_SHOWCASE,MEASURE_FULL_QUALITY,SHIP3D_MEASURE,RECORD_REPLAY), switched by editing code, so one can be committedtrueand ship. - There’s no record of which commit shipped, so there’s no clean way to hotfix a store build
after
mainhas moved on. - Telemetry, feedback and remote config can’t tell dev play, TestFlight testers and App Store
players apart. Rows carry only
buildand a device string.
Decisions (Chris, 2026-09-28)
Section titled “Decisions (Chris, 2026-09-28)”| # | Decision |
|---|---|
| D1 | Dev builds are a separate app: bundle id digital.lumara.bulletheaven.dev, display name “Dark Cosmos Dev”, dev-badged icon, own saves, installs alongside the store/TestFlight app. |
| D2 | One build number everywhere: store build (CFBundleVersion) = Sim_Const.BUILD. Next store upload is 207, not 38. |
| D3 | Tag every store upload store-N. Branch release/<version> from the tag only when a hotfix is needed; fix there, upload, merge back to main. main stays the only everyday branch. |
| D4 | Every telemetry / feedback / remote-config request carries channel ∈ {dev, testflight, appstore}. |
Design
Section titled “Design”1. Channels
Section titled “1. Channels”| Dev | Beta | Store | |
|---|---|---|---|
| Bundle id | digital.lumara.bulletheaven.dev |
digital.lumara.bulletheaven |
same as beta (same binary) |
| Name / icon | “Dark Cosmos Dev”, badged icon | Dark Cosmos | Dark Cosmos |
| Presets | tvOS-Dev, iOS-Dev (custom_features="dev") |
tvOS, iOS |
— |
| Path | bh-deploy → devicectl install |
scripts/release/release.sh → TestFlight internal → asc.py beta external |
Chris submits an approved TestFlight build |
channel |
dev |
testflight |
appstore |
Dev app mechanics. bh-deploy reuses the signed Xcode shells (build/, build-ios/) and
only re-exports the .pck. The shells’ project.pbxproj holds the store bundle id. The dev app is
produced by xcodebuild overrides, not a second shell:
PRODUCT_BUNDLE_IDENTIFIER=digital.lumara.bulletheaven.dev, INFOPLIST_KEY_CFBundleDisplayName="Dark Cosmos Dev",
an alternate app-icon set via ASSETCATALOG_COMPILER_APPICON_NAME, and development signing with
-allowProvisioningUpdates.
- Verify first (plan task 1): confirm that automatic development signing registers the new
App ID itself. [Likely] If it doesn’t, create it through the App Store Connect API
(
POST /v1/bundleIds), or ask Chris. - The tvOS
patches/dummy.mmsave bridge keys NSUserDefaults per bundle, so dev saves are separate automatically. - The dev app has no App Store Connect record and never uploads.
2. Build numbering
Section titled “2. Build numbering”Sim_Const.BUILDis the only counter. Every dev install and every store upload bumps it. Store build numbers skip values, which Apple allows (it only requires an increase per version).release.shstops taking a build-number argument. It bumpsBUILD, writes the same value intoapplication/versionof all four presets, dumps the remote-config manifest, builds, uploads and tags.bh-deploy‘s BUILD bump also writes the-Devpresets’application/version.application/short_versionstays manual. Bump it per store version (1.0.1 next) once Apple has approved the previous one.
3. Branching and tags
Section titled “3. Branching and tags”release.shcreates an annotated tagstore-<BUILD>on the commit it built, after a successful upload, and pushes it.store-37was tagged by hand (it predates the unified numbering).- Hotfix:
git switch -c release/1.0 store-N, fix, thenrelease.shfrom that branch. It tagsstore-<BUILD>there, and the fix merges back tomain.release.shrefuses to run from any branch other thanmainorrelease/*. - The BUILD counter has to stay unique across branches: a hotfix takes the next number after
main’s currentBUILD, and the merge back resolves theBUILDline to the higher value.
4. Channel field
Section titled “4. Channel field”- Client. A new
net/build_channel.gd(class_name BuildChannel, render/net side, never in/sim) exposesBuildChannel.current() -> String.devifBuildConfig.dev_tools().- Otherwise it reads a marker the native layer writes at launch. Default
appstoreif the marker is missing or unreadable, so a detection failure can never mark real players as test traffic.
- Native (iOS + tvOS). In
platform/tvos/patches/dummy.mm, the existing launch hook that already restores the meta save: if[[NSBundle mainBundle] appStoreReceiptURL].lastPathComponentissandboxReceipt, writeuser://channel.txt=testflight, otherwiseappstore.- [Likely] This is the standard TestFlight check. Plan task: confirm it on a real TestFlight
install before relying on it. Note that
appStoreReceiptURLis deprecated on newer SDKs; if it has been removed, switch to StoreKit 2AppTransaction.shared.environment, which needs a Swift shim.
- [Likely] This is the standard TestFlight check. Plan task: confirm it on a real TestFlight
install before relying on it. Note that
- Payloads. Every POST in
net/telemetry.gd,net/gameplay_telemetry.gd,net/dash_encounter_telemetry.gdand the feedback sender adds"channel".net/remote_config.gdadds&channel=toGET /config. - Worker + D1.
ALTER TABLE <t> ADD COLUMN channel TEXTonsamples,runs,encounters,feedback,hitches, andschema.sqlis updated to match. Old rows stay NULL, which means “pre-channel”. The dashboard defaults tochannel='appstore'with a selector.resolveConfig(kv, build, channel): a patch may carry an optional channel list. It applies only when the list is absent or includes the request’s channel, so a balance patch can go totestflightfirst.- Workers deploy through the usual telemetry deploy path, serialized.
- Old builds (≤ 206) send no channel. The worker treats a missing value as NULL; it’s never guessed.
5. Dev-only switches
Section titled “5. Dev-only switches”- Move
PERF_BENCH,AUTO_SHOWCASE,SHIP3D_MEASURE,RECORD_REPLAYandMEASURE_FULL_QUALITYfrommain.gdconsts into aDevFlagsholder (runtime vars).- The Debug Settings panel sets them, and it only exists when
BuildConfig.dev_tools()is true. - In non-dev builds they are forced false at startup, whatever their value.
- The tuning numbers (
PERF_BENCH_DWELL_Setc.) stay consts. - First confirm with the cleanup audit whether any of these are fully dead; delete those rather than port them.
- The Debug Settings panel sets them, and it only exists when
ADAPTIVE_QUALITYis a product setting, not a dev mode, so it stays out of scope (it’s on the 1.0.1 triage list).- Structural guard, a GUT test
tests/test_release_presets.gd:- Parse
platform/tvos/export_presets.cfgand fail if a non--Devpreset hasdevincustom_features, or a-Devpreset has the store bundle id. - Fail if a store preset’s
application/version!=Sim_Const.BUILD. - Fail if
DevFlags’ defaults aren’t all false.
- Parse
6. Out of scope
Section titled “6. Out of scope”The rename (done 2026-09-28: tiers 1-2 in 66a2f2b/51a4211, tiers 3-4 deliberately left);
the code-cleanup audit (docs/research/2026-09-28-code-cleanup-audit.md, on the 1.0.1 queue);
a Mac App Store build; a separate telemetry database for dev.
Error handling
Section titled “Error handling”- Channel detection defaults to
appstoreon any failure (see §4). - The worker accepts a missing or unknown channel and stores NULL. It never rejects a request.
release.shfails loud: dirty tree, wrong branch,BUILDbump mismatch, upload failure (no tag is written if the upload fails).- A dev-app signing failure stops
bh-deploy. It never falls back to installing under the store bundle id.
Testing
Section titled “Testing”- GUT:
BuildChannelpure core: dev / testflight / appstore / missing marker → appstore / garbage marker → appstore.tests/test_release_presets.gd(§5).DevFlagsforced false outside dev.
- Worker: extend
telemetry/testfor thechannelcolumn and channel-scopedresolveConfig. - Scripts:
release.shwithNO_UPLOAD=1bumps all four presets toBUILDand doesn’t tag. - On device (Chris’s iPhone + Apple TV):
- The Dev app installs next to the TestFlight app, and both launch with separate saves.
- D1 rows show
channel='dev'from the Dev app and'testflight'from a TestFlight build.
- Determinism baselines are untouched: nothing here is in
/sim.
Amendments (2026-09-28, from writing the plan — docs/superpowers/plans/2026-09-28-build-channels-pipeline.md)
Section titled “Amendments (2026-09-28, from writing the plan — docs/superpowers/plans/2026-09-28-build-channels-pipeline.md)”- Channel detection is GDScript, not
dummy.mm. The native bridge exists only on tvOS (iOS still ships Godot’s stockdummy.cpp), so a native check would mean converting iOS too.BuildChannellooks for theStoreKit/sandboxReceipt/receiptfile itself, in both the app bundle and the data container. Every row also carries achannel_probevalue (sandbox@containeretc.), so the first TestFlight build proves where the receipt lives. If it’s in neither location, a nativeAppTransactioncheck becomes a follow-up. Until then TestFlight counts asappstore, which is the safe direction. - Per-channel remote config uses KV keys
build-N@<channel>, looked up beforebuild-N, instead of a channel list inside the patch. No JSON parsing in the Worker, andpublish-config.mjs --channelstays trivial. - A fourth channel value,
web, for the browser demo, which already sends telemetry. - The dev icon badge is iOS only. tvOS icons are layered brand assets built by
fix-tvos-appstore-icons.py; the dev app on Apple TV is identified by its name. - The dashboard channel filter is deferred. The columns exist for SQL (
bh-telemetryskill); the HTML dashboard has around 12 queries to thread a filter through. Add it only if the SQL queries prove to be too much friction. SHIP3D_MEASUREandMEASURE_FULL_QUALITYare deleted, not ported toDevFlags. One is a disabled spike that’s broken on Metal, the other a measurement loop closed for launch.
Rollout order
Section titled “Rollout order”- Unified numbering +
release.shtagging + preset guard test. Pure tooling, lands first. - Dev app (bundle-id overrides in
bh-deploy, dev icon), verified on device. - Channel: client + worker + D1 migration, worker first, since it tolerates old clients.
- DevFlags migration.
- The first 1.0.1 TestFlight build carries all of it; check the channels in D1.