fix(release): drop version image tags, mint the rollback unit on main
test-go / test (push) Successful in 1m43s
test-web / test (push) Successful in 1m13s
test-go / integration (push) Successful in 4m12s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped

The image tag map was the inverse of family rules 145 and 147 on every
count: it published :vYYYY.MM.DD.HHMM that nobody pinned, published :main
that rule 147 says should not exist, and published no commit-addressable
image at all — so the rollback unit the rule names did not exist in this
repo. A bad main push had nothing to roll back to but the previous
release tag, which may be many commits back.

The whole map is now:

  dev  → :dev
  main → :latest + :<sha>
  tag  → :latest

A release refreshes the channel and mints nothing else. The tag build
rebuilds the SAME SOURCE as main's build minutes earlier, differing only
in which APK is baked in, so rule 145's immutability clause applies
directly: move the channel tag, never re-push a commit-addressable one.
:latest has to move here rather than waiting for the next main push, or
the channel would carry the previous release's APK indefinitely — a
channel that cannot refresh itself (rule 146).

Two consequences that are not optional:

The verify job asserted the :<version> image existed. With version tags
gone that would fail every release for a tag nothing mints. Re-pointed at
the :<sha> image rather than deleted — deleting it is the tempting way to
make a failing guard go green, and it earns its keep twice now: it still
catches an image push that silently did not happen, and it additionally
proves the ordering, since a tag cut on a commit whose main build never
completed has no rollback target.

The server's self-reported version was the literal string "main" or
"dev". That was survivable while :vYYYY.MM.DD.HHMM existed to identify a
build; with version tags gone it is the ONLY thing that says which build
is running, and two dev images months apart were indistinguishable. It
now carries the derived name from ci/version.sh on every lane, with the
channel as a sibling field (rule 149) rather than folded into the string.
Surfaced at /healthz and beside the version in Settings.

Guards added for each arm of the policy, and every one was falsified
against the specific regression it names before committing. That caught
two real bugs in the guards themselves: stepBody cut at the next
`- name:`, which returns an EMPTY body for the last step in a job and
made the assertions pass vacuously, and its replacement cut at any blank
line followed by indentation, which truncated a step mid-run-block. The
helper now refuses an empty body outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-10 15:10:15 -04:00
co-authored by Claude Opus 5
parent 88508b536b
commit aeb8781c4e
7 changed files with 366 additions and 68 deletions
+21 -9
View File
@@ -6,26 +6,36 @@
// is actually running (came up debugging the in-app update flow when
// it wasn't obvious whether v2026.05.10.0 or .1 was deployed).
//
// This is now the ONLY place an operator can see which build they are on.
// Image tags stopped carrying the version on 2026-09-10 — :latest and :dev
// are rolling names and :<sha> answers "which commit", not "which build" —
// so the server's self-report is the answer.
//
// The channel is shown BESIDE the version, never spliced into it: the same
// commit built on both lanes reports an identical version and differs only
// in channel, so "2026.09.10.1449 · dev" and "2026.09.10.1449 · stable" are
// the same code on two lines. Suppressed for stable, which is the
// unremarkable case and would just be noise on every install.
//
// /healthz is unauthenticated, so the bare fetch works without
// credentials. Renders nothing on parse failure or pre-version
// images that don't include the field — graceful degradation.
type Health = { status: string; version?: string };
type Health = { status: string; version?: string; channel?: string };
let version = $state<string | null>(null);
let channel = $state<string | null>(null);
onMount(async () => {
try {
const res = await fetch('/healthz');
if (!res.ok) return;
const body = (await res.json()) as Partial<Health>;
if (body.version && body.version !== 'dev') {
version = body.version;
} else if (body.version === 'dev') {
// Local dev images report "dev" — show it so the operator
// can tell they're not on a release tag.
version = 'dev';
}
if (!body.version) return;
version = body.version;
// Reported verbatim rather than validated against an enum — a build
// claiming something unexpected is better shown than dropped.
channel = body.channel && body.channel !== 'stable' ? body.channel : null;
} catch {
// network / parse error — silent.
}
@@ -33,5 +43,7 @@
</script>
{#if version}
<p class="text-xs text-text-secondary">Server {version}</p>
<p class="text-xs text-text-secondary">
Server {version}{#if channel}&nbsp;·&nbsp;{channel}{/if}
</p>
{/if}