Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+28
-104
@@ -2,31 +2,11 @@ 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 → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# 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)
|
||||
# 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.
|
||||
# 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
|
||||
@@ -332,16 +312,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# 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 :<version> image tags
|
||||
# gone the server's self-reported version is the only thing that says
|
||||
# which build an image is.
|
||||
fetch-depth: 0
|
||||
# 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
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -359,68 +334,30 @@ 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
|
||||
# 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"
|
||||
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"
|
||||
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 "::notice::Dev-branch build ${VERSION}: :dev"
|
||||
echo "version=dev" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Dev-branch build: :dev"
|
||||
else
|
||||
# The production line: :latest tracks main's tip (rule 147) and
|
||||
# :<sha> 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}"
|
||||
# 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"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
@@ -541,7 +478,6 @@ 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
|
||||
@@ -552,8 +488,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 image,
|
||||
# which is easy to skim past.
|
||||
# obviously wrong. The release was simply missing its APK and its immutable
|
||||
# `:vYYYY.MM.DD` 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
|
||||
@@ -604,30 +540,18 @@ 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.
|
||||
#
|
||||
# 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
|
||||
- name: Immutable image tag must exist
|
||||
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}:${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."
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
||||
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||
|
||||
+4
-13
@@ -15,21 +15,12 @@ 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 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.
|
||||
# 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.
|
||||
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}' \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -112,21 +112,18 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||
- `:<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.
|
||||
- `: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.
|
||||
- `: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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -160,7 +157,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 the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -179,86 +178,19 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
|
||||
func devArm(t *testing.T, yaml string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(yaml, "- name: "+name)
|
||||
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||
i := strings.Index(yaml, marker)
|
||||
if i < 0 {
|
||||
t.Fatalf("no %q step in release.yml", name)
|
||||
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
|
||||
}
|
||||
body := yaml[i:]
|
||||
end := len(body)
|
||||
if j := strings.Index(body, "\n - name:"); j >= 0 {
|
||||
end = j
|
||||
rest := yaml[i+len(marker):]
|
||||
if j := strings.Index(rest, "\n else"); j >= 0 {
|
||||
return rest[:j]
|
||||
}
|
||||
// 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]
|
||||
return rest
|
||||
}
|
||||
|
||||
// The worst regression this wiring can produce: a dev push that also moves
|
||||
@@ -266,7 +198,11 @@ func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm 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) {
|
||||
_, arm, _ := imageTagArms(t, releaseYAML(t))
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arm := devArm(t, string(body))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:dev") {
|
||||
t.Errorf("dev arm does not publish :dev\n%s", arm)
|
||||
@@ -350,121 +286,3 @@ 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,7 +218,6 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,27 +5,12 @@ package server
|
||||
// older clients see version_too_old at /healthz and refuse to operate.
|
||||
const MinClientVersion = "0.1.0"
|
||||
|
||||
// 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:
|
||||
// ServerVersion is the deployed server image's version tag. Defaults to
|
||||
// "dev" for local builds; overridden at link time via:
|
||||
//
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'"
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=v2026.05.10.2'"
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
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,36 +6,26 @@
|
||||
// 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; channel?: string };
|
||||
type Health = { status: string; version?: 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) 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;
|
||||
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';
|
||||
}
|
||||
} catch {
|
||||
// network / parse error — silent.
|
||||
}
|
||||
@@ -43,7 +33,5 @@
|
||||
</script>
|
||||
|
||||
{#if version}
|
||||
<p class="text-xs text-text-secondary">
|
||||
Server {version}{#if channel} · {channel}{/if}
|
||||
</p>
|
||||
<p class="text-xs text-text-secondary">Server {version}</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user