Chapter 25: The Release Gate

One gate over both stores, and what it refuses to let through

Chapters 23 and 24 each produced a checklist. Having read them, the obvious next move is to keep two lists — one for Android, one for iOS — and work through whichever one applies when you are shipping to that store.

That is exactly the mistake this chapter exists to prevent, and the source states it in one sentence:

Two separate checklists are how a team ships an Android build with a fix that never made it into the iOS build.

It happens like this. A bug is found on Friday. Somebody fixes it, builds Android, uploads, and rolls it out. The iOS build is a different machine, a different chain, a longer review, and it is Monday now — and by Monday the fix is three commits back and nobody is quite sure whether it went out. Two weeks later a user reports the same bug on an iPhone and nobody believes them.

The fix is structural rather than procedural: one gate, over both stores, that stays shut until everything blocking is done. Requirements that apply to both platforms are listed once and ticked once. And two of the twelve requirements have nothing to do with either store, because the most valuable things on a release checklist are usually not the ones a store asks for.

What you will learn in this chapter

  • Why one combined gate beats two per-platform checklists.
  • How to model requirements that belong to one store, the other, or both.
  • Why "same commit built for both" is a blocking requirement rather than good practice.
  • The difference between readiness and shippability, and why both are worth showing.
  • Why a rollback plan belongs on a release checklist even though no store asks for one.
  • How to turn a checklist into something a team actually uses.
  • The caveats: gates people learn to ignore, and what a checklist cannot catch.

The code for this chapter

The demonstration is at github.com/nodoid/MonoGameBook/src/Chapter25. It is the whole book's last screen: one gate over both stores, with SHIP IT staying dead until nothing blocks.

Chapter 25 running. Requirements tagged by store, blocking ones gating the SHIP IT control, and a readiness figure that counts everything including the non-blocking items.
Chapter 25 running. Requirements tagged by store, blocking ones gating the SHIP IT control, and a readiness figure that counts everything including the non-blocking items.

Modelling the gate

public enum Store
{
    Both,
    Android,
    Apple,
}

public sealed record Requirement(string Title, Store Store, bool Blocking);

Two axes. Which store asks for it, so a requirement common to both is written once rather than twice. And whether it blocks, which is the same distinction Chapters 23 and 24 used.

Store.Both being the first enum member is a small but deliberate signal: shared is the default, and a platform-specific requirement is the exception you have to justify.

public static IReadOnlyList<Requirement> Requirements { get; } =
[
    new Requirement("VERSION BUMPED IN BOTH HEADS",   Store.Both,    true),
    new Requirement("SAME COMMIT BUILT FOR BOTH",     Store.Both,    true),
    new Requirement("RELEASE CONFIGURATION",          Store.Both,    true),
    new Requirement("TEST SUITE GREEN",               Store.Both,    true),
    new Requirement("SIGNED AAB UPLOADED",            Store.Android, true),
    new Requirement("KEYSTORE BACKED UP OFFSITE",     Store.Android, true),
    new Requirement("DISTRIBUTION PROFILE VALID",     Store.Apple,   true),
    new Requirement("EXPORT COMPLIANCE ANSWERED",     Store.Apple,   true),
    new Requirement("STORE LISTINGS MATCH",           Store.Both,    false),
    new Requirement("SCREENSHOTS FOR EVERY SIZE",     Store.Both,    false),
    new Requirement("CRASH REPORTING WIRED UP",       Store.Both,    false),
    new Requirement("ROLLBACK PLAN WRITTEN DOWN",     Store.Both,    false),
];

Twelve items. Eight block, four do not, and the shape of the list is worth reading before the individual entries: the first four are about the build and apply to both platforms, the next four are the platform-specific irreducibles from the last two chapters, and the last four are about everything that happens after the upload.

The four that apply to both

Version bumped in both heads

Chapters 23 and 24 each had a version requirement, and they are different mechanisms — ApplicationVersion producing an Android version code and an iOS CFBundleVersion — but they are one decision. Listing it once is what stops the version being bumped on one platform and forgotten on the other, which produces two releases that claim to be the same version and are not.

Same commit built for both

This is the requirement the chapter is named after, and it is blocking for a reason. Two builds from two commits are two different products, and every bug report afterwards is ambiguous: did that fix ship on the platform the reporter is using?

It is also the easiest requirement to satisfy mechanically. Tag the commit, build both heads from the tag, and record the tag in the release notes. build-all.sh in the repository root builds every chapter's Android and iOS head from one working tree for exactly this habit:

git tag -a v1.2.0 -m "Release 1.2.0"
./build-all.sh

Release configuration

Chapter 22 showed that a Debug build's frame times are not your game's frame times; Chapters 23 and 24 both listed it because both stores care. It is one decision, and it belongs in the shared section.

Test suite green

Chapter 21's suite, run against Core, on the tagged commit, before the builds. Not "the tests passed last week" and not "the tests passed on my branch".

It is worth noticing what makes this cheap enough to be a hard gate: Core has no MonoGame reference, so the suite runs in seconds on any machine with no device attached. That is a property created in Chapter 1 and collected on here.

The four that are platform-specific

Signed AAB uploaded and keystore backed up offsite are Chapter 23's two irreducibles. The keystore item appears on every release rather than once, because "we backed it up" degrades over time — laptops get replaced, people leave, and the only way to know the backup exists is to check.

Distribution profile valid and export compliance answered are Chapter 24's. The profile item is on the list because profiles expire silently and usually on a release morning; the compliance item is there because Connect asks on every single upload and it is a legal declaration rather than a formality.

The four that no store asks for

These are the interesting ones, and they are non-blocking not because they matter less but because a store will accept your build without them.

Store listings match. Two listings that describe different features, or show different screenshots, are one product looking like two. Nobody checks this because no upload fails on it.

Screenshots for every size. Both stores want several device sizes; missing ones leave gaps in the listing that reduce installs and that nobody notices from the inside.

Crash reporting wired up. After the upload, this is how you find out anything at all. Shipping without it means your first knowledge of a crash on a device family you did not test is a one-star review a week later.

Rollback plan written down. The most valuable item on the list and the one most often absent. Before you ship, write down: what does "this release is bad" look like, who decides, and what do we do? On Android the answer is usually "halt the staged rollout", which takes a minute if you planned a staged rollout and is impossible if you shipped to 100%. On iOS you cannot roll back at all — you can only expedite a new build — which means the plan is "keep the previous build ready to resubmit" and is worth knowing before you need it.

Write the rollback plan as three sentences

What is the signal? Who calls it? What is the action? If you cannot answer all three in three sentences, you do not have a plan, and you will be inventing one under pressure.

Readiness versus shippability

The gate distinguishes two different questions:

public int BlockingTotal =>
    Requirements.Count(requirement => requirement.Blocking);

public int BlockingSatisfied =>
    Requirements.Where((requirement, index) =>
        requirement.Blocking && satisfied[index]).Count();

/// <summary>Everything still standing between this build and the stores.</summary>
public IReadOnlyList<Requirement> Blockers =>
    Requirements.Where((requirement, index) =>
        requirement.Blocking && !satisfied[index]).ToList();

/// <summary>Fraction of all requirements met, blocking or not.</summary>
public float Readiness => SatisfiedCount / (float)Requirements.Count;

public bool CanShip => Blockers.Count == 0;

Readiness counts everything and is a progress bar — useful for a status update, and deliberately not what the gate is based on. CanShip counts only blockers, and it is a boolean.

Keeping these separate matters because a percentage invites negotiation. "We are at 92%" sounds shippable and may mean the keystore backup is the missing 8%. CanShip cannot be negotiated with: it is true or it is false, and Blockers names exactly what is in the way.

That is the pattern worth taking from this chapter into any checklist you build. Show progress as a number, and gate on a list. The number keeps people informed; the list is what actually stops a bad release.

Blockers as a list, not a count

Blockers returns the requirements themselves rather than how many there are. A screen saying "2 items outstanding" sends somebody to go and look; a screen saying "KEYSTORE BACKED UP OFFSITE, TEST SUITE GREEN" has already told them.

It is the same instinct as Chapter 21's assertion messages printing expected and actual, and Chapter 20's SaveOutcome distinguishing three kinds of failure. A system that knows why it said no should say why.

Making it a gate rather than a poster

A checklist that lives in a wiki page is a poster. Some things that turn one into a gate:

  1. Put it where the release happens. A script that refuses to publish, a CI job that fails, a pull request template — anything that is in the path rather than beside it.
  2. Make each item checkable in under a minute. "Test suite green" is one command. "Quality is good" is not an item, it is an opinion.
  3. Automate what can be automated. Version bumped, same commit, release configuration and test suite green are all machine-checkable. Four of eight blockers can be a build step rather than a human.
  4. Keep it short. Twelve items is about the limit of what people will genuinely read. A forty-item checklist gets skimmed, and a skimmed checklist is worse than none because it provides false confidence.
  5. Review it after every incident. Something went wrong and was not on the list? Add it. Something on the list has never once been false? Consider removing it.

Caveats

A gate people learn to bypass is worse than no gate

If the gate blocks releases for reasons the team considers unreasonable, they will route around it, and the routing-around becomes the process. Every blocking item must be one that everybody agrees should genuinely stop a release. Move anything contentious to non-blocking rather than fighting about it.

The list is not the process

Ticking "test suite green" does not run the tests. This model is a record of decisions, not an enforcement mechanism, and it is only as honest as the people using it. Automate what you can, and accept that the rest depends on a culture where ticking something you have not done is not acceptable.

It cannot catch what nobody thought of

No checklist catches a novel failure. What it catches is the recurring one — the version code you forget every third release, the profile that expires every year. That is a narrower claim than it sounds and it is worth a great deal, because recurring failures are most of them.

One gate does not mean one release moment

Apple's review takes days and Google's staged rollout takes as long as you let it, so the two stores will never go live at the same instant. The gate is about the build, not the publish. Both stores get the same artefact from the same commit; when each becomes visible is a scheduling question.

Mechanising it

A checklist in a file is a poster; a checklist in the release path is a gate. The four machine-checkable blockers can be a script that runs before either publish:

#!/usr/bin/env bash
# gate.sh - refuses to proceed unless the shared blockers hold.
set -euo pipefail

tag="${1:?usage: gate.sh <tag>}"

# SAME COMMIT BUILT FOR BOTH: everything below builds from this tag.
git rev-parse --verify "$tag" >/dev/null || { echo "no such tag"; exit 1; }
[ -z "$(git status --porcelain)" ] || { echo "working tree dirty"; exit 1; }
git checkout --quiet "$tag"

# VERSION BUMPED IN BOTH HEADS: the two heads must agree, and must differ
# from the previous tag's version.
android=$(grep -o '<ApplicationVersion>[0-9]*' Android/*.csproj | grep -o '[0-9]*')
ios=$(grep -o '<ApplicationVersion>[0-9]*' iOS/*.csproj | grep -o '[0-9]*')
[ "$android" = "$ios" ] || { echo "version mismatch: $android vs $ios"; exit 1; }

# TEST SUITE GREEN, in RELEASE CONFIGURATION.
dotnet test tests/Core.Tests.csproj -c Release

echo "gate open for $tag (version $android)"

Four blockers, forty lines, and none of them can now be forgotten. The remaining four — the two signing items and the two store-specific declarations — are genuinely human decisions, and a shorter human list is one people read.

The dirty-working-tree check is the least obvious and the most valuable. A release built from a tag plus three uncommitted edits is a release nobody can reproduce, and it is very easy to do by accident on the afternoon of a fix.

The shape of a release day

Twelve items is short enough to work through in order, and the order matters because the expensive steps should come last.

  1. Tag the commit. Everything else refers to this tag.
  2. Run the gate script. Version, cleanliness, tests. Thirty seconds, and it fails fast.
  3. Build both heads from the tag. One command, both platforms, same source.
  4. Install both Release builds on real hardware and play for five minutes each. This is the step people skip and the step that catches ahead-of-time failures.
  5. Check the two signing items. Keystore backup verified, distribution profile not near expiry.
  6. Upload iOS first. Its review is the long pole; starting it early means the two platforms go live closer together.
  7. Upload Android to internal testing, then promote to a staged rollout at a small percentage.
  8. Watch crash reporting for a day before increasing the rollout.

Putting the iOS upload before the Android one is the single scheduling decision worth making deliberately. Apple's review is measured in days and Google's rollout is under your control, so starting the slow one first is what keeps the two platforms in step.

Keeping the gate honest

A checklist decays in two directions and both are worth watching for.

Items that have never been false. If a blocker has been satisfied on every release for two years, it is either genuinely automated — in which case it belongs in the script, not the list — or it is not really a risk. Remove it. Every item you keep costs attention that the real risks need.

Failures that were not on the list. Something went wrong that no item would have caught. Add it, and add it as the specific check rather than as a general exhortation: "verify the privacy policy URL returns 200" is an item, "be careful with the listing" is not.

The list should be roughly stable in length. Growing without bound means nothing is ever removed; shrinking to nothing means incidents are not being fed back into it.

Building and running this chapter

The solution is src/Chapter25/Chapter25.sln in the book's repository at github.com/nodoid/MonoGameBook/src/Chapter25.

cd src/Chapter25
dotnet build Android/Chapter.Android.csproj
dotnet build iOS/Chapter.iOS.csproj

To deploy to a connected Android device or a running emulator:

dotnet build Android/Chapter.Android.csproj -t:Run

To run on the iOS Simulator:

dotnet build iOS/Chapter.iOS.csproj -p:RuntimeIdentifier=iossimulator-arm64
xcrun simctl install booted \
  iOS/bin/Debug/net10.0-ios/iossimulator-arm64/Chapter25.iOS.app
xcrun simctl launch booted com.monogamebook.chapter25

Try it yourself

  1. Tick every non-blocking item and none of the blocking ones. Readiness reads 33% and SHIP IT stays dead — the two numbers are answering different questions.
  2. Tick everything except "KEYSTORE BACKED UP OFFSITE" and look at the Blockers list. One item, named, which is all anybody needs.
  3. Copy ReleaseGate.Requirements into your own project and edit it honestly. Remove what does not apply; add what has bitten you.
  4. Move "CRASH REPORTING WIRED UP" to blocking and argue the case with yourself. There is a good argument either way, and making it is the exercise.
  5. Write your rollback plan as three sentences — signal, decider, action — and put it in the repository.

Summary

Two checklists is how a team ships a fix on one platform and not the other. One gate, with requirements tagged by store and shared ones listed once, removes that failure by construction.

Four requirements are about the build and apply to both platforms: version bumped in both heads, the same commit built for both, Release configuration, and a green test suite. The second of those is the one worth being strict about — two builds from two commits are two products, and every bug report afterwards is ambiguous.

Four are the platform irreducibles from the last two chapters, and both of the signing-related ones appear on every release rather than once, because a backup you took two years ago on a laptop you no longer have is not a backup and a profile expires silently.

Four are things no store asks for, and they are where the value is. Crash reporting is how you learn anything after the upload. A rollback plan — signal, decider, action, in three sentences — is what turns a bad release into an hour rather than a week, and it has to be written before you need it because iOS cannot be rolled back at all.

Finally, the shape: show progress as a number and gate on a list. Readiness is for the status update; CanShip is a boolean that cannot be negotiated with, and Blockers names what is in the way rather than counting it.

And that is the book

Twenty-five chapters, twenty-five solutions, one shape. It is worth naming the through-line one last time, because almost every chapter turned out to be an instance of it.

Keep the rules where they can be checked. Chapter 1 refused a MonoGame reference in Core, which cost a hand-written Vec2 and bought a fixed-step accumulator, a state machine, a Sudoku solver and a save serialiser that Chapter 21 could test with no device attached. Everything downstream of that decision was easier because of it.

Derive rather than store. Chapter 9's alien positions, Chapter 9's difficulty curve, Chapter 14's particle liveness, Chapter 16's conflict set, Chapter 10's travel vector. A value computed from state cannot fall out of step with it, and a great many bugs are exactly that falling out of step.

Enforce budgets rather than hoping. Chapter 4's step clamp, Chapter 12's voice limits, Chapter 14's particle pool, Chapter 19's step budget. Each refuses work to protect the frame, and each reports how much it refused, because a system that gives up quietly is one you cannot diagnose.

Say what happened. Chapter 12's audio status, Chapter 13's save outcome, Chapter 20's three distinct failure modes, Chapter 21's assertion messages, this chapter's blocker list. Subsystems that touch the outside world fail for reasons you did not anticipate, and the ones that explain themselves cost you an hour where the silent ones cost you an evening.

And prove it on the device. Chapter 2 drew the line between what a fast loop can tell you and what only hardware can, and every chapter since has landed on one side of it. Rules, arithmetic, draw order and your own crashes are the same everywhere. Touch feel, sustained frame rate, safe areas, signing and packaging are not.

The two games in this book are small on purpose. The shape they are built in is not, and it is the part worth taking with you.