WizusLabs Engineering · Studio

Release notes keyed to a number that moves

A production release was one command away from going out with a blank “What’s new” in every language it supports. Nothing was broken. The notes were written, correct, and translated — and filed under a build number our own release process had just changed.

By WizusLabs Engineering · 2026-08-12 · ~12 min read

Google Play release notes are attached by filename. The standard tool for the job, fastlane supply, builds the path <metadata>/<language>/changelogs/<versionCode>.txt — and when that file is not there, it does not stop. It submits an empty string for that locale and carries on. A re-cut is a rebuild that bumps the build number without touching the marketing version, which is an ordinary thing for us to do and the exact thing that breaks the match. On 2026-07-30 we were one command away from promoting Sudoku 1.0.6 to production as versionCode 140, with release notes that lived in 139.txt. The tool would have done precisely what it documents. The failure would still have been total, in 18 languages, with a success response on the way out. We changed the number the filename was keyed to, and a filename has no way to tell you that.

A filename is a lookup key, and ours moves on purpose

Two numbers ride along with every Android release, and only one of them is for humans. The marketing version is the string a player sees — 1.0.6 — and it changes when the product changes. The versionCode is a monotonically increasing integer that exists so the store can tell two uploads apart; it must be unique per artifact and it never goes backwards. Those two move on completely different schedules, and the gap between them is deliberate: we treat a version string as a claim about the product and a build number as a claim about the bytes, which is the whole argument for keeping them separate in the first place.

So a re-cut happens for reasons that have nothing to do with the release notes. A store rejects an upload, a signing detail changes, a build flag was wrong, the previous versionCode was already consumed. You rebuild, the integer advances, the marketing version stays exactly where it was because the product did not move. That is the correct behaviour. It is also, quietly, a rename of the key that the release-notes lookup depends on — and the notes themselves have no idea it happened. They are still on disk, still accurate, still translated. They are simply filed under the number the artifact used to have.

How a re-cut orphans release notes that are resolved by filename Release notes are authored once into changelogs/139.txt across 18 locales. A re-cut then rebuilds the same marketing version 1.0.6 under a new versionCode, 140. The publishing tool resolves notes by filename and looks for changelogs/140.txt, which does not exist. With no default.txt fallback present either, it logs a line and submits an empty string for each locale, producing an empty “What’s new” field in all 18 locales, while the authored notes remain on disk, correct and unreferenced. The promote still returns a success response. authored once · resolved by filename Notes, written changelogs/139.txt 18 locales · reading “Sudoku 1.0.6” The re-cut 1.0.6 → 1.0.6 code 139 → 140 The lookup looks for 140.txt by name, not by version no match, no fallback a log line, not a gate empty “What’s new” in all 18 locales orphaned correct, unreferenced the promote still succeeds
The notes never became wrong — they became unreachable. Every step here is behaving exactly as documented, which is why the loudest complaint anywhere in the chain is a log line.

What the track actually looked like, minutes before the promote

Here is the state we read straight out of the publishing API that day, before touching anything. The production track was serving versionCode 131, marketing version 1.0.5, with release notes present in 18 locales — the previous release, healthy. The internal track held versionCode 140, marketing version 1.0.6: the artifact that was about to become production. Its release notes: none. Not truncated, not partial, not missing in one language. Zero.

The authored notes for 1.0.6 did exist, and they were complete. All 18 locales had them, and the first line of the file read Sudoku 1.0.6 — current, correct, exactly the text we wanted a player to read. They were in changelogs/139.txt. There was no 140.txt anywhere in the repository, in any locale; 139 was the highest changelog number present, and it was present in every one of the 18. The writing was finished. Only the number in the filename was stale, because 140 was a re-cut of the same 1.0.6, and nobody renames a file to celebrate a rebuild.

This is where the near-miss stays a near-miss: we did not ship a blank release. The engineer running the promote read the tool’s resolution rule before choosing a path, saw that the filename it would search for did not exist, and promoted through the Google Play Developer API directly instead — which lets the correct note set be attached explicitly rather than found by name. Production went out with notes in all 18 locales. No player ever saw an empty “What’s new.” This is a post about a mechanism that would have failed silently, not about a failure we inflicted, and that distinction is worth stating plainly rather than letting a dramatic story imply otherwise.

The safety net we had opted out of without deciding to

Anyone who has read the publishing tool will already be objecting, and they are right to. supply is not careless about this. When the per-versionCode file is missing it looks in the same directory for a documented fallback, default.txt — general-purpose notes to use when there is nothing build-specific. That fallback exists precisely so that a missing 140.txt is a non-event. Only if both files are absent does it log a “Could not find changelog” line and go ahead, submitting a localized-text object per locale carrying an empty string. So the correct description of what would have shipped is not “no release notes” — it is blank release notes, deliberately constructed, one per language, all 18 of them.

Which raises the question that turns this from a tooling anecdote into something worth writing down: why did the fallback not catch it? Because we do not have one. We searched the whole repository for a changelogs/default.txt in any locale and found zero — against exactly a hundred files sitting at that exact path with numeric names, so the zero is a real absence and not a mistyped search. We had opted out of the safety net without ever deciding to. Nobody removed it; nobody added it. Every release we had ever done had a matching numeric file, so the fallback was never consulted and its absence never cost anything — right up to the first re-cut, where it was the only thing standing between a filename miss and a blank rollout.

That is a more uncomfortable finding than a bug would have been. A bug is something that went wrong. This is a default we never chose, protecting nothing, invisible for as long as the happy path held. The log line is the same shape of comfort: it is real, it is emitted, and it appears in the middle of a lane that goes on to report success. A message nobody is required to read is not a gate.

The fix contained no new words

We remediated the same day, and the commit that did it is the sharpest evidence in this whole account. It is titled add Play release notes for versionCode 140 in all 18 locales, which sounds like eighteen small pieces of writing. It is not. Each locale’s new 140.txt is byte-identical to that same locale’s 139.txt — we hashed all eighteen pairs, eighteen matched, none differed. They are translations of one another, so there are eighteen distinct hashes rather than one shared value; the en-US pair is the worked example, both sides landing on ab677d4816681e2abb40698553f1d9b6926064a4. Not near-identical, not lightly edited. The same bytes, under a different name.

Sit with that for a second, because it is the proof that the diagnosis was right. A fix that adds no words cannot have been fixing the words. The content was never the defect; the name was the defect, and the name was load-bearing in a way nothing about the file’s contents could reveal. If you had reviewed those eighteen files the day before — read every line, checked every translation, confirmed the version string at the top — you would have found nothing wrong, because there was nothing wrong with them. The only broken thing was a number in a path, and reviewing content does not review paths.

This is the class of defect we find hardest to catch, and it is not because it is subtle. It is because the artifact looks correct under every inspection aimed at the artifact. The coupling lives outside the file, in an agreement between a build process and a lookup rule, and neither end of that agreement is written down anywhere a reviewer would think to look.

A success response tells you the track moved, not what it carried

The reason this would have shipped is that every signal on the way out is green. The upload succeeds. The track update succeeds. The promote returns a success response, which is genuinely true: the track did move, the rollout did reach production, the artifact is live. None of that is a statement about release notes. A success response is a statement about your request, not about the store’s state — the same thing we ran into every time we wrote to a store listing and believed the reply. “Did it succeed” and “did it carry what I meant” are two different questions, and only one of them gets answered by the response code.

So the verification is a separate read: after the promote, open a fresh, independent edit against the API and ask the track what it holds. Not the mutating call’s own response — that reply is written by the same request whose effect you are trying to confirm, and it will happily agree with you. On the actual promote, the read-back showed production at versionCode 140, marketing version 1.0.6, status completed, with release notes in all 18 locales. That is a measurement. The 200 was not.

The same read-back turned up a detail that explains where release notes actually live, and it is worth knowing before you go looking for them in the wrong place. The internal track still showed zero release notes afterwards, even though it holds the identical bundle. Notes do not attach to the artifact and they do not attach to the listing. They are a property of a track release. Each track’s release carries its own set, so moving a bundle from one track to another does not bring its notes along — there were none on internal to bring.

This also settles a fear that would otherwise make a release engineer hesitate: does attaching notes at promote time risk disturbing the store listing? It cannot. The Play Listing resource has no release-notes field at all — its entire field set is title, shortDescription, fullDescription, video, and language. There is no door between the two to accidentally walk through. We confirmed this the boring way as well: after the promote, the title, short description, full description, all 24 screenshots, the feature graphic and the icon were checked field by field and were byte-for-byte unchanged. Not because the API is forgiving, but because release notes were never in that resource to begin with.

The guard runs before you pick the path, not after

The remedy we adopted is deliberately unimpressive: count the files. Before a promote, count the changelogs/<versionCode>.txt files that exist for the versionCode you are about to ship, and compare that count against the number of locales you support. Eighteen of eighteen means the filename-resolving path is safe and you can use the convenient tool. Zero or partial means you attach the authored set explicitly through the API instead, and you name in the release record which files you used and why.

The timing is the entire design, and it is the part that is easy to get wrong. This check has to run before the promote path is chosen, not as a validation after it. Run it afterwards and it is a post-mortem: you learn that a rollout you have already published has no notes, and undoing that is a new release, not an edit. Run it first and it is a routing decision — it does not block the release, it picks the mechanism. That is what makes it cheap enough to keep. A guard that stops work gets argued with; a guard that chooses between two working paths gets used.

The discipline held on the next cut, which is the only evidence that a lesson landed rather than being written down. For versionCode 154, the notes were authored for 154, in all 18 locales, on 2026-08-11 — before the cut instead of after it. Whether that survives the tenth re-cut on a bad week is a fair question, and it is the reason the file-count check exists at all: we would rather the process not depend on anyone remembering.

Anything resolved by name is coupled to naming

The specific bug is small and easy to fix once you have seen it. The shape is not, and it is everywhere. Any time a system finds a resource by constructing a path out of a value, that value has quietly become part of an interface — and the file at the other end has no way to declare what it is for. A translation keyed to a locale code, an asset keyed to a density bucket, a migration keyed to a schema version, a changelog keyed to a build number: each one is content that is correct in itself and reachable only by a name someone else controls. Rename the key upstream and nothing complains, because nothing was ever asked to.

The lesson we took is not “stop resolving by filename” — convention over configuration earns its keep, and the alternative is a manifest that drifts. It is narrower and more useful than that: when a lookup is keyed to a value your own process is designed to change, the coupling is a release-time check, not a naming convention. Write the file however you like. Just do not assume it will still be found under a number you have since moved. The game in this story is Sudoku by WizusLabs, and the release it nearly shipped blank went out with notes in every language it supports — not because the tool protected us, but because someone read the tool’s rule before trusting it. More of these small shipping disciplines, and the ways they compound, are on the WizusLabs Engineering blog. A file that is found by its name is only as durable as the name, and ours was never meant to hold still.

Notes

This is a first-hand account of one release from our own records — a single Google Play production promote on 2026-07-30 and the cut that followed it — and every number in it is something we read out of our own repository or the publishing API at the time. No user-facing impact metric is attached to any of it, because there is none to report: the defect was caught before the promote, the release shipped with notes in all 18 locales, and no player ever saw a blank “What’s new.” Inventing a number here would be the exact failure the post is about. We have also left out one measurement we could not reconcile between two of our own tools; an unreconciled figure is a fabricated figure with extra steps, so it does not appear. Publishing tools and store APIs are living things — filename conventions, fallback behaviour, and resource field sets all change between versions, and the behaviour described here is what we measured against the versions we were running. Verify the current documentation and the tool you actually have installed before relying on any of it for a release of your own.

Sources

The resolution rule, the fallback, and the shape of the publishing resources are documented by the tool and platform vendors; one item below is a first-hand reading of shipped source rather than documentation, and is labelled as such. All are living documents (accessed 2026-08-12):

  • fastlane — supply / upload_to_play_store: documents the per-locale changelogs/ directory inside the metadata tree, the convention that a changelog file is named for the version code of the build it describes, and default.txt as the fallback used when no version-code file matches. Worth noting what it does not settle: the documented fallback is described, but the behaviour when the version-code file and default.txt are both absent is not stated there — for that we read the shipped code, next.
  • fastlane 2.237.0, shipped source read first-hand — the upload_changelog method in supply/lib/supply/uploader.rb (cited by file and method; line numbers move between releases). What we established by reading it: the changelog text starts empty, the version-code path is tried first and default.txt second, and when neither file exists the miss is reported as an ordinary informational lane message while the method still returns a LocalizedText for that locale carrying the empty string. That is the mechanism behind our claim that the outcome is blank notes rather than absent notes. This is a code reading of one pinned version, not vendor documentation — a different fastlane version may behave differently, so check the gem you actually have.
  • Google Play Developer API v3 — edits.tracks: documents release notes as releaseNotes on a track release (Track.releases[]), carried as an array of LocalizedText objects pairing a language with its text — which is why notes belong to a track release rather than to an artifact or a listing. The same resource documents the release status field and constrains userFraction to releases whose status is inProgress or halted.
  • Google Play Developer API v3 — edits.listings: documents the complete field set of a store listing — title, shortDescription, fullDescription, video, and language. That enumeration is the affirmative basis for the stronger claim in this post: the listing resource has no release-notes field at all, so attaching notes at promote time cannot disturb it.

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog