diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index bb0ac992..c42b2e5f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,11 +2,31 @@ name: release # Builds and pushes the minstrel container image to the Gitea registry. # -# push to dev → :dev (freshly-built dev APK bundled) -# push to main → :main and :latest (latest-release APK bundled) -# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled) +# push to dev → :dev (freshly-built dev APK bundled) +# push to main → :latest + : (latest-release APK bundled) +# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled) # workflow_dispatch → manual trigger (same rules based on the ref) # +# That is the whole tag map, and it is family rule 145 + 147 as written. +# +# : on main is the ROLLBACK UNIT — every production commit addressable +# without a release ceremony. It is minted only on main, where rollback is +# actually worth having: merges are gated (rule 2) so they number in the dozens +# per year, while on dev they would be one per push, forever, for a channel +# whose entire contract is that it moves. +# +# There are NO : image tags. This repo published :vYYYY.MM.DD.HHMM +# until 2026-09-10 and it was the inverse of the rule on both counts — minting +# a version tag nobody pinned while the rollback unit the rule names did not +# exist here at all. Git and the build's own self-reported version answer +# "which build is this"; a third name for the same thing is upkeep for a model +# we do not run. Operator, 2026-09-10: "only things like the APK need that kind +# of versioning for their update process." +# +# There is no :main either. :latest tracks main's tip with no gate between them +# (rule 147), so a second name for the same image sends readers looking for a +# distinction that does not exist. +# # The dev channel exists so testing a build does not require shipping one. # Before it, the only way to get an APK onto a phone was to cut a release, # which made `main` the staging area by default. `:dev` carries its own @@ -312,11 +332,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - # Shallow is fine here. This job used to need full history + tags to - # re-derive the bundled APK's version from the tagged commit; it now - # downloads the sidecar the release recorded, and touches git for - # nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git. - fetch-depth: 1 + # Full history, and rule 149 names this specifically: any job that + # DERIVES the version name needs it, because a shallow clone changes + # what git-derived values resolve to WITHOUT failing — a too-low + # value, silently, with every lane green. + # + # This job was depth-1 while it took the version from GITHUB_REF. It + # now runs ci/version.sh itself, because with : image tags + # gone the server's self-reported version is the only thing that says + # which build an image is. + fetch-depth: 0 - name: Detect buildable project id: guard @@ -334,30 +359,68 @@ jobs: if: steps.guard.outputs.ready == 'true' shell: bash run: | + set -euo pipefail + + # THE VERSION, and it is derived the same way on every ref — the + # branch decides the CHANNEL, never the version (family rule 149). + # + # This used to be three different things: the literal string "main" + # on main, "dev" on dev, and the tag name on a tag. None of them + # ordered, and the first two were the same string forever — two dev + # images eight weeks apart were indistinguishable in the UI. That + # mattered little while :vYYYY.MM.DD.HHMM existed to identify a + # build; with version image tags gone, this IS how an operator tells + # which build a container is running. + # + # `sed -n s///p` rather than `grep`: it exits 0 when nothing matches, + # so the empty check below is actually reachable. A grep here would + # kill the step at the assignment under the runner's pipefail — the + # exact bug that took down the first main build after the version + # rework. + VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')" + if [ -z "${VERSION}" ]; then + echo "::error::could not derive a build version from ci/version.sh" + exit 1 + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - VERSION="${GITHUB_REF#refs/tags/}" - echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "::notice::Release build: ${VERSION} + latest" + # A release refreshes the CHANNEL and mints nothing else. + # + # The tag build exists to produce the signed APK and attach it to + # the release; the image it rebuilds is the SAME SOURCE as the main + # build minutes earlier, differing only in which APK is baked in. + # Rule 145 is explicit about that case: when the same source is + # rebuilt with different contents, publish the moving channel tag + # and never a commit-addressable one. + # + # :latest must 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). + CHANNEL=stable + echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" + echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK" elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then # The rolling test channel, and :dev ALONE — deliberately no # per-commit tag. A rolling channel is rolling by definition, so a # commit-addressable image here would be a rollback target nobody # has ever pulled, accumulating in the registry forever. Recovery # on dev is to fix forward. + CHANNEL=dev echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT" - echo "version=dev" >> "$GITHUB_OUTPUT" - echo "::notice::Dev-branch build: :dev" + echo "::notice::Dev-branch build ${VERSION}: :dev" else - # Main is the protected, post-PR-merge branch. Treat it as the - # rolling stable channel — every main push moves :latest. - # Pinned consumers can target :vYYYY.MM.DD.HHMM, which never - # moves; everyone else gets the newest main. - echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" - echo "version=main" >> "$GITHUB_OUTPUT" - echo "::notice::Main-branch build: :main + :latest" + # The production line: :latest tracks main's tip (rule 147) and + # : is the rollback unit (rule 145). Full 40-char SHA, matching + # the family's other repos, so a rollback target is addressable + # straight from the commit anyone is reading. + CHANNEL=stable + echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}" fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" + - name: Registry login if: steps.guard.outputs.ready == 'true' shell: bash @@ -478,6 +541,7 @@ jobs: run: | docker buildx build \ --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \ + --build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \ --push ${{ steps.tags.outputs.args }} . # Verifies a tag release actually ended up complete, and names the specific @@ -488,8 +552,8 @@ jobs: # `failure` with none executed and image-release showed `skipped`. The run was # red, but the *release page rendered fine*, and `main`'s own push build had # already moved `:latest`, so the code was deployable and nothing looked - # obviously wrong. The release was simply missing its APK and its immutable - # `:vYYYY.MM.DD` image, which is easy to skim past. + # obviously wrong. The release was simply missing its APK and its image, + # which is easy to skim past. # # This job cannot prevent that (the cause was a runner failing to launch, not # anything in this file). What it does is turn an incomplete release into an @@ -540,18 +604,30 @@ jobs: # missing when v2026.08.07 had to be re-cut. `always()` on this job means # it runs even when image-release failed, so without this the guard would # cheerfully verify an incomplete release. - - name: Immutable image tag must exist + # + # This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until + # 2026-09-10. Version image tags are no longer published (rule 145), so + # that assertion would now fail every release for a tag nothing mints. + # The rollback target it was really protecting is the : image, which + # main's own build published for this same commit before the tag was cut. + # + # Checking it here earns its keep twice over: it still catches an image + # push that silently did not happen, and it additionally proves the + # ORDERING — a tag cut on a commit whose main build never completed has + # no rollback target, and that is worth failing on rather than + # discovering during an incident. + - name: Rollback image must exist for the tagged commit shell: bash run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" IMAGE="git.fabledsword.com/bvandeusen/minstrel" echo "${{ secrets.CI_TOKEN }}" \ | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin - if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then - echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run." + if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then + echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target." + echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse." exit 1 fi - echo "::notice::image verified: ${IMAGE}:${TAG}" + echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}" diff --git a/Dockerfile b/Dockerfile index cc8edf66..7aef6079 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,21 @@ COPY . . # Overwrite the committed placeholder with the freshly-built SPA assets. COPY --from=web /web/build ./web/build ENV CGO_ENABLED=0 -# Version stamping: release.yml passes the git tag via MINSTREL_VERSION -# build-arg; local `docker build` falls back to "dev". Surfaced at -# /healthz for operator-side image-version verification. +# Version stamping. release.yml passes the DERIVED version name +# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back +# to "dev"/"local". Both are surfaced at /healthz. +# +# These are two values on purpose (family rule 149): the same commit built on +# dev and on main reports the same NAME and differs only in CHANNEL. Folding +# the channel into the version string is what the rule forbids — the version +# used to BE the channel word here ("main"/"dev"), which meant two dev images +# eight weeks apart were indistinguishable. ARG MINSTREL_VERSION=dev +ARG MINSTREL_CHANNEL=local RUN go build -trimpath \ - -ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \ + -ldflags="-s -w \ + -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \ + -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \ -o /out/minstrel ./cmd/minstrel FROM debian:bookworm-slim diff --git a/README.md b/README.md index c9d8e8b1..4571d523 100644 --- a/README.md +++ b/README.md @@ -112,18 +112,21 @@ Most operational keys have a `MINSTREL_
_` env override. Recommen Image tags (`git.fabledsword.com/bvandeusen/minstrel:`): -- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators. -- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag. -- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line. -- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback. +- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run. +- `:` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back. +- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback. -Every `:latest`, `:vYYYY.MM.DD.HHMM` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All of them are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build. +That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image. + +Rolling back to `:` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name. + +Every `:latest`, `:` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build. The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump. -Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended. +Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended. ## Specs @@ -157,7 +160,7 @@ Two concurrent dev processes: - Day-to-day work happens on `dev` (or feature branches merged into `dev`). - `main` is **protected** — changes land via PR from `dev`. -- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry. +- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it. Task and milestone tracking: Fable (`Minstrel` project, id 12). diff --git a/internal/server/release_version_test.go b/internal/server/release_version_test.go index 4dbf2bef..e27aafa0 100644 --- a/internal/server/release_version_test.go +++ b/internal/server/release_version_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "testing" @@ -178,19 +179,86 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) { } } -// devArm returns the branch of "Compute image tags" that handles refs/heads/dev. -func devArm(t *testing.T, yaml string) string { +// jobBoundary matches a blank line followed by job-level (two-space) +// indentation — the end of the last step in a job. +var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`) + +// stepBody returns one workflow step's text, from its `- name:` line to the +// start of the next step or the next job. +// +// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body +// for the last step in a job, and every assertion over it then passes +// vacuously. That is the failure rule 167 names: a check that reads as +// coverage while asserting on nothing. Cutting at a blank line followed by +// job-level indentation handles the last-step case, which is exactly where +// `Build and push` sits. +func stepBody(t *testing.T, yaml, name string) string { t.Helper() - const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then` - i := strings.Index(yaml, marker) + i := strings.Index(yaml, "- name: "+name) if i < 0 { - t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired") + t.Fatalf("no %q step in release.yml", name) } - rest := yaml[i+len(marker):] - if j := strings.Index(rest, "\n else"); j >= 0 { - return rest[:j] + body := yaml[i:] + end := len(body) + if j := strings.Index(body, "\n - name:"); j >= 0 { + end = j } - return rest + // A blank line followed by EXACTLY two spaces and then content starts a + // new job or job-level comment. The "exactly" matters: a blank line inside + // a `run:` block is followed by ten-space indentation and would otherwise + // match, truncating the step mid-body — which is how this helper first + // sliced the verify step down to its first two lines. + if loc := jobBoundary.FindStringIndex(body); loc != nil && loc[0] < end { + end = loc[0] + } + body = body[:end] + if strings.TrimSpace(strings.TrimPrefix(body, "- name: "+name)) == "" { + t.Fatalf("step %q sliced to an empty body — the assertions below would pass vacuously", name) + } + return body +} + +// releaseYAML reads the workflow once per test. +func releaseYAML(t *testing.T) string { + t.Helper() + body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml")) + if err != nil { + t.Fatal(err) + } + return string(body) +} + +// imageTagArms splits "Compute image tags" into its three ref branches. The +// whole tag policy lives in that if/elif/else, so the arms are the unit worth +// asserting on — a tag published from the wrong arm reaches the wrong +// audience, and every such mistake still builds and still pushes a valid +// image. +func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) { + t.Helper() + const ( + tagMarker = `if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then` + devMarker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then` + mainMarker = "\n else" + endMarker = "\n fi" + ) + iTag := strings.Index(yaml, tagMarker) + iDev := strings.Index(yaml, devMarker) + iMain := -1 + if iDev >= 0 { + if k := strings.Index(yaml[iDev:], mainMarker); k >= 0 { + iMain = iDev + k + } + } + if iTag < 0 || iDev < 0 || iMain < 0 { + t.Fatal("Compute image tags no longer has a tag/dev/main arm — the tag policy has been restructured, so these guards are pinning nothing") + } + iEnd := iMain + if k := strings.Index(yaml[iMain:], endMarker); k >= 0 { + iEnd = iMain + k + } else { + t.Fatal("no closing fi after the main arm") + } + return yaml[iTag:iDev], yaml[iDev:iMain], yaml[iMain:iEnd] } // The worst regression this wiring can produce: a dev push that also moves @@ -198,11 +266,7 @@ func devArm(t *testing.T, yaml string) string { // next pull. Nothing else in the suite would notice — the build stays green // and the image is valid, it is simply the wrong audience. func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) { - body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml")) - if err != nil { - t.Fatal(err) - } - arm := devArm(t, string(body)) + _, arm, _ := imageTagArms(t, releaseYAML(t)) if !strings.Contains(arm, "${IMAGE}:dev") { t.Errorf("dev arm does not publish :dev\n%s", arm) @@ -286,3 +350,121 @@ func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) { } } } + +// The rollback unit, and the reason it is worth a guard: it is invisible until +// the moment it is needed. Nothing pulls : during normal operation, so if +// this arm stopped minting one, every build would stay green and every image +// would be valid — and the absence would surface only during an incident, as +// "there is nothing to roll back to." +// +// Rules 145 and 147: main push → :latest + :. +func TestMainChannel_PublishesLatestAndTheRollbackUnit(t *testing.T) { + _, _, arm := imageTagArms(t, releaseYAML(t)) + + if !strings.Contains(arm, "${IMAGE}:latest") { + t.Errorf("main arm does not move :latest — production would stop tracking main's tip\n%s", arm) + } + if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") { + t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm) + } + // Rule 147: :latest tracks main's tip, and a second name for the same + // image sends readers looking for a distinction that does not exist. + if strings.Contains(arm, "${IMAGE}:main") { + t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm) + } +} + +// A release refreshes the CHANNEL and mints nothing else (rules 145 + 146). +// +// The specific regression: re-adding : here. The tag build rebuilds the +// SAME SOURCE as main's build minutes earlier, differing only in which APK is +// baked in — so a : minted here would overwrite main's immutable rollback +// target with different contents, under the same name. That is the exact thing +// rule 145's immutability clause exists to prevent, and it is the half with +// the incidents behind it. +func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) { + arm, _, _ := imageTagArms(t, releaseYAML(t)) + + if !strings.Contains(arm, "${IMAGE}:latest") { + t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm) + } + if strings.Contains(arm, "GITHUB_SHA") { + t.Errorf("tag arm mints a : image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm) + } +} + +// No version-numbered image tags anywhere, on any arm (rule 145, and the +// operator's 2026-09-10 decision to drop them across every project). +// +// Asserted across the whole step rather than per-arm because the mistake this +// catches is re-adding one ANYWHERE, and the tag arm is only the likeliest +// spot. `${VERSION}` still legitimately appears in the step as the build's +// self-reported version, so the assertion has to name the image-tag form +// specifically rather than the variable — otherwise it would fire on correct +// code and get "fixed" by deleting the guard. +func TestNoVersionNumberedImageTags(t *testing.T) { + yaml := releaseYAML(t) + tagArm, devArm, mainArm := imageTagArms(t, yaml) + + for _, tc := range []struct{ name, arm string }{ + {"tag", tagArm}, {"dev", devArm}, {"main", mainArm}, + } { + for _, forbidden := range []string{ + "${IMAGE}:${VERSION}", + "${IMAGE}:v", + "${IMAGE}:${GITHUB_REF#refs/tags/}", + } { + if strings.Contains(tc.arm, forbidden) { + t.Errorf("%s arm publishes a version-numbered image tag (%q); git and the build's self-reported version answer \"which build is this\"\n%s", + tc.name, forbidden, tc.arm) + } + } + } +} + +// The verify job asserted the : image existed. With version tags +// gone that assertion would fail every release for a tag nothing mints — so +// this pins that it was re-pointed rather than deleted, since deleting it is +// the tempting way to make a failing guard go green. +func TestVerifyJob_ChecksTheRollbackImageNotAVersionTag(t *testing.T) { + yaml := releaseYAML(t) + + if !strings.Contains(yaml, "- name: Rollback image must exist for the tagged commit") { + t.Fatal("the release-verification step that checks an image exists is gone; an image push that silently did not happen would now pass verification") + } + step := stepBody(t, yaml, "Rollback image must exist for the tagged commit") + if !strings.Contains(step, "${IMAGE}:${GITHUB_SHA}") { + t.Errorf("the verify step does not inspect the commit's rollback image\n%s", step) + } + if strings.Contains(step, "${IMAGE}:${TAG}") { + t.Errorf("the verify step still inspects a version-numbered image, which is no longer published — this would fail every release\n%s", step) + } +} + +// The server's self-reported version is now the ONLY thing that identifies a +// build, so a lane that stamps a channel word instead of a version silently +// removes that ability. It used to stamp the literal "main"/"dev". +func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) { + yaml := releaseYAML(t) + + step := stepBody(t, yaml, "Build and push") + for _, want := range []string{ + "MINSTREL_VERSION=", + "MINSTREL_CHANNEL=", + } { + if !strings.Contains(step, want) { + t.Errorf("Build and push does not pass %s — the image cannot report which build it is\n%s", want, step) + } + } + + // The version must come from the shared derivation, not from the ref. + // Reading it off GITHUB_REF is what produced "main" and "dev" as version + // strings, which is the regression this pins. + _, _, mainArm := imageTagArms(t, yaml) + if strings.Contains(mainArm, `version=main`) { + t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm) + } + if !strings.Contains(yaml, "ci/version.sh HEAD | sed") { + t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 622e2a9b..af4780c0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -218,6 +218,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "version": ServerVersion, + "channel": ServerChannel, "min_client_version": MinClientVersion, }) } diff --git a/internal/server/version.go b/internal/server/version.go index 1d87c7d4..efc2399e 100644 --- a/internal/server/version.go +++ b/internal/server/version.go @@ -5,12 +5,27 @@ package server // older clients see version_too_old at /healthz and refuse to operate. const MinClientVersion = "0.1.0" -// ServerVersion is the deployed server image's version tag. Defaults to -// "dev" for local builds; overridden at link time via: +// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived +// from the commit it was built from by ci/version.sh. Defaults to "dev" for +// local builds; overridden at link time via: // -// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=v2026.05.10.2'" +// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'" // -// release.yml passes the git tag through MINSTREL_VERSION build-arg → -// Dockerfile ldflag. Surfaced at /healthz so operators can verify which -// image their container is running without exec'ing into it. +// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile +// ldflag. Surfaced at /healthz so operators can verify which image their +// container is running without exec'ing into it. +// +// This carried the literal strings "main" and "dev" until 2026-09-10, which +// made every image on a channel report the same thing forever. It stopped +// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule +// 145): this is now the ONLY thing that says which build is running. var ServerVersion = "dev" + +// ServerChannel is which line this build came off — "stable" or "dev", or +// "local" for a plain `docker build`. +// +// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The +// same commit built on both lanes reports the same version and differs only +// here; folding the two together is what makes a version string stop being +// comparable. +var ServerChannel = "local" diff --git a/web/src/lib/components/ServerVersion.svelte b/web/src/lib/components/ServerVersion.svelte index 2fc80eea..b855a50f 100644 --- a/web/src/lib/components/ServerVersion.svelte +++ b/web/src/lib/components/ServerVersion.svelte @@ -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 : 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(null); + let channel = $state(null); onMount(async () => { try { const res = await fetch('/healthz'); if (!res.ok) return; const body = (await res.json()) as Partial; - 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 @@ {#if version} -

Server {version}

+

+ Server {version}{#if channel} · {channel}{/if} +

{/if}