Definition of done — what is still open
The live checklist. Keep it short: when an item closes, tick it, write the result into whatever long-form record you keep, and delete the detail from here. A session should be able to pick up the work by reading this file alone.
The standing schema gate — never ticked, checked at every bump
An update must migrate an existing install without losing anything (ADR-0001). This item does not
close. Whenever APP_SCHEMA_VERSION changes, all five hold before the release goes out:
MIGRATION_x_ywritten and registered inAPP_MIGRATIONSfor every step. A migration Room never runs is not a migration, and nothing about the build complains.- The exported
app/schemas/*/N.jsoncommitted — every later migration is transcribed from it, never from the entity classes. SchemaGateTestextended, so the launch gate is proven to let the upgrade through. Every migration test opens the database directly and walks past that gate; this assertion is the only thing standing in front of it.- A migration test proving the rows survive — read values back, do not merely assert nothing
threw.
runMigrationsAndValidatecompares shapes and is blind to a table emptied by a cascadingDROP. - An actual upgrade watched on a phone: install the previous build, put real data in it, install
the new one over the top, confirm the app opens and the data is there.
./gradlew assembleDebug -PreleaseShapedDebugis how to do this without touching a Play install — it is minified andBuildConfig.DEBUG == false, so migrations are registered rather than the destructive fallback. ⚠️ Both halves are load-bearing: a plain debug build takes the fallback and proves the opposite of what you wanted.
scripts/schema-gate.py enforces 1–3 mechanically in CI. Items 4 and 5 are yours.
Before the first upload
- Delete what the app does not need — the photo pipeline and exact alarms, in phase 0
(
phase-0.md); Room, backup and the reminder module stay. - Decide
minSdkdeliberately, before launch. The direction is asymmetric: raising it later strands existing installs on the last build that fitted them, lowering it later is free. If the only phone in the loop sits at the top of the range, everySDK_INTbranch below it ships unvalidated — the first app built from this template raised 26 to 33 for exactly that reason. Move CI’s floor legs with it, and write the reason besideminSdkinapp/build.gradle.kts, where the next person to touch it reads it. - Read the themed icon at 48dp. Tally is drawn and every icon regenerated; only this reading is owed (docs/phase-0.md, Readings): the strike’s gap read so far at the launcher’s 61dp.
- Replace the placeholder domain — schema 1 and Home, in phase 0.
- Rewrite
scripts/edge-to-edge.py’s SCENES — they walk Home, a Counterparty, Settings, Add Loan from Home and from a Counterparty, and the Currency picker. - Run all four
aab-*.pyscripts against a realbundleRelease— all four passed in 0.2.0’s publish run (2026-11-12), on the bundle that was uploaded. - Choose the palette. Twilight indigo, slate lavender and honey ochre (docs/phase-0.md, The palette).
- Make the support address work both ways.
bootstrap.pycompiled in<repo>.support@maksforge.com(or--support) and wrote it into the site, the privacy policy and the Play docs. Mail to it arrives through the domain’s catch-all; replying from it needs the receiving Gmail’s Send mail as entry, confirmed. Send Report a problem from a non-Gmail account and check it lands outside Spam. One address everywhere — the listing’s contact field and IARC’s rating contact too;ui/support/SupportConfig.ktsays why it lives on our own domain. - Write the listing.
docs/store-listing.md— every heading in it is parsed by a script. The Console’s Review step then asks for the AI-generated assets declaration: the owner’s to make. - Capture the screenshots with
scripts/screenshots.pyfrom a release-shaped build, in every listing language, intoart/play-screenshots/. Play wants at least two before the first review. - Put the app on maksforge.com. Its icon goes at
assets/icons/<repo>.webpin maks-sredniawa/maks-sredniawa.github.io, which the app’s own site loads, and the app goes on that home page. Replacedescriptionindocs/_config.ymltoo: it is the tagline under the app’s name. - Write the privacy policy and confirm
maksforge.com/<repo>/privacy-policyserves it. Play requires a hosted URL, and an offline app has no server of its own. - Answer the Console’s App content pages and write
play-app-content.mdin the same sitting — the reasons are in front of you then and gone a week later. It moves in the same commit as the privacy policy, every time. Re-read Target audience after the content rating is submitted. LICENSEandREADME.md. Bootstrap wrote a source-available notice and a Contributing that refuses pull requests, or with--licence reserveda notice that grants nothing and a placeholder. Keep both or replace both; they must agree.- Back up the upload key off this machine — confirmed by the owner on 2026-11-13; the key
is
~/.keystores/iou-tracker-upload.jks, its fingerprint inRELEASING.md. Where the backup sits is deliberately kept out of this public repo and held by the owner. - The release-please PAT — set 2026-11-12, expires 2027-11-12. Renew it before then: a lapsed PAT fails the Release Please run and no release PR opens.
- Set the five Play secrets and create the service account —
iou-tracker-play-ciin the Cloud projectiou-tracker-play; 0.2.0 reached internal testing through it on 2026-11-12. - Plan the closed test. A new personal developer account reaches production only after a
closed test — at the time of writing 12 testers opted in for 14 continuous days, then an access
application. Recruit the testers before the build is ready, not after;
publish-play-closed.ymlmoves each build to them. A tester-swap community, where developers test each other’s closed tests, filled the apps built from this template within days. Testers: internal is the email listonly-me-Tester, every closed test both groupstesters-community@googlegroups.comandmaks-forge-testers@googlegroups.com— never a per-app group or list. The first closed release of a never-published app is made in the Console; the workflow takes over after its approval (RELEASING.md). - Decide the
applicationId—com.maksforge.ioutracker, fixed once the Play entry exists.
The tip — open until a real purchase is consumed
ADR-0007. The code is in; these are the parts only Play and a phone can prove. The steps are
Selling the tip in RELEASING.md.
- Keep it or delete it, before the first upload. Deleting is in
optional-modules.md; keeping it costs Brazil’s rating (14+). - A build with billing on internal testing, then the four products created and active.
- One purchase from a licence tester on the internal install, ending in the thank-you line, and the same tip bought again — the second purchase is the proof that the first was consumed.
- The debug build shows no tip section and no card. Play has never heard of the
.debugpackage, so absent is the correct reading, not a failure. - Content rating re-submitted with digital purchases Yes, before production.
Before the next upload
Re-declarations a change owes Play, written down the day the change merges and ticked when the Console matches. A new permission, foreground service or data type is a Console change that waits in Publishing overview for Send for review with the upload that needs it. Empty most of the time.
After the closed test
- The production-access application is in, with its answers recorded verbatim in
play-app-content.md. Until it is granted the production track does not exist. The app this template was extracted from waited one day; that is one datapoint, not a promise. If it comes back rejected, read the reason before changing any artifact. Changing the build first leaves you arguing the reason against a different build. - Add the production countries in the Console — Test and release → Production → Countries /
regions. A new app’s production track targets none, the API cannot add them, and a dry run
does not catch it (
RELEASING.md). - Decide the first production number. release-please never jumps to
1.0.0by itself (with the major at 0 evenfeat!:bumps the minor), so it takes aRelease-As: 1.0.0footer (RELEASING.md), or a deliberate decision to stay on 0.x. In order: production access granted, then that version’s release notes in every locale on an ordinary branch (never on release-please’s own, which it force-pushes), then the AAB and the listing go up together.
Standing checks that never close
- Every release: run the artifact checks on the built AAB, not on the source.
aab-permissions.pyis the one that finds what a dependency merged into your manifest — a permission you never declared, or auses-featurethat quietly filters the app off devices. - Every release: read the release notes gate’s output rather than trusting it passed.
- Every Play declaration: confirm Data safety still holds against
play-app-content.md, and write the date of the re-read here. - Every dependency bump: re-run
licensee. The build fails on an unallowed licence, which is the point — but the fix is two things, the allowlist and the bundled text. - Test the release-shaped build on a device. A missing R8 keep rule does not crash; it makes a feature silently stop working.