End-to-end slice for the operator's "track played with 0 length, player sat stuck" report — fast-skip on Android + new admin inbox surfaced from the web — plus the NowPlaying polish (icon revert, swipe tabs, refresh flicker, Start Radio, dominant swatch, slim scrubber).
Playback errors (server + Android + admin UI)
Server (15b59a21): new playback_errors table (migration 0032) with CHECK-gated kind + resolution enums and a partial index on unresolved rows. Endpoints: POST /api/playback-errors (any signed-in user reports), GET /api/admin/playback-errors?resolved= (admin list with joined track/album/artist for one-shot row render), POST /api/admin/playback-errors/{id}/resolve (admin marks resolved with a resolution enum).
Android (99e1df49): PlayerController.onPlaybackStateChanged(STATE_READY) checks player.duration; if <= 0 or C.TIME_UNSET, fires a PlaybackErrorEvent(kind="zero_duration") and calls seekToNextMediaItem (or stop on the last item). onPlayerError also lifts up to the same event shape as kind="load_failed". New PlaybackErrorRepository posts via the offline-first MutationQueue path (PLAYBACK_ERROR_REPORT kind + payload + replayer dispatch). PlaybackErrorReporter now fans events to both the snackbar (debounced) and the server (per-event).
Web admin (de61305f): new /admin/playback-errors page with Unresolved / Resolved tabs. Per-row: Resolve (modal w/ Fixed/Ignored dropdown), Copy (JSON to clipboard including file_path), Delete file (auto-resolves as deleted). AdminTabs grows from 5 to 6 entries.
Already on dev (now folded into this PR)
Library icon revert (b83a6a4b) — back to Lucide.LibraryBig; drops the material-icons-extended dep.
Swipe to change Library tabs (9b3ec654) — HorizontalPager synced with PrimaryScrollableTabRow.
Refresh flicker fix (1fdd785e) — keyed Crossfades on state::class across LibraryScreen + AlbumDetail + ArtistDetail + PlaylistDetail so same-kind Success → Success refreshes don't full-fade.
Start Radio preserves current playback (516d22fc) — active queue → trim upcoming + append radio (dedup seed if current); empty queue → existing setQueue.
Dominant swatch only (5fd1a572) — palette.dominantSwatch instead of vibrant→muted→dominant fallback chain.
Hide track + Re-request from Lidarr from the playback-errors inbox row. Hide because the existing quarantine flow is per-user-flag (not a library-hide); Re-request because we'd need album MBID added to the row projection. Operator can still hide/request from the album page in the meantime.
Flutter / web-client playback-error reporting. The server endpoint is client-agnostic so it's a small follow-up to add to those surfaces.
Stall detection (track plays but stops mid-song). Different cause, different fix.
Test plan
CI: vitest (AdminTabs renumbered to 6 tabs), Go server tests, Android ktlint + detekt
On Android: play a zero-duration track — player advances within ~1 frame, snackbar shows "Couldn't play X — skipping"
/admin/playback-errors shows the report; Copy yields the expected JSON; Delete file removes the file AND closes the row; Resolve modal lets you stamp Fixed or Ignored
Library tabs swipe; refresh on Library / Album / Artist / Playlist screens no longer flickers; new Library icon is the books-on-shelf glyph; Start Radio from a playing track doesn't restart playback
## Summary
End-to-end slice for the operator's "track played with 0 length, player sat stuck" report — fast-skip on Android + new admin inbox surfaced from the web — plus the NowPlaying polish (icon revert, swipe tabs, refresh flicker, Start Radio, dominant swatch, slim scrubber).
### Playback errors (server + Android + admin UI)
- **Server** (`15b59a21`): new `playback_errors` table (migration 0032) with CHECK-gated `kind` + `resolution` enums and a partial index on unresolved rows. Endpoints: `POST /api/playback-errors` (any signed-in user reports), `GET /api/admin/playback-errors?resolved=` (admin list with joined track/album/artist for one-shot row render), `POST /api/admin/playback-errors/{id}/resolve` (admin marks resolved with a resolution enum).
- **Android** (`99e1df49`): `PlayerController.onPlaybackStateChanged(STATE_READY)` checks `player.duration`; if `<= 0` or `C.TIME_UNSET`, fires a `PlaybackErrorEvent(kind="zero_duration")` and calls `seekToNextMediaItem` (or `stop` on the last item). `onPlayerError` also lifts up to the same event shape as `kind="load_failed"`. New `PlaybackErrorRepository` posts via the offline-first MutationQueue path (`PLAYBACK_ERROR_REPORT` kind + payload + replayer dispatch). `PlaybackErrorReporter` now fans events to both the snackbar (debounced) and the server (per-event).
- **Web admin** (`de61305f`): new `/admin/playback-errors` page with Unresolved / Resolved tabs. Per-row: Resolve (modal w/ Fixed/Ignored dropdown), Copy (JSON to clipboard including file_path), Delete file (auto-resolves as `deleted`). AdminTabs grows from 5 to 6 entries.
### Already on dev (now folded into this PR)
- **Library icon revert** (`b83a6a4b`) — back to `Lucide.LibraryBig`; drops the material-icons-extended dep.
- **Swipe to change Library tabs** (`9b3ec654`) — `HorizontalPager` synced with `PrimaryScrollableTabRow`.
- **Refresh flicker fix** (`1fdd785e`) — keyed Crossfades on `state::class` across LibraryScreen + AlbumDetail + ArtistDetail + PlaylistDetail so same-kind Success → Success refreshes don't full-fade.
- **Start Radio preserves current playback** (`516d22fc`) — active queue → trim upcoming + append radio (dedup seed if current); empty queue → existing setQueue.
- **Dominant swatch only** (`5fd1a572`) — `palette.dominantSwatch` instead of vibrant→muted→dominant fallback chain.
- **Slim scrubber track** (`d9c7aae2` + revert `6a54d074`) — 4dp rounded custom track replaces M3's 16dp default; Slider height clamp reverted so surrounding layout stays put.
### Deferred (called out for follow-ups)
- Hide track + Re-request from Lidarr from the playback-errors inbox row. Hide because the existing quarantine flow is per-user-flag (not a library-hide); Re-request because we'd need album MBID added to the row projection. Operator can still hide/request from the album page in the meantime.
- Flutter / web-client playback-error reporting. The server endpoint is client-agnostic so it's a small follow-up to add to those surfaces.
- Stall detection (track plays but stops mid-song). Different cause, different fix.
## Test plan
- [ ] CI: vitest (AdminTabs renumbered to 6 tabs), Go server tests, Android ktlint + detekt
- [ ] On Android: play a zero-duration track — player advances within ~1 frame, snackbar shows "Couldn't play X — skipping"
- [ ] `/admin/playback-errors` shows the report; Copy yields the expected JSON; Delete file removes the file AND closes the row; Resolve modal lets you stamp Fixed or Ignored
- [ ] Library tabs swipe; refresh on Library / Album / Artist / Playlist screens no longer flickers; new Library icon is the books-on-shelf glyph; Start Radio from a playing track doesn't restart playback
Operator polled another user and reversed the earlier swap to
Material's LibraryMusic. Restore Lucide.LibraryBig and drop the
material-icons-extended Gradle dependency we added for the
intermediate icon, keeping the icon set Lucide-only.
Operator: tabs in the Library view should feel swipeable, not just
tappable. Replace the selectedTab Int state + when-block content
with HorizontalPager whose state drives the PrimaryScrollableTabRow.
Tap routes through animateScrollToPage so swipe + tap share one
source of truth.
Horizontal pager gestures don't conflict with the LazyVerticalGrid
inside each tab (different axes) or with PullToRefreshScaffold's
vertical pull (different axes). HorizontalPager renders only the
current page by default; adjacent tabs remain composed during the
swipe but not eager-mounted at start.
Pull-to-refresh produced a strong full-screen fade on Library
(both tabs) and the Album / Artist / Playlist detail screens
because their Crossfades were keyed on the entire state value.
A refresh emits a fresh UiState.Success with a NEW data instance
(same kind, different content) so Crossfade animated old grid →
new grid even though both are the same Success branch — the
visible result was a flash that read as "broken/heavy."
HomeScreen already keys on `state::class` (4b9d-ish prior fix);
apply the same pattern to the four screens that still flicker.
Inner content reads the outer `state` directly via `val s = state`
so the branch still has access to the typed value. Row-level diffs
are owned by LazyVerticalGrid / LazyColumn via item keys, so the
visual update is smooth and granular instead of a full fade.
Only Loading ↔ Success ↔ Error ↔ Empty transitions animate now —
the intended use of Crossfade. Same-kind state updates flow
through Compose's normal recomposition.
Operator: tapping Start Radio while music plays previously
reloaded the current track from position 0 because the radio
seed response includes the seed at index 0 and the handler called
setQueue(tracks, 0) — Flutter's playerActions.startRadio does the
same. They want the current track to keep playing untouched,
upcoming queue cleared, radio results appended after.
PlayerController.startRadio now branches on mediaItemCount:
- Empty queue: existing behavior — setQueue from index 0.
- Active queue: keep currentMediaItem, removeMediaItems from
currentIdx+1 to end, then addMediaItems with the radio list.
When the seed is the currently-playing track (the common
"Start Radio on the song I'm listening to" case), drop the
seed from the appended list so it doesn't immediately repeat
after the current track ends.
queueRefs is updated alongside the controller so the cached
TrackRef list stays consistent. Source tag "radio:<id>" is
preserved for the appended items so play_started attribution
stays correct.
Intentional divergence from Flutter — recorded in the docstring
so future ports notice it.
Operator framing: the cover art is the main feature of NowPlaying,
not the background. A vibrant accent on the cover (small bright
logo, sticker, stripe) should pop against the background, not be
matched by it. The previous vibrant → muted → dominant fallback
chain often picked a high-saturation accent that covered only a
sliver of the cover, producing gradients that clashed with the
actual image.
Drop to dominantSwatch only — the majority-by-pixel-count color.
If the palette resolves no dominant swatch (extremely rare;
essentially uniform/empty bitmap) the held color stays on the
previous track's dominant, matching the existing "keep previous
on failure" docstring contract.
The M3 Slider default track is 16dp tall and the Slider itself
expands to the 48dp interactive-component minimum, so the 14dp
thumb we'd already shrunk to a flat circle was still sitting in
the middle of a fat horizontal pill with lots of empty space
above and below. Operator framing: "puffy, not a tool."
Two changes:
- Custom 4dp rounded track replaces SliderDefaults.Track. The
thumb (14dp) now reads as visibly taller than the bar — the
classic "handle on a string" cue that says "tool, draggable."
Also drops M3's stop-indicator dot which the web scrubber
doesn't have.
- Clamp the Slider's vertical footprint to 20dp via Modifier.
height. 14dp thumb + 3dp clearance each side, vs the default
~17dp empty above and below. Touch area stays usable since the
drag axis is horizontal — pulling left/right anywhere on the
thin bar feels natural, and Slider's gesture detector still
responds to a tap anywhere along its row.
Keeps an Android flavor (slightly thicker than the web's 2-3px
hairline; rounded caps; accent fill) without reading as bulky.
Operator: the slider height clamp shifted the surrounding layout
above and below — not what they intended to change. Revert the
Modifier.height(20.dp) and remove the SCRUB_SLIDER_HEIGHT_DP
constant; the Slider goes back to its M3-default 48dp interactive
component height so adjacent rows sit where they did before.
The slim 4dp custom track stays — that's what addresses the
"puffy bar" feel — and the thumb still sits on it as a visible
14dp circle, with 17dp empty vertical space above and below.
That's the M3 standard layout the operator wants restored.
New client-reported playback-error log. Surfaces zero-duration
tracks (and future load_failed / stalled kinds) into an admin
inbox so the operator can hide / delete / re-request the
offending track.
Schema (migration 0032):
- playback_errors table with CHECK constraints on the kind +
resolution enums (per the standing rule that new enum values
need a migration to add)
- Partial index on unresolved rows for fast inbox lookup
- ON DELETE CASCADE from tracks + users so cleanup is automatic
Endpoints:
- POST /api/playback-errors: any signed-in user reports. Body
validates track existence + kind whitelist; client_id required
so support can correlate reports from the same device.
- GET /api/admin/playback-errors?resolved=false&offset=&limit=:
admin list with join to track/album/artist for table render
without per-row round-trips. Pagination capped at 200/page.
- POST /api/admin/playback-errors/{id}/resolve: admin marks
resolved with a resolution enum string.
Auto-resolve on Hide/Delete/Re-request from the inbox row is
driven from the web client (two sequential calls) — keeps the
existing track-action endpoints unchanged.
Operator hit a track that loaded with zero duration; player just sat
on it. Two things needed: skip the dead track immediately, and tell
the server so the admin inbox can surface the bad file.
PlayerController:
- Player.Listener.onPlaybackStateChanged(STATE_READY) now checks
duration. If it's <= 0 or C.TIME_UNSET, fires a PlaybackErrorEvent
with kind="zero_duration" and calls seekToNextMediaItem (or stop
if it was the last item). Per-item evaluation guard keeps repeat
STATE_READY events (post-seek, post-resume) from re-firing.
- onPlayerError now also surfaces a PlaybackErrorEvent with
kind="load_failed" + the Media3 exception message as detail.
- playbackErrorEvents flow changes from Flow<String> (title only) to
Flow<PlaybackErrorEvent> (track_id + kind + title + detail) so
downstream consumers can both surface a snackbar AND POST to the
admin inbox without duplicating event emission.
PlaybackErrorRepository (new):
- Wraps POST /api/playback-errors with the offline-first MutationQueue
fallback per the standing rule for server writes.
- Reuses AuthStore.clientId for the client_id field — same UUID-per-
install identifier the play-events reporter sends, so support can
correlate playback errors with surrounding plays.
PlaybackErrorReporter:
- Consumes the new richer event shape. Fires the server report per
event (no debounce — the admin inbox should capture every report,
not a coalesced summary). Continues to debounce the user-facing
snackbar in the 2s window so a burst doesn't spam toasts.
MutationQueue / MutationReplayer:
- Adds PLAYBACK_ERROR_REPORT kind + PlaybackErrorReportPayload +
enqueuePlaybackErrorReport entry point + replayer dispatch case
hitting the new PlaybackErrorsApi.
Web admin inbox + UI is the next commit.
Surfaces client-reported playback failures from /api/admin/playback-errors
in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row
shows track + artist + album, error kind badge, who hit it, when,
optional client-supplied detail, and the absolute file path so the
operator can grep the library mount without leaving the page.
Per-row actions (RowActionsMenu):
- Resolve (primary) — modal with Fixed / Ignored dropdown for the
"no further action taken" cases.
- Copy — JSON payload to clipboard with track_id / file_path / kind
/ detail / reporter / client_id / occurred_at. Matches the
operator's "logs with a copy-out function" ask.
- Delete file (danger, modal-confirm) — uses the existing
/api/admin/quarantine/{track_id}/delete-file endpoint AND
auto-stamps resolution='deleted' so a single click closes both
the file and the inbox row.
Deferred to a follow-up: Hide (the existing quarantine flow is
per-user-flag, not a true library-hide), and Re-request via Lidarr
(needs album MBID join — not in the current ListAdminPlaybackErrors
projection).
Also: admin tab list grows from five to six; AdminTabs.test.ts
updated.
Three rule trips from the playback-errors + scrubber commits:
PlayerController.startRadio (4 returns → 2): extract the mid-queue
append branch into appendRadioToQueue(). startRadio just does the
guard checks and dispatches; the helper handles the cursor trim +
addMediaItems. Behavior identical.
PlayerController.onPlaybackStateChanged (4 returns → 2): extract
the duration check + zero-duration error emission + skip logic
into handleZeroDurationIfNeeded(). The listener stays compact (one
return for non-READY, one for repeat-evaluation guard); the helper
owns the failure path.
NowPlayingScreen.ScrubberRow (63 lines → ~50): extract the custom
track Box block into a ScrubTrack(fraction, accent) composable.
The Slider's `track` lambda becomes a one-line call. Pixel output
is identical.
The kdoc on PlaybackErrorReportRequest mentioned the existing
/api/plays/* endpoint. Kotlin's lexer treats /* inside a /** ... */
kdoc as a NESTED comment opener, which then swallows the outer
*/ — so the entire PlaybackErrorReportRequest data class
disappeared from the symbol table and the four call sites in
PlaybackErrorsApi.kt / MutationReplayer.kt / PlaybackErrorRepository.kt
all reported "Unresolved reference".
This is the trap recorded in the project's KSP-could-not-be-
resolved memory; mark it again. Fix is mechanical: rewrite the
prose as `/api/plays/...` so no /* sequence appears inside a
block comment.
The APK was shipping with a hardcoded versionName="0.1.0-native"
and versionCode=1 — so the About card never reflected the actual
release, and two same-day re-cuts of the per-day mutable tag
v2026.06.02 looked identical to the update-banner comparator.
Operator wants the iteration restored on the APK side (the docker
tag stays plain per the earlier intentional change).
Scheme:
- Per release: versionName = "${tag}.${commit_count}", e.g.
"2026.06.02.142", where commit_count = `git rev-list --count HEAD`.
Monotonic across the project lifetime, deterministic, no manual
counter to maintain.
- versionCode = commit_count. Monotonic, fits in Int forever (we're
not hitting 2.1B commits).
- Local / debug / dev builds fall back to versionName="dev" /
versionCode=1 so the About card reads honestly.
build.gradle.kts:
- defaultConfig reads MINSTREL_VERSION_NAME / MINSTREL_VERSION_CODE
Gradle properties via project.findProperty with the dev fallbacks.
.gitea/workflows/release.yml:
- android-release: checkout with fetch-depth: 0 (the default shallow
clone would return 1 for `git rev-list --count HEAD`); new
Compute release version step exports name + code as step outputs;
assembleRelease passes them via -P; new job-level outputs propagate
them to the downstream image-release job.
- image-release: Stage bundled APK + version sidecar pulls the
computed version_name from needs.android-release.outputs and
writes it into client/minstrel.apk.version, so the server's
/api/client/version reports the exact string baked into the APK.
Without this the sidecar would say "v2026.06.02" while the
installed APK has "2026.06.02.142" — isVersionNewer would call
the bundled APK older and the update banner would thrash.
The existing isVersionNewer comparator already handles the
4-component shape ("2026.06.02.142" > "2026.06.02.141"), so no
client-side logic changes are needed.
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.
Summary
End-to-end slice for the operator's "track played with 0 length, player sat stuck" report — fast-skip on Android + new admin inbox surfaced from the web — plus the NowPlaying polish (icon revert, swipe tabs, refresh flicker, Start Radio, dominant swatch, slim scrubber).
Playback errors (server + Android + admin UI)
15b59a21): newplayback_errorstable (migration 0032) with CHECK-gatedkind+resolutionenums and a partial index on unresolved rows. Endpoints:POST /api/playback-errors(any signed-in user reports),GET /api/admin/playback-errors?resolved=(admin list with joined track/album/artist for one-shot row render),POST /api/admin/playback-errors/{id}/resolve(admin marks resolved with a resolution enum).99e1df49):PlayerController.onPlaybackStateChanged(STATE_READY)checksplayer.duration; if<= 0orC.TIME_UNSET, fires aPlaybackErrorEvent(kind="zero_duration")and callsseekToNextMediaItem(orstopon the last item).onPlayerErroralso lifts up to the same event shape askind="load_failed". NewPlaybackErrorRepositoryposts via the offline-first MutationQueue path (PLAYBACK_ERROR_REPORTkind + payload + replayer dispatch).PlaybackErrorReporternow fans events to both the snackbar (debounced) and the server (per-event).de61305f): new/admin/playback-errorspage with Unresolved / Resolved tabs. Per-row: Resolve (modal w/ Fixed/Ignored dropdown), Copy (JSON to clipboard including file_path), Delete file (auto-resolves asdeleted). AdminTabs grows from 5 to 6 entries.Already on dev (now folded into this PR)
b83a6a4b) — back toLucide.LibraryBig; drops the material-icons-extended dep.9b3ec654) —HorizontalPagersynced withPrimaryScrollableTabRow.1fdd785e) — keyed Crossfades onstate::classacross LibraryScreen + AlbumDetail + ArtistDetail + PlaylistDetail so same-kind Success → Success refreshes don't full-fade.516d22fc) — active queue → trim upcoming + append radio (dedup seed if current); empty queue → existing setQueue.5fd1a572) —palette.dominantSwatchinstead of vibrant→muted→dominant fallback chain.d9c7aae2+ revert6a54d074) — 4dp rounded custom track replaces M3's 16dp default; Slider height clamp reverted so surrounding layout stays put.Deferred (called out for follow-ups)
Test plan
/admin/playback-errorsshows the report; Copy yields the expected JSON; Delete file removes the file AND closes the row; Resolve modal lets you stamp Fixed or IgnoredOperator: tapping Start Radio while music plays previously reloaded the current track from position 0 because the radio seed response includes the seed at index 0 and the handler called setQueue(tracks, 0) — Flutter's playerActions.startRadio does the same. They want the current track to keep playing untouched, upcoming queue cleared, radio results appended after. PlayerController.startRadio now branches on mediaItemCount: - Empty queue: existing behavior — setQueue from index 0. - Active queue: keep currentMediaItem, removeMediaItems from currentIdx+1 to end, then addMediaItems with the radio list. When the seed is the currently-playing track (the common "Start Radio on the song I'm listening to" case), drop the seed from the appended list so it doesn't immediately repeat after the current track ends. queueRefs is updated alongside the controller so the cached TrackRef list stays consistent. Source tag "radio:<id>" is preserved for the appended items so play_started attribution stays correct. Intentional divergence from Flutter — recorded in the docstring so future ports notice it.New client-reported playback-error log. Surfaces zero-duration tracks (and future load_failed / stalled kinds) into an admin inbox so the operator can hide / delete / re-request the offending track. Schema (migration 0032): - playback_errors table with CHECK constraints on the kind + resolution enums (per the standing rule that new enum values need a migration to add) - Partial index on unresolved rows for fast inbox lookup - ON DELETE CASCADE from tracks + users so cleanup is automatic Endpoints: - POST /api/playback-errors: any signed-in user reports. Body validates track existence + kind whitelist; client_id required so support can correlate reports from the same device. - GET /api/admin/playback-errors?resolved=false&offset=&limit=: admin list with join to track/album/artist for table render without per-row round-trips. Pagination capped at 200/page. - POST /api/admin/playback-errors/{id}/resolve: admin marks resolved with a resolution enum string. Auto-resolve on Hide/Delete/Re-request from the inbox row is driven from the web client (two sequential calls) — keeps the existing track-action endpoints unchanged.Surfaces client-reported playback failures from /api/admin/playback-errors in a new admin tab. Tabs: Unresolved (default) / Resolved. Each row shows track + artist + album, error kind badge, who hit it, when, optional client-supplied detail, and the absolute file path so the operator can grep the library mount without leaving the page. Per-row actions (RowActionsMenu): - Resolve (primary) — modal with Fixed / Ignored dropdown for the "no further action taken" cases. - Copy — JSON payload to clipboard with track_id / file_path / kind / detail / reporter / client_id / occurred_at. Matches the operator's "logs with a copy-out function" ask. - Delete file (danger, modal-confirm) — uses the existing /api/admin/quarantine/{track_id}/delete-file endpoint AND auto-stamps resolution='deleted' so a single click closes both the file and the inbox row. Deferred to a follow-up: Hide (the existing quarantine flow is per-user-flag, not a true library-hide), and Re-request via Lidarr (needs album MBID join — not in the current ListAdminPlaybackErrors projection). Also: admin tab list grows from five to six; AdminTabs.test.ts updated.The APK was shipping with a hardcoded versionName="0.1.0-native" and versionCode=1 — so the About card never reflected the actual release, and two same-day re-cuts of the per-day mutable tag v2026.06.02 looked identical to the update-banner comparator. Operator wants the iteration restored on the APK side (the docker tag stays plain per the earlier intentional change). Scheme: - Per release: versionName = "${tag}.${commit_count}", e.g. "2026.06.02.142", where commit_count = `git rev-list --count HEAD`. Monotonic across the project lifetime, deterministic, no manual counter to maintain. - versionCode = commit_count. Monotonic, fits in Int forever (we're not hitting 2.1B commits). - Local / debug / dev builds fall back to versionName="dev" / versionCode=1 so the About card reads honestly. build.gradle.kts: - defaultConfig reads MINSTREL_VERSION_NAME / MINSTREL_VERSION_CODE Gradle properties via project.findProperty with the dev fallbacks. .gitea/workflows/release.yml: - android-release: checkout with fetch-depth: 0 (the default shallow clone would return 1 for `git rev-list --count HEAD`); new Compute release version step exports name + code as step outputs; assembleRelease passes them via -P; new job-level outputs propagate them to the downstream image-release job. - image-release: Stage bundled APK + version sidecar pulls the computed version_name from needs.android-release.outputs and writes it into client/minstrel.apk.version, so the server's /api/client/version reports the exact string baked into the APK. Without this the sidecar would say "v2026.06.02" while the installed APK has "2026.06.02.142" — isVersionNewer would call the bundled APK older and the update banner would thrash. The existing isVersionNewer comparator already handles the 4-component shape ("2026.06.02.142" > "2026.06.02.141"), so no client-side logic changes are needed.