Recommendation relevance, the rollback unit, and a version that names what shipped #131
+103
-27
@@ -3,10 +3,30 @@ name: release
|
|||||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to dev → :dev (freshly-built dev APK bundled)
|
# push to dev → :dev (freshly-built dev APK bundled)
|
||||||
# push to main → :main and :latest (latest-release APK bundled)
|
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# 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.
|
||||||
|
#
|
||||||
|
# :<sha> 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 :<version> 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.
|
# 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,
|
# 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
|
# which made `main` the staging area by default. `:dev` carries its own
|
||||||
@@ -312,11 +332,16 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Shallow is fine here. This job used to need full history + tags to
|
# Full history, and rule 149 names this specifically: any job that
|
||||||
# re-derive the bundled APK's version from the tagged commit; it now
|
# DERIVES the version name needs it, because a shallow clone changes
|
||||||
# downloads the sidecar the release recorded, and touches git for
|
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||||
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
|
# value, silently, with every lane green.
|
||||||
fetch-depth: 1
|
#
|
||||||
|
# This job was depth-1 while it took the version from GITHUB_REF. It
|
||||||
|
# now runs ci/version.sh itself, because with :<version> 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
|
- name: Detect buildable project
|
||||||
id: guard
|
id: guard
|
||||||
@@ -334,30 +359,68 @@ jobs:
|
|||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
# A release refreshes the CHANNEL and mints nothing else.
|
||||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
#
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
# The tag build exists to produce the signed APK and attach it to
|
||||||
echo "::notice::Release build: ${VERSION} + latest"
|
# 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
|
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
|
||||||
# The rolling test channel, and :dev ALONE — deliberately no
|
# The rolling test channel, and :dev ALONE — deliberately no
|
||||||
# per-commit tag. A rolling channel is rolling by definition, so a
|
# per-commit tag. A rolling channel is rolling by definition, so a
|
||||||
# commit-addressable image here would be a rollback target nobody
|
# commit-addressable image here would be a rollback target nobody
|
||||||
# has ever pulled, accumulating in the registry forever. Recovery
|
# has ever pulled, accumulating in the registry forever. Recovery
|
||||||
# on dev is to fix forward.
|
# on dev is to fix forward.
|
||||||
|
CHANNEL=dev
|
||||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=dev" >> "$GITHUB_OUTPUT"
|
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||||
echo "::notice::Dev-branch build: :dev"
|
|
||||||
else
|
else
|
||||||
# Main is the protected, post-PR-merge branch. Treat it as the
|
# The production line: :latest tracks main's tip (rule 147) and
|
||||||
# rolling stable channel — every main push moves :latest.
|
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
||||||
# Pinned consumers can target :vYYYY.MM.DD.HHMM, which never
|
# the family's other repos, so a rollback target is addressable
|
||||||
# moves; everyone else gets the newest main.
|
# straight from the commit anyone is reading.
|
||||||
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
CHANNEL=stable
|
||||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Main-branch build: :main + :latest"
|
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Registry login
|
- name: Registry login
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -478,6 +541,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||||
|
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||||
--push ${{ steps.tags.outputs.args }} .
|
--push ${{ steps.tags.outputs.args }} .
|
||||||
|
|
||||||
# Verifies a tag release actually ended up complete, and names the specific
|
# 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
|
# `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
|
# 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
|
# already moved `:latest`, so the code was deployable and nothing looked
|
||||||
# obviously wrong. The release was simply missing its APK and its immutable
|
# obviously wrong. The release was simply missing its APK and its image,
|
||||||
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
# which is easy to skim past.
|
||||||
#
|
#
|
||||||
# This job cannot prevent that (the cause was a runner failing to launch, not
|
# 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
|
# 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
|
# 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
|
# it runs even when image-release failed, so without this the guard would
|
||||||
# cheerfully verify an incomplete release.
|
# 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 :<sha> 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
|
||||||
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||||
|
|
||||||
echo "${{ secrets.CI_TOKEN }}" \
|
echo "${{ secrets.CI_TOKEN }}" \
|
||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /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."
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
||||||
|
|||||||
+13
-4
@@ -15,12 +15,21 @@ COPY . .
|
|||||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||||
COPY --from=web /web/build ./web/build
|
COPY --from=web /web/build ./web/build
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
# Version stamping. release.yml passes the DERIVED version name
|
||||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||||
# /healthz for operator-side image-version verification.
|
# 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_VERSION=dev
|
||||||
|
ARG MINSTREL_CHANNEL=local
|
||||||
RUN go build -trimpath \
|
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
|
-o /out/minstrel ./cmd/minstrel
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -112,18 +112,21 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
|||||||
|
|
||||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||||
|
|
||||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||||
- `: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.
|
- `:<commit-sha>` — 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.
|
||||||
- `: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 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.
|
||||||
- `: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.
|
|
||||||
|
|
||||||
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 `:<commit-sha>` 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`, `:<commit-sha>` 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.
|
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.
|
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
|
## Specs
|
||||||
|
|
||||||
@@ -157,7 +160,7 @@ Two concurrent dev processes:
|
|||||||
|
|
||||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||||
- `main` is **protected** — changes land via PR from `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).
|
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -178,19 +179,86 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
|
// jobBoundary matches a blank line followed by job-level (two-space)
|
||||||
func devArm(t *testing.T, yaml string) string {
|
// 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()
|
t.Helper()
|
||||||
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
i := strings.Index(yaml, "- name: "+name)
|
||||||
i := strings.Index(yaml, marker)
|
|
||||||
if i < 0 {
|
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):]
|
body := yaml[i:]
|
||||||
if j := strings.Index(rest, "\n else"); j >= 0 {
|
end := len(body)
|
||||||
return rest[:j]
|
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
|
// 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
|
// next pull. Nothing else in the suite would notice — the build stays green
|
||||||
// and the image is valid, it is simply the wrong audience.
|
// and the image is valid, it is simply the wrong audience.
|
||||||
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
_, arm, _ := imageTagArms(t, releaseYAML(t))
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
arm := devArm(t, string(body))
|
|
||||||
|
|
||||||
if !strings.Contains(arm, "${IMAGE}:dev") {
|
if !strings.Contains(arm, "${IMAGE}:dev") {
|
||||||
t.Errorf("dev arm does not publish :dev\n%s", arm)
|
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 :<sha> 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 + :<sha>.
|
||||||
|
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 :<sha> here. The tag build rebuilds the
|
||||||
|
// SAME SOURCE as main's build minutes earlier, differing only in which APK is
|
||||||
|
// baked in — so a :<sha> 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 :<sha> 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 :<version> 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": ServerVersion,
|
"version": ServerVersion,
|
||||||
|
"channel": ServerChannel,
|
||||||
"min_client_version": MinClientVersion,
|
"min_client_version": MinClientVersion,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,27 @@ package server
|
|||||||
// older clients see version_too_old at /healthz and refuse to operate.
|
// older clients see version_too_old at /healthz and refuse to operate.
|
||||||
const MinClientVersion = "0.1.0"
|
const MinClientVersion = "0.1.0"
|
||||||
|
|
||||||
// ServerVersion is the deployed server image's version tag. Defaults to
|
// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived
|
||||||
// "dev" for local builds; overridden at link time via:
|
// 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 →
|
// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
|
||||||
// Dockerfile ldflag. Surfaced at /healthz so operators can verify which
|
// ldflag. Surfaced at /healthz so operators can verify which image their
|
||||||
// image their container is running without exec'ing into it.
|
// 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"
|
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"
|
||||||
|
|||||||
@@ -6,26 +6,36 @@
|
|||||||
// is actually running (came up debugging the in-app update flow when
|
// 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).
|
// 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
|
// /healthz is unauthenticated, so the bare fetch works without
|
||||||
// credentials. Renders nothing on parse failure or pre-version
|
// credentials. Renders nothing on parse failure or pre-version
|
||||||
// images that don't include the field — graceful degradation.
|
// 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 version = $state<string | null>(null);
|
||||||
|
let channel = $state<string | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/healthz');
|
const res = await fetch('/healthz');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const body = (await res.json()) as Partial<Health>;
|
const body = (await res.json()) as Partial<Health>;
|
||||||
if (body.version && body.version !== 'dev') {
|
if (!body.version) return;
|
||||||
version = body.version;
|
version = body.version;
|
||||||
} else if (body.version === 'dev') {
|
// Reported verbatim rather than validated against an enum — a build
|
||||||
// Local dev images report "dev" — show it so the operator
|
// claiming something unexpected is better shown than dropped.
|
||||||
// can tell they're not on a release tag.
|
channel = body.channel && body.channel !== 'stable' ? body.channel : null;
|
||||||
version = 'dev';
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// network / parse error — silent.
|
// network / parse error — silent.
|
||||||
}
|
}
|
||||||
@@ -33,5 +43,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if version}
|
{#if version}
|
||||||
<p class="text-xs text-text-secondary">Server {version}</p>
|
<p class="text-xs text-text-secondary">
|
||||||
|
Server {version}{#if channel} · {channel}{/if}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user