Build channels pipeline — implementation plan
Build Channels Pipeline Implementation Plan
Section titled “Build Channels Pipeline Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo, code-writing subagents arebh-implementer(model opus), and each branch is reviewed bybh-reviewerbefore merge.
Goal: Separate dev / beta / store builds so that:
- a dev install never replaces the store app;
- one build number means one thing everywhere;
- every store upload is tagged;
- telemetry and remote config know which channel a request came from;
- dev-only modes can’t ship.
Architecture:
- One shared script bumps the only build counter (
Sim_Const.BUILD) into all four export presets.release.shuses it and tagsstore-N. - A dev install script reuses the signed Xcode projects, overriding the bundle id, name and icon at
xcodebuildtime. - A small
BuildChannelclass (render/net side, never in/sim) picksdev/testflight/appstore/web. Every telemetry/feedback/config request carries it, and the Worker stores it and can serve per-channel config patches. DevFlagsreplaces the dev-only consts inmain.gdwith runtime flags. They’re forced off outside dev builds and guarded by a test.
Tech Stack: Godot 4.7 typed GDScript + GUT, bash, Cloudflare Worker (JS, node --test), D1, KV, Xcode xcodebuild/devicectl.
Spec: docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md (read its “Amendments” section: this plan implements the amended version).
Global Constraints
Section titled “Global Constraints”- Nothing in this plan touches
/simexcept theconst BUILDline insim/constants.gd. The determinism baselines must stay green without a re-pin. /simnever referencesBuildChannelorDevFlags(both use OS/FileAccess, which is render-side only).- Channel values are exactly
dev,testflight,appstore,web. Any detection failure yieldsappstore. - Dev bundle id:
digital.lumara.bulletheaven.dev. Dev display name:Dark Cosmos Dev. Store bundle id staysdigital.lumara.bulletheaven. - Store build number (
application/versionin the tvOS/iOS presets) ==Sim_Const.BUILD. Tags arestore-<BUILD>. release.shruns only frommainorrelease/*, with a clean tree.- Run the full gate
bash scripts/gate.shbefore every merge. Commit anyproject.godotedit before running the gate:gate.sh:32runsgit checkout -- project.godotand silently discards uncommitted edits. - Single GUT script:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<script_name_without_.gd> -gexit(-gtestdoes NOT narrow the run). - Worker tests:
cd telemetry && npm test. Script tests:node --test scripts/test/. - Worker deploy, D1 migration and device installs are outward-facing. Confirm with Chris on the day before running them, and serialize deploys.
- Commit messages end with the session’s attribution lines (see the repo’s recent commits).
git push origin mainafter every merge.
File Structure
Section titled “File Structure”| File | Responsibility |
|---|---|
scripts/bump-build.sh (new) |
The ONLY way to bump the build number: Sim_Const.BUILD+1 → all four presets’ application/version. Prints the new N. |
scripts/release/release.sh (modify) |
No argument; branch/clean guards; calls bump-build.sh; tags + pushes store-N after a successful upload. |
scripts/dev/install-dev.sh (new) |
Build + install the Dark Cosmos Dev app to a named device (atv/iphone/ipad). |
scripts/dev/make-dev-icon.sh (new) |
Creates the badged AppIcon-Dev.appiconset inside the (gitignored) iOS Xcode shell from its AppIcon.appiconset. |
net/build_channel.gd (new) |
class_name BuildChannel: pure classify() core + current() (OS-backed, cached). |
meta/dev_flags.gd (new) |
class_name DevFlags: static runtime dev flags, loaded from user://dev_flags.json only in dev builds. |
tests/test_build_channel.gd, tests/test_dev_flags.gd, tests/test_release_presets.gd (new) |
GUT coverage. |
net/telemetry.gd, net/gameplay_telemetry.gd, net/dash_encounter_telemetry.gd, net/remote_config.gd (modify) |
Add channel to every request. |
telemetry/src/worker.js, telemetry/schema.sql, telemetry/test/channel.test.mjs (new test) |
Store channel; per-channel config resolution. |
scripts/publish-config.mjs (+ its test) |
--channel <c> publishes to KV key build-N@<c>. |
main.gd, ui/debug_settings_panel.gd (modify) |
Consume DevFlags; delete dead SHIP3D_MEASURE/MEASURE_FULL_QUALITY. |
.claude/skills/bh-deploy/SKILL.md, .claude/skills/bh-appstore-release/SKILL.md, docs/architecture/dev-tools.md, docs/architecture/remote-config.md |
Docs follow the code. |
Task 1: One build counter (bump-build.sh) + preset guard test
Section titled “Task 1: One build counter (bump-build.sh) + preset guard test”Files:
- Create:
scripts/bump-build.sh - Create:
tests/test_release_presets.gd - Modify:
sim/constants.gd:6(comment only) - Modify:
platform/tvos/export_presets.cfg(application/versionof all four presets →"206")
Interfaces:
-
Produces:
bash scripts/bump-build.shprints the new build number N on its last stdout line and exits 0. It editssim/constants.gdandplatform/tvos/export_presets.cfg. It does NOT commit. -
Produces:
bash scripts/bump-build.sh --syncwrites the currentBUILD(no increment) into all four presets. Used once here, and available for repair later. -
Step 1: Write the failing test
tests/test_release_presets.gd
extends GutTest
# Structural guard for the dev/beta/store split (spec 2026-09-28-build-channels-pipeline-design.md).# Parses the tracked export presets directly: a store preset that carries the "dev" feature, a dev# preset that signs as the store app, or a store build number that drifted from Sim_Const.BUILD all# fail here instead of on a player's device.
const PRESETS := "res://platform/tvos/export_presets.cfg"const STORE_ID := "digital.lumara.bulletheaven"
func _presets() -> Array[Dictionary]: var cf := ConfigFile.new() assert_eq(cf.load(PRESETS), OK, "export_presets.cfg must be readable at " + PRESETS) var out: Array[Dictionary] = [] var i := 0 while cf.has_section("preset.%d" % i): var s := "preset.%d" % i var o := s + ".options" out.append({ "name": str(cf.get_value(s, "name", "")), "features": str(cf.get_value(s, "custom_features", "")), "bundle": str(cf.get_value(o, "application/bundle_identifier", "")), "version": str(cf.get_value(o, "application/version", "")), }) i += 1 return out
func test_there_are_four_presets() -> void: var names := _presets().map(func(p): return p.name) assert_eq(names, ["tvOS", "iOS", "tvOS-Dev", "iOS-Dev"])
func test_store_presets_never_carry_the_dev_feature() -> void: for p in _presets(): if not p.name.ends_with("-Dev"): assert_false("dev" in p.features.split(","), "%s must not carry the dev feature" % p.name)
func test_dev_presets_carry_the_dev_feature() -> void: for p in _presets(): if p.name.ends_with("-Dev"): assert_true("dev" in p.features.split(","), "%s must carry the dev feature" % p.name)
func test_every_preset_version_equals_sim_build() -> void: for p in _presets(): assert_eq(p.version, str(Sim_Const.BUILD), "%s application/version must equal Sim_Const.BUILD (run scripts/bump-build.sh)" % p.name)
func test_store_presets_use_the_store_bundle_id() -> void: for p in _presets(): if not p.name.ends_with("-Dev"): assert_eq(p.bundle, STORE_ID, p.name)(The dev presets keep the store id in the file on purpose. The dev id is applied at xcodebuild time by scripts/dev/install-dev.sh, Task 3, because the presets share one Xcode project.)
- Step 2: Run it and confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_release_presets -gexit
Expected: test_every_preset_version_equals_sim_build FAILS (presets say 37/17, BUILD is 206). The others pass. If cf.load fails instead, Godot can’t read under platform/tvos/. Stop and report; don’t work around it.
- Step 3: Write
scripts/bump-build.sh
#!/usr/bin/env bash# The ONLY way to change the build number (spec 2026-09-28-build-channels-pipeline-design.md §2).# Sim_Const.BUILD is the single counter; store build (CFBundleVersion) == BUILD for every preset.# scripts/bump-build.sh BUILD += 1, write it into all four presets, print N# scripts/bump-build.sh --sync write the CURRENT BUILD into all four presets (no increment)# Edits sim/constants.gd + platform/tvos/export_presets.cfg; never commits.set -euo pipefailROOT=$(cd "$(dirname "$0")/.." && pwd); cd "$ROOT"C=sim/constants.gdP=platform/tvos/export_presets.cfgold=$(grep -oE '^const BUILD: int = [0-9]+' "$C" | grep -oE '[0-9]+$')[ -n "$old" ] || { echo "::error:: no 'const BUILD: int = N' in $C" >&2; exit 1; }if [ "${1:-}" = "--sync" ]; then new=$old; else new=$((old+1)); fised -i '' -E "s/^const BUILD: int = $old\b/const BUILD: int = $new/" "$C"grep -qE "^const BUILD: int = $new\b" "$C" || { echo "::error:: BUILD bump failed" >&2; exit 1; }awk -v n="$new" '/^application\/version=/{$0="application/version=\"" n "\""} 1' "$P" > "$P.tmp" && mv "$P.tmp" "$P"[ "$(grep -c "^application/version=\"$new\"" "$P")" = 4 ] || { echo "::error:: expected 4 presets at version $new" >&2; exit 1; }echo "$new"chmod +x scripts/bump-build.sh
- Step 4: Fix the
BUILDcomment and sync the presets
In sim/constants.gd:6 replace the trailing comment so the line reads:
const BUILD: int = 206 # change ONLY via scripts/bump-build.sh; == store build number from 207 on (206 = store build 37)Run: bash scripts/bump-build.sh --sync. Expected output: 206, and grep -c 'application/version="206"' platform/tvos/export_presets.cfg prints 4.
- Step 5: Run the test and confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_release_presets -gexit
Expected: 5/5 pass.
- Step 6: Check that
bump-build.shincrements and restores cleanly
Run: bash scripts/bump-build.sh && git diff --stat && git checkout -- sim/constants.gd platform/tvos/export_presets.cfg && bash scripts/bump-build.sh --sync
Expected: first prints 207 and the diff shows 2 files; after the checkout + sync, the presets are back at 206 (git diff shows only the Step 4 changes).
- Step 7: Commit, then run the full gate
git add scripts/bump-build.sh tests/test_release_presets.gd sim/constants.gd platform/tvos/export_presets.cfggit commit -m "feat(release): single build counter (bump-build.sh) + preset guard test"bash scripts/gate.shExpected: GATE PASS.
Task 2: release.sh — no argument, branch guard, store-N tag
Section titled “Task 2: release.sh — no argument, branch guard, store-N tag”Files:
- Modify:
scripts/release/release.sh(whole file) - Modify:
.claude/skills/bh-appstore-release/SKILL.md(§0 and the “Scripted since” note)
Interfaces:
-
Consumes:
scripts/bump-build.sh(Task 1) → prints N. -
Produces:
scripts/release/release.sh(no args). EnvEXTERNAL=1,NO_UPLOAD=1as before. On a successful upload it creates and pushes annotated tagstore-N. WithNO_UPLOAD=1it never tags. -
Step 1: Replace
scripts/release/release.sh
#!/usr/bin/env bash# TestFlight release, both platforms. Usage: scripts/release/release.sh (no argument)# - bumps Sim_Const.BUILD by 1 and writes it into all four presets (scripts/bump-build.sh);# the store build number IS BUILD (spec 2026-09-28-build-channels-pipeline-design.md §2)# - dumps the remote-config manifest, builds + uploads iOS then tvOS, waits for processing# - tags the built commit store-<N> and pushes the tag (never with NO_UPLOAD=1)# - EXTERNAL=1 also attaches N to the external groups and submits beta review.# Runs only from main or release/* with a clean tree. Commit the bump yourself afterwards# (docs(release): build N). App Store (not beta) submission is Chris's.set -euo pipefailROOT=$(cd "$(dirname "$0")/../.." && pwd); cd "$ROOT"[ $# -eq 0 ] || { echo "release.sh takes no argument any more: the store build number is Sim_Const.BUILD+1"; exit 2; }git diff --quiet && git diff --cached --quiet || { echo "working tree dirty; commit first"; exit 1; }BR=$(git rev-parse --abbrev-ref HEAD)case "$BR" in main|release/*) ;; *) echo "release.sh runs only from main or release/* (on '$BR')"; exit 1 ;; esacN=$(bash scripts/bump-build.sh | tail -1)echo "==> store build $N (Sim_Const.BUILD) on $BR"godot --headless --path . -s res://tools/remote_config/dump_manifest.gdgit add remote-config/manifest/bash scripts/release/build-ios.shbash scripts/release/build-tvos.sh[ -z "${NO_UPLOAD:-}" ] || { echo "NO_UPLOAD set: not uploading, not tagging"; exit 0; }. scripts/release/env.shpython3 scripts/release/asc.py "$([ -n "${EXTERNAL:-}" ] && echo beta || echo wait)" "$N"# Tag what was built. The bump itself is still uncommitted here, so commit it first; the tag must# point at a commit whose BUILD == N.git add sim/constants.gd platform/tvos/export_presets.cfg remote-config/manifest/git commit -q -m "chore: BUILD $N = store build $N" && git tag -a "store-$N" -m "Store build $N ($BR)" && git push -q origin "$BR" "store-$N"echo "==> tagged + pushed store-$N"- Step 2: Check the guards without building
Run each and check the message and exit code:
-
bash scripts/release/release.sh 207: prints “takes no argument”, exit 2. -
git switch -c tmp-guard && bash scripts/release/release.sh; git switch main && git branch -D tmp-guard: prints “runs only from main or release/*”, exit 1. -
touch x.tmp && git add x.tmp && bash scripts/release/release.sh; git rm -q --cached x.tmp && rm x.tmp: prints “working tree dirty”, exit 1. -
Step 3: Update
bh-appstore-releaseSKILL.md
In .claude/skills/bh-appstore-release/SKILL.md:
- Replace the blockquote starting
> **Scripted since 2026-09-27:with:> **Scripted:scripts/release/release.sh(no argument, since 2026-09-28) runs steps 0-3 end to end.** It bumpsSim_Const.BUILDviascripts/bump-build.sh(store build number == BUILD), builds + uploads both platforms, waits for processing, then commits the bump and tags + pushesstore-N. Runs only frommainorrelease/*. Internal testers get every build; external groups + beta review only withEXTERNAL=1or a laterasc.py beta N. Hotfix:git switch -c release/store- , fix, runrelease.shthere, merge back to main. - In §0 “Find the next build number”, replace the paragraph starting
Bump \application/version`with:Don’t bump by hand: `scripts/bump-build.sh` is the only writer (all four presets + BUILD, kept in lockstep by tests/test_release_presets.gd).`
(.claude/ is gitignored: this edit is local, not committed.)
- Step 4: Commit
git add scripts/release/release.shgit commit -m "feat(release): release.sh takes no arg, store build = BUILD, tags store-N"Task 3: The Dark Cosmos Dev app
Section titled “Task 3: The Dark Cosmos Dev app”Files:
- Create:
scripts/dev/install-dev.sh - Create:
scripts/dev/make-dev-icon.sh - Modify:
.claude/skills/bh-deploy/SKILL.md(§C and §C2 command blocks, local-only)
Interfaces:
- Consumes: the
tvOS-Dev/iOS-Devpresets and the existing signed shellsplatform/tvos/build/andplatform/tvos/build-ios/. - Produces:
bash scripts/dev/install-dev.sh <atv|iphone|ipad> [--no-bump]. It bumps BUILD (unless--no-bump), exports the-Devpack, builds with bundle iddigital.lumara.bulletheaven.dev+ nameDark Cosmos Dev(+AppIcon-Devon iOS), installs and launches.
Device UUIDs (from bh-deploy): atv 8C229574-499D-51EE-9B4F-87DEB2865DB3, iphone 12FDF0CC-72E2-581B-B306-714C6E2275AC, ipad 4A2B411C-53F7-5BB3-AA08-8B1467E9142B.
- Step 1: Write
scripts/dev/make-dev-icon.sh
#!/usr/bin/env bash# Badge every PNG of the iOS shell's AppIcon set with a DEV band -> AppIcon-Dev.appiconset.# The shell (build-ios/) is gitignored and regenerated by full exports, so install-dev.sh calls this# every time (cheap, idempotent). tvOS keeps the store icon (layered brand assets; name-only there).set -euo pipefailS=${1:?usage: make-dev-icon.sh <path to Images.xcassets>}src="$S/AppIcon.appiconset"; dst="$S/AppIcon-Dev.appiconset"[ -d "$src" ] || { echo "::error:: no $src" >&2; exit 1; }rm -rf "$dst"; cp -R "$src" "$dst"for f in "$dst"/*.png; do h=$(magick identify -format '%h' "$f") magick "$f" \( -size "$(magick identify -format '%w' "$f")x$((h/4))" xc:'#ff7a00' \) \ -gravity south -composite \ -fill white -font Helvetica-Bold -pointsize $((h/6)) -annotate +0+$((h/28)) DEV "$f"doneecho "dev icon: $dst ($(ls "$dst"/*.png | wc -l | tr -d ' ') images)"- Step 2: Write
scripts/dev/install-dev.sh
#!/usr/bin/env bash# Build + install "Dark Cosmos Dev" (digital.lumara.bulletheaven.dev) next to the store/TestFlight# app (spec 2026-09-28-build-channels-pipeline-design.md §1). Reuses the signed Xcode shells; the dev# identity is applied as xcodebuild overrides, so the shells' pbxproj keeps the store id for release.sh.# Usage: scripts/dev/install-dev.sh <atv|iphone|ipad> [--no-bump]set -euo pipefailROOT=$(cd "$(dirname "$0")/../.." && pwd); cd "$ROOT"DEV_ID=digital.lumara.bulletheaven.devcase "${1:-}" in atv) DEV=8C229574-499D-51EE-9B4F-87DEB2865DB3; KIND=tvos ;; iphone) DEV=12FDF0CC-72E2-581B-B306-714C6E2275AC; KIND=ios ;; ipad) DEV=4A2B411C-53F7-5BB3-AA08-8B1467E9142B; KIND=ios ;; *) echo "usage: install-dev.sh <atv|iphone|ipad> [--no-bump]"; exit 2 ;;esac[ "${2:-}" = "--no-bump" ] || echo "BUILD -> $(bash scripts/bump-build.sh | tail -1)"N=$(grep -oE '^const BUILD: int = [0-9]+' sim/constants.gd | grep -oE '[0-9]+$')secrets >/dev/null 2>&1 || . "$HOME/.sec""rets" >/dev/null 2>&1AUTH=(-allowProvisioningUpdates -authenticationKeyPath "$HOME/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8" -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID")OVR=(PRODUCT_BUNDLE_IDENTIFIER=$DEV_ID INFOPLIST_KEY_CFBundleDisplayName="Dark Cosmos Dev" CURRENT_PROJECT_VERSION=$N CODE_SIGN_IDENTITY="Apple Development")cd platform/tvosif [ "$KIND" = tvos ]; then ~/Claude/godot-tvos/godot/bin/godot.macos.editor.arm64 --headless --path . --export-pack "tvOS-Dev" build/BulletHeaven.pck git checkout -- project.godot 2>/dev/null || true cd build DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer xcodebuild -scheme BulletHeaven -configuration Release \ -destination "id=$DEV" -derivedDataPath ./DerivedData-dev "${AUTH[@]}" "${OVR[@]}" build > dev-build.log 2>&1 \ || { tail -30 dev-build.log; exit 1; } APP=DerivedData-dev/Build/Products/Release-appletvos/BulletHeaven.appelse /opt/homebrew/bin/godot --path . --rendering-method mobile --export-pack "iOS-Dev" build-ios/BulletHeaven.pck git checkout -- project.godot 2>/dev/null || true bash "$ROOT/scripts/dev/make-dev-icon.sh" build-ios/BulletHeaven/Images.xcassets cd build-ios DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -scheme BulletHeaven -configuration Release \ -destination "id=$DEV" -derivedDataPath ./DerivedData-dev "${AUTH[@]}" "${OVR[@]}" \ ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Dev build > dev-build.log 2>&1 \ || { tail -30 dev-build.log; exit 1; } APP=DerivedData-dev/Build/Products/Release-iphoneos/BulletHeaven.appfiid=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Info.plist")[ "$id" = "$DEV_ID" ] || { echo "::error:: built app is '$id', not $DEV_ID: refusing to install over the store app"; exit 1; }xcrun devicectl device install app --device "$DEV" "$APP"xcrun devicectl device process launch --device "$DEV" "$DEV_ID" || echo "(launch failed: benign if the device is asleep)"echo "==> Dark Cosmos Dev BUILD $N installed on $1"chmod +x scripts/dev/*.sh
The bundle-id check before install is the guard from spec §Error handling: a dev build can never replace the store app.
- Step 3: Verify on a real device (needs Chris’s OK on the day; devices must be awake)
Run: bash scripts/dev/install-dev.sh iphone
Expected: “Dark Cosmos Dev BUILD 207 installed on iphone”. On the iPhone, Dark Cosmos (TestFlight/App Store) AND Dark Cosmos Dev (orange DEV badge) are both on the home screen. The Dev app starts with a fresh save (the tutorial shows), and the store app’s progress is unchanged.
If signing fails with “No profiles for ‘digital.lumara.bulletheaven.dev’”: automatic signing didn’t register the App ID. Register it with python3 scripts/release/asc.py style JWT + POST /v1/bundleIds ({"data":{"type":"bundleIds","attributes":{"identifier":"digital.lumara.bulletheaven.dev","name":"Dark Cosmos Dev","platform":"UNIVERSAL"}}}), then re-run with --no-bump. Do not fall back to the store id.
Then: bash scripts/dev/install-dev.sh atv --no-bump. Same checks on the Apple TV (store icon, name “Dark Cosmos Dev” when focused).
- Step 4: Point
bh-deployat the script
In .claude/skills/bh-deploy/SKILL.md, at the top of §C and §C2 add:
**Default path (2026-09-28): \bash scripts/dev/install-dev.sh <atv|iphone|ipad>`** installs the separate Dark Cosmos Dev app (`digital.lumara.bulletheaven.dev`) next to the store app, bumping BUILD via `scripts/bump-build.sh`. The manual commands below are the reference for what it does. They install under the STORE id and overwrite the store app, so only use them deliberately.`
- Step 5: Commit (the BUILD bump from Step 3 goes in with it)
git add scripts/dev/ sim/constants.gd platform/tvos/export_presets.cfggit commit -m "feat(dev): Dark Cosmos Dev app (separate bundle id) via scripts/dev/install-dev.sh"Task 4: BuildChannel
Section titled “Task 4: BuildChannel”Files:
- Create:
net/build_channel.gd - Test:
tests/test_build_channel.gd
Interfaces:
- Produces:
BuildChannel.classify(is_dev: bool, is_web: bool, sandbox_receipt: bool, prod_receipt: bool) -> String(pure). - Produces:
BuildChannel.current() -> String(cached per process) andBuildChannel.probe() -> String. The probe is a diagnostic:"sandbox","prod"or"none", plus the path that matched, e.g."sandbox@container". Tasks 5 and 6 send it aschannel_probeso the first TestFlight build proves where the receipt lives.
Receipt locations checked, in order, for each of sandboxReceipt / receipt:
OS.get_executable_path().get_base_dir() + "/StoreKit/"(app bundle), labelbundleOS.get_user_data_dir().get_base_dir() + "/StoreKit/"(data container;user://is<container>/Documentson iOS/tvOS), labelcontainer
[Guessing] which one iOS 27 uses; the probe settles it (Task 9).
- Step 1: Write the failing test
tests/test_build_channel.gd
extends GutTest
# BuildChannel.classify is the pure decision; current() just feeds it OS facts.# Rule: dev wins, then web, then a sandbox receipt = testflight; ANYTHING else = appstore, so a# detection failure can never label real players as test traffic.
func test_dev_build_is_dev_whatever_the_receipt() -> void: assert_eq(BuildChannel.classify(true, false, true, false), "dev") assert_eq(BuildChannel.classify(true, false, false, true), "dev")
func test_web_is_web() -> void: assert_eq(BuildChannel.classify(false, true, false, false), "web")
func test_sandbox_receipt_is_testflight() -> void: assert_eq(BuildChannel.classify(false, false, true, false), "testflight")
func test_prod_receipt_is_appstore() -> void: assert_eq(BuildChannel.classify(false, false, false, true), "appstore")
func test_no_receipt_defaults_to_appstore() -> void: assert_eq(BuildChannel.classify(false, false, false, false), "appstore")
func test_both_receipts_prefers_appstore() -> void: # Ambiguous = don't mark real players as testers. assert_eq(BuildChannel.classify(false, false, true, true), "appstore")
func test_current_in_editor_is_dev() -> void: assert_eq(BuildChannel.current(), "dev", "GUT runs in the editor binary, which BuildConfig treats as dev")- Step 2: Run it and confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit
Expected: FAIL / parse error, “BuildChannel” not declared.
- Step 3: Write
net/build_channel.gd
class_name BuildChannelextends RefCounted
# Which kind of build is running: "dev" | "web" | "testflight" | "appstore"# (spec docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md §4). Render/net side# only: uses OS + FileAccess, never referenced from /sim. Sent with every telemetry / feedback /# remote-config request. ANY detection failure = "appstore", so real players are never mislabelled# as testers. TestFlight vs App Store is the StoreKit receipt's file name: TestFlight installs carry# "sandboxReceipt", App Store installs "receipt".
static var _cached: String = ""static var _probe: String = ""
static func current() -> String: if _cached == "": var found := _find_receipts() _cached = classify(BuildConfig.dev_tools(), OS.has_feature("web"), found.sandbox, found.prod) return _cached
# Diagnostic for the first TestFlight/App Store builds: which receipt was found, and where.static func probe() -> String: if _probe == "": _find_receipts() return _probe
static func classify(is_dev: bool, is_web: bool, sandbox_receipt: bool, prod_receipt: bool) -> String: if is_dev: return "dev" if is_web: return "web" if sandbox_receipt and not prod_receipt: return "testflight" return "appstore"
static func _find_receipts() -> Dictionary: var out := {"sandbox": false, "prod": false} _probe = "none" if not (OS.has_feature("ios") or OS.has_feature("tvos")): return out var dirs := { "bundle": OS.get_executable_path().get_base_dir() + "/StoreKit/", "container": OS.get_user_data_dir().get_base_dir() + "/StoreKit/", } for where in dirs: if FileAccess.file_exists(dirs[where] + "sandboxReceipt"): out.sandbox = true _probe = "sandbox@" + where if FileAccess.file_exists(dirs[where] + "receipt"): out.prod = true _probe = "prod@" + where return outCheck: OS.has_feature("tvos") is the feature tag the godot-tvos fork sets. Confirm with git grep -n 'has_feature("tvos")' -- '*.gd'. If nothing uses it, use the tag this repo already uses for tvOS detection (git grep -n "has_feature(" -- '*.gd' | grep -i tv).
- Step 4: Run it and confirm it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit
Expected: 7/7 pass.
- Step 5: Commit
git add net/build_channel.gd tests/test_build_channel.gdgit commit -m "feat(net): BuildChannel (dev/web/testflight/appstore, default appstore)"Task 5: Channel on every client request
Section titled “Task 5: Channel on every client request”Files:
- Modify:
net/telemetry.gd(sample body ~line 128, hitch body ~line 109) - Modify:
net/gameplay_telemetry.gd(run body ~line 56,feedback_body~line 96) - Modify:
net/dash_encounter_telemetry.gd(~line 103) - Modify:
net/remote_config.gd:61 - Test: add to
tests/test_build_channel.gd
Interfaces:
- Consumes:
BuildChannel.current(),BuildChannel.probe()(Task 4). - Produces: JSON bodies gain
"channel": Stringand"channel_probe": String.GET /configgains&channel=<c>.
Recipe (the same in each file). In each body dictionary, directly after the existing "build": Sim_Const.BUILD, entry, add:
"channel": BuildChannel.current(), "channel_probe": BuildChannel.probe(),Verified body sites: net/telemetry.gd (var body := {"build": Sim_Const.BUILD, "session": _session, "dev": _device, "quality": quality, "hitches": _hitch_queue} at ~109, and the multi-line sample body at ~128), net/gameplay_telemetry.gd (the run body at ~56 and feedback_body()’s return {"build": Sim_Const.BUILD, ... at ~96), net/dash_encounter_telemetry.gd (~103). For the single-line dicts, insert the two keys after "build": Sim_Const.BUILD, on the same line.
- Step 1: Write the failing test (append to
tests/test_build_channel.gd)
func test_feedback_body_carries_channel() -> void: var body: Dictionary = GameplayTelemetry.feedback_body("bug", "x", "s", "dev", null, "touch") assert_eq(body.get("channel"), "dev") assert_true(body.has("channel_probe"))Before running, check feedback_body’s real signature: grep -n "func feedback_body" net/gameplay_telemetry.gd. Adjust the call’s arguments to match it exactly (it’s static; if it isn’t, construct the node with GameplayTelemetry.new() and free() it after).
- Step 2: Run it and confirm it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit
Expected: test_feedback_body_carries_channel FAILS (channel is null).
- Step 3: Apply the recipe to all five bodies, then change
net/remote_config.gd:61to
if _http.request("%s?build=%d&channel=%s&install=%s" % [ENDPOINT, _build, BuildChannel.current(), _install_id.uri_encode()]) != OK:- Step 4: Run the test, then the full gate
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_build_channel -gexit, then bash scripts/gate.sh.
Expected: 8/8, then GATE PASS. The existing telemetry/remote-config tests must pass unchanged (the worker ignores unknown fields until Task 6).
- Step 5: Commit
git add net/ tests/test_build_channel.gdgit commit -m "feat(net): send channel + channel_probe with telemetry, feedback and config fetch"Task 6: Worker stores channel and serves per-channel config
Section titled “Task 6: Worker stores channel and serves per-channel config”Files:
- Modify:
telemetry/src/worker.js(resolveConfig~37,/configroute ~59, INSERTs at ~73 samples, ~95 hitches, ~116 runs, ~148 encounters, ~176 feedback) - Modify:
telemetry/schema.sql(addchannel TEXTandchannel_probe TEXTto the five CREATE TABLEs) - Create:
telemetry/test/channel.test.mjs
Interfaces:
-
Produces:
resolveConfig(kv, build, channel = ""). Lookup order:build-N@channel→build-N→ the nearest older build, where for each older buildbuild-M@channelbeatsbuild-M. The@-suffixed keys are ignored whenchannelis empty. -
Produces: rows in all five tables carry
channel/channel_probe(NULL for old clients). -
Step 1: Write the failing test
telemetry/test/channel.test.mjs
import { test } from "node:test";import assert from "node:assert/strict";import worker, { resolveConfig } from "../src/worker.js";
const kv = (m) => ({ get: async (k) => m[k] ?? null, list: async ({ prefix }) => ({ keys: Object.keys(m).filter((k) => k.startsWith(prefix)).map((name) => ({ name })) }),});
test("channel-specific patch beats the plain one for that channel only", async () => { const m = { "build-207": '{"config_id":"all"}', "build-207@testflight": '{"config_id":"tf"}' }; assert.equal(await resolveConfig(kv(m), 207, "testflight"), '{"config_id":"tf"}'); assert.equal(await resolveConfig(kv(m), 207, "appstore"), '{"config_id":"all"}'); assert.equal(await resolveConfig(kv(m), 207), '{"config_id":"all"}');});test("older-build fallback honours channel too, and never leaks a channel patch to others", async () => { const m = { "build-205@testflight": '{"config_id":"tf205"}', "build-204": '{"config_id":"all204"}' }; assert.equal(await resolveConfig(kv(m), 207, "testflight"), '{"config_id":"tf205"}'); assert.equal(await resolveConfig(kv(m), 207, "appstore"), '{"config_id":"all204"}');});test("/config passes ?channel through", async () => { const res = await worker.fetch(new Request("https://x/config?build=207&channel=testflight"), { CONFIG: kv({ "build-207@testflight": '{"config_id":"tf"}' }) }); assert.equal(await res.text(), '{"config_id":"tf"}');});test("/feedback stores channel", async () => { let bound = null; const DB = { prepare: (sql) => ({ bind: (...a) => { if (sql.includes("INSERT INTO feedback")) bound = { sql, a }; return { run: async () => ({}), first: async () => ({ n: 0 }) }; } }) }; await worker.fetch(new Request("https://x/feedback", { method: "POST", body: JSON.stringify({ build: 207, session: "s", kind: "bug", note: "n", channel: "testflight", channel_probe: "sandbox@container" }) }), { DB }); assert.ok(bound, "feedback INSERT ran"); assert.match(bound.sql, /channel,channel_probe/); assert.ok(bound.a.includes("testflight") && bound.a.includes("sandbox@container"));});- Step 2: Run it and confirm it fails
Run: cd telemetry && npm test
Expected: the 4 new tests fail and the existing tests pass. If the /feedback mock doesn’t match how the route calls D1 (e.g. it uses .all()), read the route at ~line 165-180 and extend the mock’s returned object with that method. Don’t change the route to suit the mock.
- Step 3: Implement
resolveConfigwith channel
export async function resolveConfig(kv, build, channel = "") { if (!kv || !(build > 0)) return "{}"; const ch = /^[a-z]{1,12}$/.test(channel) ? channel : ""; const pick = async (n) => (ch && (await kv.get(`build-${n}@${ch}`))) || (await kv.get(`build-${n}`)); const exact = await pick(build); if (exact) return exact; const { keys } = await kv.list({ prefix: "build-" }); let best = -1; for (const k of keys) { const [num, kc] = k.name.slice(6).split("@"); const n = parseInt(num, 10); if (kc && kc !== ch) continue; if (Number.isFinite(n) && n < build && n > best) best = n; } return best < 0 ? "{}" : ((await pick(best)) || "{}");}and in the /config route: body = await resolveConfig(env.CONFIG, intt(url.searchParams.get("build")), str(url.searchParams.get("channel"), 12));
- Step 4: Add the columns to the five INSERTs
Recipe per INSERT: append ,channel,channel_probe to the column list, append ,?,? to VALUES, and append str(X.channel, 12) || null, str(X.channel_probe, 40) || null to .bind(...), where X is that route’s parsed-body variable (s for /sample; read each route to get its name).
In telemetry/schema.sql, add channel TEXT, channel_probe TEXT before the closing ) of each of the five CREATE TABLEs.
- Step 5: Run the tests and confirm they pass
Run: cd telemetry && npm test
Expected: all pass (old + 4 new).
- Step 6: Commit
git add telemetry/git commit -m "feat(telemetry): store channel/channel_probe; per-channel remote config (build-N@channel)"- Step 7: Migrate D1, then deploy (Chris confirms on the day; worker before any client ships)
Migration first. The new INSERTs fail on a table without the columns.
cd telemetry && secretsfor t in samples hitches runs encounters feedback; do CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc \ npx --yes wrangler@latest d1 execute bullet-heaven-telemetry --remote \ --command "ALTER TABLE $t ADD COLUMN channel TEXT; ALTER TABLE $t ADD COLUMN channel_probe TEXT;"doneCLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx --yes wrangler@latest deployVerify: curl -s "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/config?build=206&channel=appstore" returns the same body as without &channel=. After a feedback POST from a dev build, SELECT channel, channel_probe FROM feedback ORDER BY ts DESC LIMIT 1 shows dev.
Task 7: publish-config.mjs --channel
Section titled “Task 7: publish-config.mjs --channel”Files:
- Modify:
scripts/publish-config.mjs(lines 33-47) - Modify:
scripts/test/publish-config.test.mjs
Interfaces:
-
Produces:
node scripts/publish-config.mjs <N> [--channel <testflight|appstore|dev|web>] [--dry-run]publishes to KV keybuild-<N>orbuild-<N>@<channel>, and exportskvKey(n, channel)for the test. -
Step 1: Write the failing test (append to
scripts/test/publish-config.test.mjs, using its existing import style)
import { kvKey } from "../publish-config.mjs";test("kvKey: plain and per-channel", () => { assert.equal(kvKey(207), "build-207"); assert.equal(kvKey(207, "testflight"), "build-207@testflight"); assert.throws(() => kvKey(207, "beta"), /channel/);});-
Step 2: Run it and confirm it fails.
node --test scripts/test/fails:kvKeyis not exported. -
Step 3: Implement
Add near the top of scripts/publish-config.mjs:
const CHANNELS = ["dev", "web", "testflight", "appstore"];export function kvKey(n, channel) { if (channel === undefined) return `build-${n}`; if (!CHANNELS.includes(channel)) throw new Error(`unknown channel '${channel}' (one of ${CHANNELS.join(", ")})`); return `build-${n}@${channel}`;}In the CLI block: parse const ci = process.argv.indexOf("--channel"); const channel = ci > 0 ? process.argv[ci + 1] : undefined;. Replace the literal `build-${n}` in the wrangler kv key put args and the final console.log with kvKey(n, channel). Update the usage strings to include [--channel <c>].
-
Step 4: Run it and confirm it passes.
node --test scripts/test/: all pass. -
Step 5: Commit
git add scripts/publish-config.mjs scripts/test/publish-config.test.mjsgit commit -m "feat(remote-config): publish-config --channel (KV build-N@channel)"Task 8: DevFlags replaces the dev-only consts
Section titled “Task 8: DevFlags replaces the dev-only consts”Files:
- Create:
meta/dev_flags.gd - Test:
tests/test_dev_flags.gd - Modify:
main.gd(consts at 158, 167, 176, 203, 207; uses at 466, 475, 749, 906, 1198, 1326, 1329, 1347, 1351, 1734, 1744, 1756) - Modify:
ui/debug_settings_panel.gd(_build_rows~282; new_dev_flag_row)
Interfaces:
- Produces:
DevFlags.perf_bench: bool,DevFlags.auto_showcase: bool,DevFlags.record_replay: bool(static vars, default false).DevFlags.load_for(is_dev: bool, d: Dictionary) -> void(pure).DevFlags.boot() -> void(readsuser://dev_flags.jsonviaLocalJson.readin dev builds, otherwise forces all false).DevFlags.save() -> void.
Decisions:
-
SHIP3D_MEASURE(disabled 2026-07-01, broken on Metal) andMEASURE_FULL_QUALITY(“measurement loop closed for the v0.1 launch”) are deleted with their branches, not ported. -
ADAPTIVE_QUALITY,V01_LOCK_*and the tuning numbers (PERF_BENCH_DWELL_Setc.) stay consts. -
Step 1: Write the failing test
tests/test_dev_flags.gd
extends GutTest
func after_each() -> void: DevFlags.load_for(false, {})
func test_defaults_are_all_false() -> void: DevFlags.load_for(false, {}) assert_false(DevFlags.perf_bench) assert_false(DevFlags.auto_showcase) assert_false(DevFlags.record_replay)
func test_release_build_ignores_a_saved_file() -> void: DevFlags.load_for(false, {"perf_bench": true, "auto_showcase": true, "record_replay": true}) assert_false(DevFlags.perf_bench or DevFlags.auto_showcase or DevFlags.record_replay, "a non-dev build must force every dev flag off, whatever is on disk")
func test_dev_build_reads_the_saved_file() -> void: DevFlags.load_for(true, {"perf_bench": true, "record_replay": true}) assert_true(DevFlags.perf_bench) assert_false(DevFlags.auto_showcase) assert_true(DevFlags.record_replay)
func test_garbage_values_are_false() -> void: DevFlags.load_for(true, {"perf_bench": "yes", "auto_showcase": 1}) assert_false(DevFlags.perf_bench) assert_false(DevFlags.auto_showcase)
func test_main_has_no_dev_mode_consts_left() -> void: var src := FileAccess.get_file_as_string("res://main.gd") for needle in ["const PERF_BENCH :=", "const AUTO_SHOWCASE :=", "const RECORD_REPLAY :=", "SHIP3D_MEASURE", "MEASURE_FULL_QUALITY"]: assert_false(src.contains(needle), "main.gd must not contain '%s': use DevFlags / deleted (spec §5)" % needle)-
Step 2: Run it and confirm it fails.
-gselect=test_dev_flagsfails: DevFlags not declared. -
Step 3: Write
meta/dev_flags.gd
class_name DevFlagsextends RefCounted
# Dev-only run modes (spec docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md §5).# They used to be `const X := false` in main.gd, flipped by editing code, so one could ship `true`.# Now: runtime flags set from Debug Settings (dev builds only), saved to user://dev_flags.json, and# forced false in every non-dev build regardless of the file. They take effect on the NEXT launch# (perf_bench / auto_showcase skip the menu at boot). Never referenced from /sim.
const FILE := "dev_flags.json"
static var perf_bench: bool = false # autopilot sweeps quality tiers (main.PERF_BENCH_* tuning)static var auto_showcase: bool = false # hands-free invulnerable autopilot showcasestatic var record_replay: bool = false # record input replays to user://replays/
static func boot() -> void: var dev := BuildConfig.dev_tools() load_for(dev, LocalJson.read(FILE) if dev else {})
static func load_for(is_dev: bool, d: Dictionary) -> void: perf_bench = is_dev and d.get("perf_bench", false) is bool and d.get("perf_bench", false) auto_showcase = is_dev and d.get("auto_showcase", false) is bool and d.get("auto_showcase", false) record_replay = is_dev and d.get("record_replay", false) is bool and d.get("record_replay", false)
static func save() -> void: if BuildConfig.dev_tools(): LocalJson.write(FILE, {"perf_bench": perf_bench, "auto_showcase": auto_showcase, "record_replay": record_replay})- Step 4: Migrate
main.gd
- Delete the const lines for
MEASURE_FULL_QUALITY(158 and its 4 comment lines above),PERF_BENCH(167 only; keepPERF_BENCH_DWELL_S/_MAX_LEVEL/_RUN_S),AUTO_SHOWCASE(176; keepSHOWCASE_WARP_S/SHOWCASE_LOOK_S),SHIP3D_MEASURE(203 and its comment block 196-202),RECORD_REPLAY(207; update the comment above it to say “DevFlags.record_replay”). - Replace every whole-word use:
PERF_BENCH→DevFlags.perf_bench,AUTO_SHOWCASE→DevFlags.auto_showcase,RECORD_REPLAY→DevFlags.record_replay. Use\bword boundaries soPERF_BENCH_DWELL_Setc. are untouched. Verified sites: 466, 475, 749, 906, 1326, 1329, 1734, 1744. - Line 1347
if not ADAPTIVE_QUALITY or MEASURE_FULL_QUALITY:→if not ADAPTIVE_QUALITY:. Delete theif MEASURE_FULL_QUALITY:blocks at ~1351 and ~1756. Then rungrep -n measure_label main.gd: if the only remaining references are its declaration and creation, delete those too. - Delete the
if SHIP3D_MEASURE:block at ~1198. Then rungrep -n "ship3d\b\|Ship3DRenderer" main.gd: ifship3dis now only declared (line 9) and never read, delete line 9. Leave theShip3DRendererclass file alone (the cleanup audit owns it). - At the very top of
func _ready() -> void:(line 326) addDevFlags.boot()as the first statement.
- Step 5: Debug Settings rows (dev builds only)
In ui/debug_settings_panel.gd _build_rows(), after the three _static_toggle_row lines, add:
if BuildConfig.dev_tools(): _rows.append(_dev_flag_row("Dev: perf bench (next launch)", "perf_bench")) _rows.append(_dev_flag_row("Dev: auto showcase (next launch)", "auto_showcase")) _rows.append(_dev_flag_row("Dev: record replays", "record_replay"))and add, next to _static_toggle_row:
func _dev_flag_row(label: String, field: String) -> Dictionary: return { "label": label, "is_graphics": false, "get_state": func() -> bool: return bool(DevFlags.get(field)), "set_state": func(v: bool) -> void: DevFlags.set(field, v) DevFlags.save(), }Check first: static vars are reachable through DevFlags.get(field) / DevFlags.set(...) on the class in Godot 4.7. If not (it returns null), switch on field the way _touch_static does at line 470.
- Step 6: Run the tests and the full gate
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_dev_flags -gexit (5/5), then also -gselect=test_debug_settings_panel (a row-count assertion may need its expected count raised by 3 in dev/editor context; that’s the only acceptable test edit), then bash scripts/gate.sh.
Expected: GATE PASS. The determinism tests are unchanged (render-side only).
- Step 7: Commit
git add meta/dev_flags.gd tests/test_dev_flags.gd main.gd ui/debug_settings_panel.gd tests/test_debug_settings_panel.gdgit commit -m "refactor(main): dev-only modes -> DevFlags (dev builds only); drop dead SHIP3D/MEASURE_FULL_QUALITY"Task 9: Docs, the queue, and the first-TestFlight check
Section titled “Task 9: Docs, the queue, and the first-TestFlight check”Files:
-
Modify:
docs/architecture/dev-tools.md(add a “Build channels” section) -
Modify:
docs/architecture/remote-config.md(per-channel keys) -
Modify:
docs/RELEASE-1.0.1.md(tick the pipeline item) -
Modify:
docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md(Amendments, already added with this plan) -
Step 1:
docs/architecture/dev-tools.md. Append:
## Build channels (2026-09-28)
Spec: `docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md`.- **Dev app:** `bash scripts/dev/install-dev.sh <atv|iphone|ipad>` installs **Dark Cosmos Dev** (`digital.lumara.bulletheaven.dev`, own saves, DEV-badged icon on iOS) next to the store app.- **One build number:** `scripts/bump-build.sh` is the only writer; store build == `Sim_Const.BUILD`; `tests/test_release_presets.gd` enforces it. `release.sh` tags every upload `store-N`.- **Channel:** `BuildChannel.current()` (`dev`/`web`/`testflight`/`appstore`, failure → `appstore`) rides on every telemetry/feedback row (`channel`, `channel_probe` columns) and `GET /config`. Filter real players with `WHERE channel='appstore'`; rows before BUILD 207 are NULL.- **Dev modes:** `DevFlags` (Debug Settings → "Dev: …", dev builds only, next launch).-
Step 2:
docs/architecture/remote-config.md. Add under the publishing section:Per-channel patches: \node scripts/publish-config.mjs–channel testflight` writes KV `build-N@testflight`, served only to that channel (lookup: `build-N@ch` → `build-N` → nearest older, same order). Use it to trial a balance patch on TestFlight before App Store players get it.` -
Step 3: Tick the pipeline line in
docs/RELEASE-1.0.1.md→✅ DONE (<commit>), keeping the numbering note. -
Step 4: Commit + push
git add docs/git commit -m "docs: build channels (dev app, one build number, channel field, DevFlags)"git push origin main- Step 5: The first-TestFlight check (when 1.0.1’s first build goes up)
After release.sh uploads BUILD ≥ 207 and Chris opens the TestFlight build on the iPhone and the Apple TV:
SELECT dev, channel, channel_probe, COUNT(*) FROM samples WHERE build >= 207 GROUP BY 1,2,3;Expected: testflight / sandbox@<bundle|container> for Chris’s TestFlight devices, and dev for the Dev app.
If TestFlight rows show appstore / none: neither receipt path exists on iOS/tvOS 27. Add it to docs/RELEASE-1.0.1.md as a bug. The fix is a native check (AppTransaction.shared.environment via a Swift shim in the shells, written into a file BuildChannel reads), spec’d separately. Until then, TestFlight traffic counts as appstore, the safe direction.