A staging build has one honest job: to be the thing that ships, only tested earlier. That is why our QA switch does not live in a separate debug build — it lives inside the release binary, flipped on by a single compile-time boolean. Which raises a question with a genuinely surprising answer. Given two release binaries, one built for QA and one bound for the store, how do you prove which is which? The obvious move is to grep the binary for the flag. The obvious move fails, because the token you grep for is the one the compiler already deleted. This is a first-hand account of the switch, the trap, and the one line that makes a build tell the truth about itself.
Why the switch lives in the release build at all
The instinct, when you want QA affordances — every level unlocked, the paid tier forced on, an in-app badge that shouts which build this is — is to put them in a debug build and keep the release build pristine. It feels tidy. It is also wrong, because a debug build is a different animal from the thing you will ship. It is compiled differently, it performs differently, and on iOS it carries a different signing story. Ask a tester to evaluate a debug build and you have asked them to evaluate a rehearsal that shares no wardrobe with opening night. When they say it feels good, you have learned something about the rehearsal.
So we put the switch where it does the most good and the most damage if mishandled: inside the release binary. Our staging artifact — the TestFlight build, the internal-track build — is compiled in release mode exactly like production, carries the same clean version string, and is byte-identical in shape to the build that would go to the store. The only difference is that one boolean is flipped on. This is the whole argument we made in staging has to lie exactly like production: a rehearsal that differs from the performance is not a rehearsal, it is a different play. The QA switch is the one sanctioned deviation, and everything below is about keeping that deviation on a very short leash.
One boolean, folded away at compile time
The switch itself is deliberately dull. It is a
single compile-time constant — const bool kDevUnlock =
bool.fromEnvironment('DEV_UNLOCK') — and it is turned on by passing
--dart-define=DEV_UNLOCK=true at build time. Every QA-only branch in
the codebase sits behind if (kDevUnlock) { … }. When we build
for QA the flag is present, the constant is true, and those branches
are live. When we build for the store we simply omit the flag, the constant
resolves to false, and those branches are dead code.
Here is the part that matters, and it is a property of
the compiler rather than a habit of ours. Because bool.fromEnvironment
produces a genuine compile-time constant, the ahead-of-time compiler does not carry
the if into the shipped program and evaluate it at runtime. It folds
the condition where it stands. In a production build the branch is provably
unreachable — if (false) — so the optimizer removes it
entirely, along with everything only that branch referenced. The dead code does not
ship. That is exactly what you want: the store build carries none of the QA
machinery, not as disabled code, but as no code at all.
The token you grep for is the one the compiler deletes
Now the trap. It is tempting, on promote day, to want a
cheap way to confirm you are shipping the right binary — to reach for
strings and grep the compiled artifact for DEV_UNLOCK. The
reasoning feels airtight: if this is the QA build the flag will be in there
somewhere; if it is the production build it will not. Both halves of that sentence
are false. In the production build the whole branch was tree-shaken away, so the
literal never appears. In the QA build the flag lives in the build invocation,
not the source — after constant folding, bool.fromEnvironment('DEV_UNLOCK')
collapses to the bare value true, and the string 'DEV_UNLOCK'
has no reason to survive into the binary either. You grep both builds, you find nothing
in both builds, and you have learned nothing.
This is the quiet danger of a switch you cannot see. The version label will not save you here: a QA build wears the same clean production version string by design, because a non-numeric version is rejected at the store and because build-number parity has to hold across platforms — the reasons we laid out in a version number is a promise; a build number is a receipt. So the label lies by looking normal, and the obvious grep lies by finding nothing. Two release binaries sit on the desk, one harmless and one that must never reach the store, and nothing about either one, read naively, tells them apart.
A marker that has to survive
The fix turns the compiler’s own honesty against the
problem. The optimizer removes a branch only when it can prove the branch is
dead. So we give the QA branch a job the optimizer cannot prove is pointless: inside
if (kDevUnlock) we reference a unique, per-project marker string through a
print() call. print() has an observable side effect, which
means the compiler cannot show that removing the call changes nothing, so it keeps the
call — and the marker string it carries — in the QA binary. In a production
build the enclosing condition is false, the whole branch folds away, and
the marker goes with it. The marker is present in exactly one of the two builds, and
which one is not a matter of opinion.
Verification stops being a hunch and becomes one line:
strings <binary> | grep -c "<marker>". A QA build returns at
least one; a production build returns zero. On promote day that check is the gate —
a store build that returns anything other than zero is not a store build, and it goes
back to be re-cut without the flag. It costs nothing at runtime, either: in a release
build print() is effectively a no-op, so the marker is a fingerprint the
binary carries for our benefit and the player never pays for. It is the same instinct as
the state-hash fingerprint in deterministic
by design — if a property matters, make the artifact able to prove it about
itself, rather than trusting a person to remember.
Real keys, sandboxed behaviour
A switch that only unlocked screens would be low-stakes. Ours
does more, and this is where the leash gets short. Because the staging build is
byte-identical to production, it carries the real third-party keys — the
production ad mediation keys, the production purchase keys — not sandbox ones. That
is deliberate: a tester needs to hit the real initialization path, or they are testing a
different program again. But real keys in the hands of QA is a way to generate real,
garbage traffic against live systems. So kDevUnlock does double duty: the
same flag that unlocks the screens also flips those SDKs into their test modes — the
ad SDK into test-ad mode, the purchase SDK into a path that skips real backend entitlement
and purchase sync. Real keys go in; real consequences stay out.
One honest clarification, because it would be easy to imply the wrong thing. “Force the paid tier on” is a QA affordance, not a description of the product. Our games are free and ad-supported, with a single optional Pro purchase that removes the ads; the QA switch simply lets a tester stand inside the paid experience without transacting, so they can check it renders and behaves. It does not make the shipped app ad-free, and it is not a discount — it is a lever that exists only inside a build the public will never install.
The rule that makes it safe
All of this rests on one non-negotiable rule, and the rule is a sentence, not a subsystem: a build carrying the flag must never be promoted to the store. “Go to production” does not mean “ship the build you already tested” — it means re-cut the artifact without the flag, and verify the marker is gone before it moves. That is why the marker matters more than the mechanism. The mechanism lets us ship a debug switch inside a release binary; the marker lets us prove, mechanically, on the way out the door, that we did not. The discipline is not the switch. The discipline is the thing that can catch the switch.
It does feel reckless to compile a debug switch into your release binary — the first time you say it plainly, it should. What makes it safe is not that the risk is small; it is that the risk is observable. The branch is dead in production because the compiler proves it, the SDKs are sandboxed because the same flag that opens the door also closes the windows, and the build itself will tell you which of the two it is if you ask it the right one-line question. A debug switch is only safe when the release binary can prove which build it is. If you want the long version of how this fits the rest of how we work, it is all on the WizusLabs Engineering blog — which is, as ever, the point of writing it down.
Notes
This is a first-hand account of how this studio builds and verifies
its QA/staging artifacts, not a general Dart or Flutter tutorial. The specifics — a single
DEV_UNLOCK compile-time boolean, a per-project marker string kept alive through a
print() side effect, the strings | grep check on promote, and the
coupling of the flag to SDK test-modes — are our own conventions, recorded in our own
workspace rules, not benchmarks or public figures. It deliberately contains no performance
numbers, no build sizes, and no app names or dates: the runtime cost of a release-mode
print() in a dead branch, for instance, is a number we would rather describe
honestly as “effectively nothing” than quote to a false precision we have not
measured on the device you happen to be holding. The absence of those numbers is the honest
position, not a gap in it.
Sources
The public building blocks this post leans on (living documents; accessed 2026-07-27):
- Dart API —
bool.fromEnvironment: documents that the boolean is read from the compile-time environment and is aconstvalue — the mechanism of the switch, and the reason the condition is a compile-time constant rather than a runtime read. - Dart — Configuring apps with compilation environment declarations: documents
--define/--dart-defineand that these declarations are resolved at compile time, which is why the production branch becomes provably dead code that the optimizer can remove. - Flutter — Build modes (debug, profile, release): documents that release builds are ahead-of-time compiled and optimized — the mode our staging and production artifacts share, and the reason the switch has to live in a release binary rather than a debug one.
Keep reading: all posts on the WizusLabs Engineering blog.