Skip to content

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)”
  1. All four export presets use bundle id digital.lumara.bulletheaven, so a devicectl dev install replaces the TestFlight/App Store app on Chris’s devices and shares its saves.
  2. 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. -Dev presets are stale at version 17.
  3. Dev-only modes are const flags in main.gd (PERF_BENCH, AUTO_SHOWCASE, MEASURE_FULL_QUALITY, SHIP3D_MEASURE, RECORD_REPLAY), switched by editing code, so one can be committed true and ship.
  4. There’s no record of which commit shipped, so there’s no clean way to hotfix a store build after main has moved on.
  5. Telemetry, feedback and remote config can’t tell dev play, TestFlight testers and App Store players apart. Rows carry only build and a device string.
# 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}.
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.mm save bridge keys NSUserDefaults per bundle, so dev saves are separate automatically.
  • The dev app has no App Store Connect record and never uploads.
  • Sim_Const.BUILD is 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.sh stops taking a build-number argument. It bumps BUILD, writes the same value into application/version of all four presets, dumps the remote-config manifest, builds, uploads and tags.
  • bh-deploy‘s BUILD bump also writes the -Dev presets’ application/version.
  • application/short_version stays manual. Bump it per store version (1.0.1 next) once Apple has approved the previous one.
  • release.sh creates an annotated tag store-<BUILD> on the commit it built, after a successful upload, and pushes it. store-37 was tagged by hand (it predates the unified numbering).
  • Hotfix: git switch -c release/1.0 store-N, fix, then release.sh from that branch. It tags store-<BUILD> there, and the fix merges back to main. release.sh refuses to run from any branch other than main or release/*.
  • The BUILD counter has to stay unique across branches: a hotfix takes the next number after main’s current BUILD, and the merge back resolves the BUILD line to the higher value.
  • Client. A new net/build_channel.gd (class_name BuildChannel, render/net side, never in /sim) exposes BuildChannel.current() -> String.
    • dev if BuildConfig.dev_tools().
    • Otherwise it reads a marker the native layer writes at launch. Default appstore if 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].lastPathComponent is sandboxReceipt, write user://channel.txt = testflight, otherwise appstore.
    • [Likely] This is the standard TestFlight check. Plan task: confirm it on a real TestFlight install before relying on it. Note that appStoreReceiptURL is deprecated on newer SDKs; if it has been removed, switch to StoreKit 2 AppTransaction.shared.environment, which needs a Swift shim.
  • Payloads. Every POST in net/telemetry.gd, net/gameplay_telemetry.gd, net/dash_encounter_telemetry.gd and the feedback sender adds "channel". net/remote_config.gd adds &channel= to GET /config.
  • Worker + D1. ALTER TABLE <t> ADD COLUMN channel TEXT on samples, runs, encounters, feedback, hitches, and schema.sql is updated to match. Old rows stay NULL, which means “pre-channel”. The dashboard defaults to channel='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 to testflight first.
    • 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.
  • Move PERF_BENCH, AUTO_SHOWCASE, SHIP3D_MEASURE, RECORD_REPLAY and MEASURE_FULL_QUALITY from main.gd consts into a DevFlags holder (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_S etc.) stay consts.
    • First confirm with the cleanup audit whether any of these are fully dead; delete those rather than port them.
  • ADAPTIVE_QUALITY is 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.cfg and fail if a non--Dev preset has dev in custom_features, or a -Dev preset has the store bundle id.
    • Fail if a store preset’s application/version != Sim_Const.BUILD.
    • Fail if DevFlags’ defaults aren’t all false.

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.

  • Channel detection defaults to appstore on any failure (see §4).
  • The worker accepts a missing or unknown channel and stores NULL. It never rejects a request.
  • release.sh fails loud: dirty tree, wrong branch, BUILD bump 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.
  • GUT:
    • BuildChannel pure core: dev / testflight / appstore / missing marker → appstore / garbage marker → appstore.
    • tests/test_release_presets.gd (§5).
    • DevFlags forced false outside dev.
  • Worker: extend telemetry/test for the channel column and channel-scoped resolveConfig.
  • Scripts: release.sh with NO_UPLOAD=1 bumps all four presets to BUILD and 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)”
  1. Channel detection is GDScript, not dummy.mm. The native bridge exists only on tvOS (iOS still ships Godot’s stock dummy.cpp), so a native check would mean converting iOS too. BuildChannel looks for the StoreKit/sandboxReceipt / receipt file itself, in both the app bundle and the data container. Every row also carries a channel_probe value (sandbox@container etc.), so the first TestFlight build proves where the receipt lives. If it’s in neither location, a native AppTransaction check becomes a follow-up. Until then TestFlight counts as appstore, which is the safe direction.
  2. Per-channel remote config uses KV keys build-N@<channel>, looked up before build-N, instead of a channel list inside the patch. No JSON parsing in the Worker, and publish-config.mjs --channel stays trivial.
  3. A fourth channel value, web, for the browser demo, which already sends telemetry.
  4. 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.
  5. The dashboard channel filter is deferred. The columns exist for SQL (bh-telemetry skill); 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.
  6. SHIP3D_MEASURE and MEASURE_FULL_QUALITY are deleted, not ported to DevFlags. One is a disabled spike that’s broken on Metal, the other a measurement loop closed for launch.
  1. Unified numbering + release.sh tagging + preset guard test. Pure tooling, lands first.
  2. Dev app (bundle-id overrides in bh-deploy, dev icon), verified on device.
  3. Channel: client + worker + D1 migration, worker first, since it tolerates old clients.
  4. DevFlags migration.
  5. The first 1.0.1 TestFlight build carries all of it; check the channels in D1.