Previously, in Part 10, the app learned to sit on a desk. Everything now works on the bench, and nothing is shippable: the Android build wears a debug signature, the Windows exe assumes a developer's machine, and CI has never heard of the Pocket project. This part fixes all three, writes down how, and — as every part of this season has — says out loud what it did not manage.

The prompt: make it shippable, fake nothing

Verbatim from the commit in the repo (tag pocket-11):

"Make it shippable. Android: a keystore created once outside the
repo with a fictional identity, and a release publish signed with
properties passed on the command line — never in the csproj — producing
the APK and the AAB; verify the signature with apksigner and run the
release build on the emulator. Windows: a self-contained, unpackaged
publish that runs with nothing installed, and an honest look at MSIX.
CI: build the Android app on Linux (workload + Java 17, no Firebase file)
and the Windows app on windows-latest, alongside the existing clinic
build-and-test. Write docs/pocket.md — hosts, secrets, signing, publishing
— and give the README a season-three table."

Notice "an honest look at MSIX" rather than "package it as MSIX." That clause was written knowing where it would probably end, and it's the reason this post can show you a real signed APK and a real self-contained folder without pretending the third artefact exists.

Model pick: Sonnet, medium effort. Signing, publishing and workflow YAML are toolchain plumbing with well-documented shapes. Opus for one decision only: what not to fake — whether to ship an MSIX that couldn't be installed, and whether to keep claiming Windows toasts were "nearly there." It said no to both.

Android: a key you make once, and never commit

A release APK is signed with a key that must be the same for every future update, which makes the keystore the single most valuable file the project will ever have — and the one that must never be in the repository. It's created once, outside the repo, with a made-up identity, exactly as the runbook now records it:

keytool -genkeypair -v -keystore .secrets/pocket-release.keystore -alias pocket \
        -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ClinicLive Demo, O=ClinicLive, C=ZZ"

C=ZZ is not a country; that's the point. The .secrets/ folder is git-ignored, the password lives in a sibling file that is also ignored, and the publish command references both without ever writing them down:

dotnet publish src/ClinicLive.Pocket -f net10.0-android -c Release \
  -p:AndroidKeyStore=true \
  -p:AndroidSigningKeyStore=$PWD/.secrets/pocket-release.keystore \
  -p:AndroidSigningKeyAlias=pocket \
  -p:AndroidSigningKeyPass=env:POCKET_KEY_PASS \
  -p:AndroidSigningStorePass=env:POCKET_STORE_PASS

The env: prefix tells the Android build to read the passwords from environment variables, so they never appear in a shell history or a build log. And none of it is in the .csproj, on principle: the project file is public and describes the code; signing describes who is publishing, which is an input to a build, not a property of the source. The same command runs unchanged in CI with the secrets injected from the CI vault. Out came two files — com.cliniclive.pocket-Signed.apk at 33.8 MB for sideloading and -Signed.aab at 33.3 MB for the Play Store — and apksigner verify --print-certs reported the demo distinguished name. The release build then went onto the emulator, after uninstalling the debug build first: two builds with different signatures can't share a package name.

The signed release build running on the Android emulator: the Home screen with the 'Your visit, in your pocket' hero, the 'Find my appointment' card with its six-dot code field, a 'Scan my ticket' button, and five tabs along the bottom.
The release APK on the emulator. It looks identical to the debug build, which is the correct outcome: the proof of this part is the signature apksigner printed and the fact that the app opened at all with the debug one gone — not anything in the pixels.

Windows: a folder that runs anywhere

The Windows story splits in two, and the split is the honest part. First, the thing that works:

dotnet publish src/ClinicLive.Pocket -f net10.0-windows10.0.19041.0 -c Release \
  -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:SelfContained=true

That produced 643 files and 235 MB — a folder you can zip and hand to a receptionist, with no .NET runtime and no Windows App SDK to install first. The published exe was launched from that folder, and the running process's path confirmed it was the publish output and not the debug build sitting next to it. What you don't type is -r win-x64, and the catch below explains why.

The published, self-contained Windows build launched from its publish folder: the same dark 1100 by 760 window as Part 10, side rail on the left with Home selected, the hero and the code card in the content column.
The self-contained build, launched from the publish folder. Pixel-for-pixel the Part 10 window — same white margin from the harness and all — because the evidence here was the process path, not the picture.

Second, the thing that doesn't. Part 10 established that Windows toasts need an app identity, and MSIX packaging is how an app gets one. So the MSIX build was tried, honestly: dotnet build with WindowsPackageType=MSIX and GenerateAppxPackageOnBuild=true ran to completion, reported success — and produced no sideloadable package anywhere outside obj/, only the App SDK's own framework packages. Producing an installable MSIX from the command line needs the packaging targets that Visual Studio's Package-and-Publish wizard drives, plus a signing certificate the target machine trusts. Installing a self-signed one means telling a Windows machine to trust a certificate you made up, and that is not a step this series will demonstrate. So the season's answer is stated plainly: packaged identity is the Store and toast path; this series ships unpackaged, and Windows toasts stay open. The runbook says the same thing in the same words.

CI for three targets

Season one's workflow built the clinic and ran the tests on Ubuntu with a real PostgreSQL from Testcontainers. Two jobs join it:

# Season three: the phone app compiles on Linux — no Firebase file in CI means no
# push resources, which the csproj tolerates (the GoogleServicesJson item is
# conditional on the file existing).
build-pocket-android:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with: { dotnet-version: 10.0.x }
    - uses: actions/setup-java@v4
      with: { distribution: temurin, java-version: '17' }
    - run: dotnet workload install android
    - run: dotnet build src/ClinicLive.Pocket -f net10.0-android --configuration Release

build-pocket-windows:
  runs-on: windows-latest
  steps:
    # …checkout, .NET 10…
    - run: dotnet workload install maui-windows
    - run: dotnet build src/ClinicLive.Pocket -f net10.0-windows10.0.19041.0 --configuration Release -p:WindowsPackageType=None

The Android job is the interesting one: a Linux runner has no Firebase configuration file, because that file is git-ignored on purpose. The build survives because Part 6 wired the file in conditionally:

<GoogleServicesJson Include="Platforms\Android\google-services.json"
                    Condition="Exists('Platforms\Android\google-services.json')" />

No file, no push resources, still compiles — a machine with the file gets Firebase, a machine without gets an app that simply never registers a token, which is what IPushRegistration answering null was designed for. One honest timing note: at the moment the tag was cut, the workflow existed but hadn't run — CI runs on the push, and the commit message says so.

Post-publication addendum — what the first real run did: the push that published this season was that first run. The clinic's tests passed, the Windows job passed, and the Android job failed before compiling a line: NETSDK1147: To build this project, the following workloads must be installed: maui-android. Look at the workflow above — it installs android, the bare Android workload, and a MAUI project needs maui-android. A one-word fix, committed on top of pocket-11 as "CI: install maui-android, not android"; the re-run went green on all three targets. The tag keeps the wrong word, because that is what shipped, and the lesson belongs next to the code: a workflow that has never run is a hypothesis, however reasonable it reads.

The runbook

The last deliverable is docs/pocket.md, because a companion app has more moving parts than a web app and the next person to clone the repo shouldn't have to read eleven posts. It's four short sections: how each host finds the clinic — the emulator's 10.0.2.2 versus a real phone needing your PC's LAN address, the server on 0.0.0.0 and a firewall rule; the two secret files, what each is for and where it goes; the signing and publishing commands above; and CI. The README gained a season-three table mapping pocket-02 through pocket-11 to their parts, the way the earlier seasons' tags are mapped.

What the AI got wrong: three catches, and they share a shape. One: the first Windows publish used -r win-x64, the idiomatic flag for a self-contained app — and on a multi-target MAUI project the runtime identifier applies to the restore for every target framework, so NuGet went looking for an Android Mono runtime pack for win-x64 that doesn't exist (NU1102). Drop the flag; the Windows target already defaults to win-x64. Two: the MSIX build "succeeded" and produced nothing usable — success was the exit code, not the outcome. Three, the mirror image: apksigner.bat returns a non-zero exit code after printing the certificates, so the harness reported a verification that had succeeded as a failure. Lesson of the part: an exit code is a claim, not a fact. Look in the output folder; read the output; believe neither direction on its own.

The meter: ≈ $7.20 — season three's final reading. This part itself was proportionate; the two big dead ends of the season were earlier, the emulator's QR poster hunt in Part 8 and the Windows toast chase in Part 5. The retro has the whole bill.

Checkpoint: git checkout pocket-11 in the repo: follow docs/pocket.md to make your own demo keystore and the publish command produces a signed APK and AAB you can verify with apksigner; the Windows publish produces a folder that runs on a machine with nothing installed; .github/workflows/ci.yml builds all three targets. 28 tests green — the same 28 as Part 8, because shipping changed nothing the tests can see.

Eleven parts, nine capability interfaces, three hosts, one signed build. The meter has stopped at $7.20 and it's time to look at everything at once: the gallery across phone, desk and browser, every 🤖 in one table, the three things the bench could not prove, and the question the whole season was really asking — Part 12: The Pocket Retro: What a Companion App Really Costs.