Eight commits. CI green on 90bb3538 (release run 6260, test run 6261).
The versioning rework
The tag shape was the cosmetic half. Two things were actually broken.
versionCode was a commit count, with build.gradle.kts asserting directly above it that this was "monotonic forever" — a reassurance sitting on top of the bug it denied. A commit count runs ahead on dev, so a dev build outranked the main release meant to replace it and Android refused the install as a downgrade: a channel you could enter and not leave without uninstalling.
The offer and the install asked different questions. The client compared version names; Android installs by versionCode. Nothing kept the two orderings consistent, so the app could offer a build the platform then refused — or stay silent about one it would have accepted.
Now: the name derives from commit time (so every lane building one commit reports one string) and the ordering key from build time (so it is monotonic by construction). Two clocks, deliberately — each answers the question the other cannot. The sidecar and /api/client/version carry all three values as siblings, and the client decides on the key the platform actually installs by.
Tags become vYYYY.MM.DD.HHMM — literally the artifact's own version name with a v. release.yml had been instructing the reader to git push -f a published tag; that is now stated as forbidden, with the old advice recorded as retired rather than deleted.
A dev channel
There was none. release.yml ran only on main and tags, so no APK existed outside a release and the only way to get a build onto a phone was to ship one — which made main the staging area by default.
A push to dev now builds a signed APK and publishes :dev, alone. Same signing key as stable, so a phone crosses channels in either direction without an uninstall. Verified on its own first run: one tag pushed, sidecar {"name":"2026.09.10.0247","code":3519527,"channel":"dev"}.
The miniplayer
Reported with a screenshot. The bar drew at 80dp but its content hugged the top, leaving 28dp of dead surface above the gesture area — 80 − 4 − 48, matching the measured 99px at 3.5× exactly. The row was already centring its content, inside a box only ever 48dp tall.
Guards
The version derivation moved into ci/version.sh so it can be executed rather than asserted on as text, and test-go.yml now triggers on ci/** and release.yml — verified by a commit touching only the script, which fired the lane.
Also guarded: a dev push must never move :latest (that ships untested code to every stable operator, with a green build and a valid image — just the wrong audience), and the rebundle path is gated to main specifically rather than "not a tag", or a dev push would overwrite its own fresh APK with the previous release's.
What this merge does and does not ship
:latest moves on merge, so the web and server changes go live. The Android changes reach devices only on the tag that follows — including the miniplayer fix and the bundled typefaces.
Briefly between merge and tag, :latest will bundle v2026.09.09's APK with a name-only sidecar (code: null), because that release predates sidecar assets. It fails safe — a client on 2026.09.09.1895 is offered nothing — and resolves the moment the new tag builds.
Eight commits. CI green on `90bb3538` (release run 6260, test run 6261).
## The versioning rework
The tag shape was the cosmetic half. Two things were actually broken.
**`versionCode` was a commit count**, with `build.gradle.kts` asserting directly above it that this was *"monotonic forever"* — a reassurance sitting on top of the bug it denied. A commit count runs ahead on `dev`, so a dev build outranked the `main` release meant to replace it and Android refused the install as a downgrade: a channel you could enter and not leave without uninstalling.
**The offer and the install asked different questions.** The client compared version *names*; Android installs by `versionCode`. Nothing kept the two orderings consistent, so the app could offer a build the platform then refused — or stay silent about one it would have accepted.
Now: the **name** derives from commit time (so every lane building one commit reports one string) and the **ordering key** from build time (so it is monotonic by construction). Two clocks, deliberately — each answers the question the other cannot. The sidecar and `/api/client/version` carry all three values as siblings, and the client decides on the key the platform actually installs by.
Tags become `vYYYY.MM.DD.HHMM` — literally the artifact's own version name with a `v`. `release.yml` had been *instructing* the reader to `git push -f` a published tag; that is now stated as forbidden, with the old advice recorded as retired rather than deleted.
## A dev channel
There was none. `release.yml` ran only on `main` and tags, so no APK existed outside a release and the only way to get a build onto a phone was to ship one — which made `main` the staging area by default.
A push to `dev` now builds a signed APK and publishes `:dev`, alone. Same signing key as stable, so a phone crosses channels in either direction without an uninstall. Verified on its own first run: one tag pushed, sidecar `{"name":"2026.09.10.0247","code":3519527,"channel":"dev"}`.
## The miniplayer
Reported with a screenshot. The bar drew at 80dp but its content hugged the top, leaving 28dp of dead surface above the gesture area — `80 − 4 − 48`, matching the measured 99px at 3.5× exactly. The row was already centring its content, inside a box only ever 48dp tall.
## Guards
The version derivation moved into `ci/version.sh` so it can be **executed** rather than asserted on as text, and `test-go.yml` now triggers on `ci/**` and `release.yml` — verified by a commit touching only the script, which fired the lane.
Also guarded: a dev push must never move `:latest` (that ships untested code to every stable operator, with a green build and a valid image — just the wrong audience), and the rebundle path is gated to `main` specifically rather than "not a tag", or a dev push would overwrite its own fresh APK with the previous release's.
## What this merge does and does not ship
`:latest` moves on merge, so the **web and server** changes go live. The **Android** changes reach devices only on the tag that follows — including the miniplayer fix and the bundled typefaces.
Briefly between merge and tag, `:latest` will bundle `v2026.09.09`'s APK with a name-only sidecar (`code: null`), because that release predates sidecar assets. It fails safe — a client on `2026.09.09.1895` is offered nothing — and resolves the moment the new tag builds.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it
"monotonic forever". It is not, and that claim was sitting directly above
the bug it denied.
A commit count runs ahead on `dev`. So a dev build carried a HIGHER code
than the `main` release meant to supersede it, and Android refuses that
install as a downgrade — a channel you can enter and cannot leave without
uninstalling and losing local data.
Two clocks now, and the split is deliberate even though it reads like an
inconsistency:
The NAME answers "is this the same code?", so it derives from COMMIT time
and reads identically on every lane building this source. A dev build and
a main build of one commit must report the same string. Build time cannot
do that — it prints two numbers for one thing.
The ORDERING KEY answers "may this be installed over that?", so it must be
monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails
here for the mirror-image reason — rebuild an older commit and it goes
DOWN, which on a phone is a refused install rather than a confusing label.
The non-tag :latest path reconstructed the bundled APK's name with the old
formula, so it is moved to the same commit-timestamp derivation. That
duplication is temporary: once the tag becomes `v<version-name>` it
collapses to `${TAG#v}` with nothing left to keep in step.
Verified locally by running the derivations rather than reasoning about
them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895
from the old scheme, inside int32 with ~4000 years of headroom; a commit
at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted
shape too — a malformed name builds, signs and publishes happily and only
surfaces as an update nobody is offered, which nobody reports.
That local check is the only verification this commit gets. release.yml
triggers on main and tags only, so nothing on `dev` executes the new
derivation; CI here proves the Gradle file still parses and nothing else.
Also confirms the migration constraint recorded in milestone #390: this
commit would name a release 2026.09.09.1828, which is LOWER than the
installed 2026.09.09.1895 under name comparison. The first new-scheme
release must be cut on a later calendar day, or existing installs will
never be offered it.
Step 1 of 5 — Scribe task #3808, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
The tag is now the artifact's own version name with a `v` in front, so
`v2026.09.10.1432` and `2026.09.10.1432` are one string. Nothing has to
reconcile what the tag claims against what the APK reports, and minting one
is arithmetic on the tagged commit's timestamp rather than a lookup.
The substantive change is the prose. release.yml's header instructed the
reader to `git push -f origin vYYYY.MM.DD` on a same-day re-cut. That is
the operation the family rulebook forbids outright, and it has incidents
behind it — moving a same-day tag forward once took a published release
down with it. Anyone who had installed from that tag was holding something
it no longer pointed at.
With HHMM there is nothing left for mutability to buy: every tag is unique
by construction, so a second release the same day is not a collision to
resolve, just another tag.
The old instruction is recorded as retired rather than deleted. Someone who
remembers it should learn it was withdrawn and why, not find it silently
absent and assume they misremembered.
README contradicted itself inside one sentence — "immutable per-day release
tags ... a same-day re-cut moves the tag forward" — and now says which it
is, plus a note that pre-2026-09-10 tags keep the old shape and still work.
Transition wrinkle, deliberately left for step 3: the non-tag :latest path
reconstructs the bundled APK's name from the latest release's commit
timestamp, which for the one existing old-shape release yields
2026.09.09.1828 while that APK actually declares 2026.09.09.1895. It fails
SAFE — 1828 compares lower, so no false update is offered — and it
self-corrects at the first new-scheme release. Step 3 removes the
reconstruction entirely by having the sidecar carry recorded values instead
of derived ones.
Step 2 of 5 — Scribe task #3809, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
The client compares names while Android installs by versionCode, and the
wire had no way to close that gap: the sidecar was one positional line and
the endpoint returned a name only. This is the plumbing that makes the
ordering key decidable by the client at all.
The sidecar is now JSON rather than a grown positional string. That shape
was chosen against a specific failure: the obvious growth path was
"<name> <code>", which a first-space split silently mangles the moment a
third field appears — the code stops parsing as an integer and the reader
falls back to name comparison WITHOUT erroring. JSON cannot mistake a new
field for an old one.
code is a POINTER on both sides, and omitempty on the wire. Absent has to
stay distinguishable from zero: a build published before ordering keys were
recorded genuinely has no code, and zero would claim it is infinitely old
rather than unknown.
A malformed sidecar now fails loudly instead of serving a blank version.
If an unreadable file produced an empty name, every client would compare
against nothing, conclude it was current, and go quiet — "I cannot read
this" and "there is nothing newer" would return the same answer, which is
the failure mode nobody reports because nobody is offered anything to
report.
The non-tag :latest path no longer RECONSTRUCTS the bundled APK's version.
android-release now publishes the sidecar as a release asset beside the
APK, and the image build downloads it. The old reconstruction duplicated a
derivation formula across two files, and could only ever recover the name —
the ordering key is build-time minutes and exists nowhere once that build
ends. Releases predating the sidecar report their name with a null code,
which is the honest answer rather than a guessed one.
image-release also drops to a shallow checkout: it needed full history and
tags only to re-derive versions from the tagged commit, and now touches git
for nothing. MINSTREL_VERSION comes from GITHUB_REF.
Two things checked rather than assumed. The Android Json sets
ignoreUnknownKeys, so the added fields cannot break already-installed apps.
It also sets coerceInputValues, which will silently turn a null code into 0
if step 4 declares the field non-nullable — recorded on task #3811, because
reading the field declaration alone would never reveal it.
Also fixes a stale comment block describing "the Flutter client", deleted
in v2026.08.18.
Step 3 of 5 — Scribe task #3810, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
The app compared NAMES while Android installs by versionCode, with nothing
keeping the two orderings consistent. So it could offer a build the platform
then refused as a downgrade, or stay silent about one it would have
accepted. The offer and the install were asking different questions.
Both consumers — the shell banner and the About card — now route through
one isUpdateAvailable(): decide on the ordering key whenever the server
reports one, since that is the same value the package installer compares,
so an offer implies an install that will actually be accepted. Name
comparison survives only as the fallback for a server predating the field.
isVersionNewer is deliberately untouched. It already degrades per segment
and is not what was broken; rewriting it while nearby would have put the
fallback path at risk for no gain.
code is nullable on the wire, and that is load-bearing rather than
stylistic. The app's Json sets coerceInputValues = true, which replaces a
JSON null with the declared default on a NON-nullable property — so
`val code: Long = 0` would have turned "this server reports no ordering
key" into "its key is 0" silently, ranking every such server as infinitely
behind and offering its build to everyone forever. Reading the field
declaration alone would never show that; it lives in AppModule.
A third caller turned up during the sweep and was deliberately left alone.
NetworkStatusController compares the /healthz minClientVersion, which is a
server-declared compatibility floor rather than the bundled APK — there is
no ordering key on that wire at all, so names remain the only thing it can
compare. Different question, correctly still using the old helper.
The update channel had no tests whatsoever before this, which is worth
stating: the thing deciding whether anyone is ever offered an update fails
silently in both directions. The new suite pins that the key wins when it
disagrees with the name, that a null key falls back rather than reading as
zero, the recorded migration constraint (a new-scheme name outranks an
old-scheme one across a day boundary but NOT within the same day), and the
degradation cases — including that an unparseable DECIDING segment reads as
zero and loses, which is why the channel must never live inside the name.
Every assertion was checked against the real comparison by mirroring it,
rather than from reading it: two of my first-draft comments described the
wrong mechanism and were corrected on the evidence.
Step 4 of 5 — Scribe task #3811, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Reported with a screenshot: the bar drew at full height but the cover,
title and transport row hugged its top edge, leaving an empty strip of
surface above the gesture area.
The Surface is a fixed 80dp. Inside it a plain Column stacked a 4dp
progress fill and then MiniRow at its INTRINSIC height — 48dp, set by the
cover and the icon buttons. A Column stacks from the top and nothing
claimed the remainder, so 80 - 4 - 48 = 28dp collected at the bottom.
Measured off the screenshot rather than eyeballed, and the bands agree
exactly: progress fill 14px (4dp at 3.5x), surface 280px (80dp), cover
167px (48dp), empty below 99px (28.3dp). That the arithmetic lands on the
measurement is what makes this the whole cause rather than one contributor.
MiniRow was already centring its content correctly — inside a box that was
only ever 48dp tall. Giving it weight(1f) lets it take what the progress
fill leaves, so it measures 76dp and centres 48dp of content: 14dp above
and below. The fill stays pinned to the top edge, which is where a
progress indicator belongs.
Not the same bug as issue #2681. That was a dead strip ABOVE the
miniplayer from an unclaimed navigation-bar inset, fixed in v2026.08.18.
This is inside the bar, pure layout, no insets — the surface already
stopped correctly above the gesture area.
CI cannot see this one. There are no Compose UI tests in the repo; the
Android lane is ktlint, detekt and JVM unit tests, and a layout bug needs
an instrumented test to catch. Compilation and lint are all this commit
gets from CI — the visual check is on a device, and the APK only builds on
a tagged release.
Scribe issue #3826.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Steps 1 and 2 of this milestone shipped with no CI coverage at all, and the
reason generalises: release.yml triggers only on main and tags, so nothing
inside it is exercised until a release is already running. That is the worst
place in the repo to be unguarded, because the failure mode is silence — a
version nobody can compare looks exactly like being up to date, and nobody
reports an update they were never offered.
The fix is not a test that reads YAML. The derivation moved into
ci/version.sh, so it can be RUN, and internal/server/release_version_test.go
runs it on every push. release.yml now calls the same script, so the thing
that ships and the thing under test are one artifact rather than two copies
that agree until they don't.
test-go.yml gains 'ci/**' and '.gitea/workflows/release.yml' in its paths.
Without that the guard exists but never fires on the changes it protects,
which is the same nothing it replaces.
What is pinned, and why each one:
- HHMM is zero-padded. A build at 00:42 must emit "0042"; a stripped
leading zero shifts the segment two orders of magnitude and reverses
comparisons against every other build that day. It only bites for a
tenth of the day, so it will not be found by chance.
- The name derives from the COMMIT and the code from the BUILD. Asserted
by holding one clock and moving the other: the name must not move, the
code must.
- The code clears 1895, the highest versionCode the retired commit-count
scheme shipped. Below that Android refuses the upgrade as a downgrade
and the channel becomes a one-way door.
- The tag is the name with a `v`, never chosen.
- release.yml still calls the script, and does not derive a commit count
again. This pins the WIRING: without it every other assertion keeps
passing while the shipped path silently drifts out of coverage.
The script rejects unusable clocks rather than emitting something plausible,
and those rejections are tested — a guard that cannot fail is worse than
none, because it reads as coverage.
Falsified before committing rather than after: ran the script against good
and broken inputs and watched all three failure paths fire; verified every
asserted value by executing it rather than by reading it; and checked the
two workflow predicates catch their regressions while staying immune to a
comment that merely names the old formula.
Step 5 of 5 — Scribe task #3812, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
The script asserted the key was positive but never that it fits. Android's
versionCode is a signed 32-bit int and the platform rejects an APK above it,
so a build machine with a badly wrong clock would emit a code the script
happily hands on and the install then refuses.
Worse than a rejected build: an over-ceiling code is also unreachably high,
so every correct build afterwards would fail to outrank it and the update
channel would be permanently stuck. Cheaper to refuse at the source than to
diagnose it from a phone that will not update.
The Go guard already asserted this, but only against a pinned value. The
script is what actually runs at build time, so the check belongs here too.
Falsified at the boundary rather than by eye — exactly at the ceiling exits
0, one minute past exits 1. My first probe used a year-6000 clock and did
NOT fire, which turned out to be the probe being wrong rather than the
check: that epoch still lands under the ceiling. The ceiling is reached in
6103, roughly 4079 years out, so this only ever catches a misconfigured
clock.
This commit deliberately touches ci/version.sh alone, to verify the path
filters added in eaf4654c actually fire the Go lane for release-machinery
changes. That run proved nothing about them, because it also touched
internal/** and would have run regardless.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
There was no test channel at all. release.yml ran only on main and tags, so
no :dev image existed and no APK was produced outside a release — the only
way to get a build onto a phone was to ship one, which made `main` the
staging area by default.
A push to dev now builds a signed APK, bundles it, and publishes :dev.
Signed with the SAME key as release builds, deliberately. A differently
signed APK cannot install over the stable app, so anyone moving between
channels would have to uninstall and lose their local data. Same key means
both directions work.
:dev is published ALONE, with no per-commit tag. A rolling channel is
rolling by definition; a commit-addressable image for it would be a rollback
target nobody ever pulls, kept forever. Recovery on dev is to fix forward,
and that is a deliberate trade rather than an omission.
The channel is derived from the REF, not the commit, which is why it is
computed in the workflow and not in ci/version.sh. The same commit built on
dev and on main reports the same version NAME and differs only in the
channel field — that separation is the entire point of keeping the three
values apart.
What this repo deliberately does NOT get: a cross-repo dispatch to refresh
the channel when its bundled APK is rebuilt. That mechanism exists elsewhere
in the family because the app and server live in separate repos, and a
channel that can only be refreshed by an unrelated commit is not a channel.
Minstrel is a monorepo — one push builds the APK and the image in the same
run from the same commit, so the channel cannot go stale against its own
artifact. The requirement is met structurally; copying the mechanism would
add a moving part to fix a problem that does not exist here.
Two guards, for the two ways this wiring can fail quietly:
A dev push must never move :latest. That would ship untested code to every
stable operator on their next pull, with the build green and the image
perfectly valid — just the wrong audience. Nothing else in the suite would
notice.
The two bundling paths must stay mutually exclusive. The rebundle step is
now gated to main specifically, not to "not a tag": under the looser
condition a dev push would run BOTH steps, staging its fresh APK and then
overwriting it with the previous release's. The image still builds, the
sidecar still parses, and the channel whose whole job is being current
quietly serves stale art.
Both falsified against the regressions they name before committing.
Scribe task #3819, milestone #390.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Eight commits. CI green on
90bb3538(release run 6260, test run 6261).The versioning rework
The tag shape was the cosmetic half. Two things were actually broken.
versionCodewas a commit count, withbuild.gradle.ktsasserting directly above it that this was "monotonic forever" — a reassurance sitting on top of the bug it denied. A commit count runs ahead ondev, so a dev build outranked themainrelease meant to replace it and Android refused the install as a downgrade: a channel you could enter and not leave without uninstalling.The offer and the install asked different questions. The client compared version names; Android installs by
versionCode. Nothing kept the two orderings consistent, so the app could offer a build the platform then refused — or stay silent about one it would have accepted.Now: the name derives from commit time (so every lane building one commit reports one string) and the ordering key from build time (so it is monotonic by construction). Two clocks, deliberately — each answers the question the other cannot. The sidecar and
/api/client/versioncarry all three values as siblings, and the client decides on the key the platform actually installs by.Tags become
vYYYY.MM.DD.HHMM— literally the artifact's own version name with av.release.ymlhad been instructing the reader togit push -fa published tag; that is now stated as forbidden, with the old advice recorded as retired rather than deleted.A dev channel
There was none.
release.ymlran only onmainand tags, so no APK existed outside a release and the only way to get a build onto a phone was to ship one — which mademainthe staging area by default.A push to
devnow builds a signed APK and publishes:dev, alone. Same signing key as stable, so a phone crosses channels in either direction without an uninstall. Verified on its own first run: one tag pushed, sidecar{"name":"2026.09.10.0247","code":3519527,"channel":"dev"}.The miniplayer
Reported with a screenshot. The bar drew at 80dp but its content hugged the top, leaving 28dp of dead surface above the gesture area —
80 − 4 − 48, matching the measured 99px at 3.5× exactly. The row was already centring its content, inside a box only ever 48dp tall.Guards
The version derivation moved into
ci/version.shso it can be executed rather than asserted on as text, andtest-go.ymlnow triggers onci/**andrelease.yml— verified by a commit touching only the script, which fired the lane.Also guarded: a dev push must never move
:latest(that ships untested code to every stable operator, with a green build and a valid image — just the wrong audience), and the rebundle path is gated tomainspecifically rather than "not a tag", or a dev push would overwrite its own fresh APK with the previous release's.What this merge does and does not ship
:latestmoves on merge, so the web and server changes go live. The Android changes reach devices only on the tag that follows — including the miniplayer fix and the bundled typefaces.Briefly between merge and tag,
:latestwill bundlev2026.09.09's APK with a name-only sidecar (code: null), because that release predates sidecar assets. It fails safe — a client on2026.09.09.1895is offered nothing — and resolves the moment the new tag builds.🤖 Generated with Claude Code
https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it "monotonic forever". It is not, and that claim was sitting directly above the bug it denied. A commit count runs ahead on `dev`. So a dev build carried a HIGHER code than the `main` release meant to supersede it, and Android refuses that install as a downgrade — a channel you can enter and cannot leave without uninstalling and losing local data. Two clocks now, and the split is deliberate even though it reads like an inconsistency: The NAME answers "is this the same code?", so it derives from COMMIT time and reads identically on every lane building this source. A dev build and a main build of one commit must report the same string. Build time cannot do that — it prints two numbers for one thing. The ORDERING KEY answers "may this be installed over that?", so it must be monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails here for the mirror-image reason — rebuild an older commit and it goes DOWN, which on a phone is a refused install rather than a confusing label. The non-tag :latest path reconstructed the bundled APK's name with the old formula, so it is moved to the same commit-timestamp derivation. That duplication is temporary: once the tag becomes `v<version-name>` it collapses to `${TAG#v}` with nothing left to keep in step. Verified locally by running the derivations rather than reasoning about them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895 from the old scheme, inside int32 with ~4000 years of headroom; a commit at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted shape too — a malformed name builds, signs and publishes happily and only surfaces as an update nobody is offered, which nobody reports. That local check is the only verification this commit gets. release.yml triggers on main and tags only, so nothing on `dev` executes the new derivation; CI here proves the Gradle file still parses and nothing else. Also confirms the migration constraint recorded in milestone #390: this commit would name a release 2026.09.09.1828, which is LOWER than the installed 2026.09.09.1895 under name comparison. The first new-scheme release must be cut on a later calendar day, or existing installs will never be offered it. Step 1 of 5 — Scribe task #3808, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLHSteps 1 and 2 of this milestone shipped with no CI coverage at all, and the reason generalises: release.yml triggers only on main and tags, so nothing inside it is exercised until a release is already running. That is the worst place in the repo to be unguarded, because the failure mode is silence — a version nobody can compare looks exactly like being up to date, and nobody reports an update they were never offered. The fix is not a test that reads YAML. The derivation moved into ci/version.sh, so it can be RUN, and internal/server/release_version_test.go runs it on every push. release.yml now calls the same script, so the thing that ships and the thing under test are one artifact rather than two copies that agree until they don't. test-go.yml gains 'ci/**' and '.gitea/workflows/release.yml' in its paths. Without that the guard exists but never fires on the changes it protects, which is the same nothing it replaces. What is pinned, and why each one: - HHMM is zero-padded. A build at 00:42 must emit "0042"; a stripped leading zero shifts the segment two orders of magnitude and reverses comparisons against every other build that day. It only bites for a tenth of the day, so it will not be found by chance. - The name derives from the COMMIT and the code from the BUILD. Asserted by holding one clock and moving the other: the name must not move, the code must. - The code clears 1895, the highest versionCode the retired commit-count scheme shipped. Below that Android refuses the upgrade as a downgrade and the channel becomes a one-way door. - The tag is the name with a `v`, never chosen. - release.yml still calls the script, and does not derive a commit count again. This pins the WIRING: without it every other assertion keeps passing while the shipped path silently drifts out of coverage. The script rejects unusable clocks rather than emitting something plausible, and those rejections are tested — a guard that cannot fail is worse than none, because it reads as coverage. Falsified before committing rather than after: ran the script against good and broken inputs and watched all three failure paths fire; verified every asserted value by executing it rather than by reading it; and checked the two workflow predicates catch their regressions while staying immune to a comment that merely names the old formula. Step 5 of 5 — Scribe task #3812, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH