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.
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-localechangelogs/directory inside the metadata tree, the convention that a changelog file is named for the version code of the build it describes, anddefault.txtas 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 anddefault.txtare 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_changelogmethod insupply/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 anddefault.txtsecond, and when neither file exists the miss is reported as an ordinary informational lane message while the method still returns aLocalizedTextfor 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 asreleaseNoteson a track release (Track.releases[]), carried as an array ofLocalizedTextobjects pairing alanguagewith itstext— 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 constrainsuserFractionto releases whose status isinProgressorhalted. - Google Play Developer API v3 —
edits.listings: documents the complete field set of a store listing —title,shortDescription,fullDescription,video, andlanguage. 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.