Compare commits

..
4 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 270ad7a71b fix(ci): tests do not ship, so they must not re-version an artifact
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 3m17s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Completes the pathspec. 17212e9e excluded CI, docs and tooling but left
tests in the shipped set, so its own commit re-versioned the image on the
strength of a _test.go file. `go build` drops *_test.go outright and the
Vite build never imports a .test.ts — neither reaches an image or an APK.

Globs over files rather than a directory exclusion, because this repo has
no tests/ tree to exclude: Go tests sit inline beside the code they cover
(158 files) and the web suite beside its modules (115). Patterns match what
exists and nothing speculative — there are no .spec.* files, no __tests__/
directories and no androidTest/ tree. If any appear they re-version until
named, which is the harmless direction and the point of a denylist.

The guard that matters is not "a test-only commit is inert" — it is that a
commit touching a test AND its source still moves the version. `':!internal'`
would satisfy every inertness assertion while silently excluding the entire
server, which is the stale-version-on-changed-artifact failure this whole
derivation exists to prevent.

Falsified: drop the Go exclusion and a _test.go commit moves the version;
drop the web one and a .test.ts does; replace the globs with `':!internal'`
and the source-alongside-test case breaks.

That last check failed first time, on a bug in the FIXTURE rather than the
derivation, and it is worth recording because it makes a test pass for the
wrong reason. Both commit helpers wrote the constant "x\n", so re-writing a
file with identical bytes recorded NOTHING — the "source and test together"
commit actually contained only the test, and the assertion was quietly
checking the case it was meant to contrast against. Content is now derived
from the commit's epoch, and the test asserts HEAD really contains both
paths before drawing any conclusion from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 18:21:12 -04:00
bvandeusenandClaude Opus 5 17212e9eb4 fix(ci): version derives from the shipped set; untrack an 18MB binary
test-go / test (push) Successful in 1m5s
test-go / integration (push) Successful in 3m30s
release / Build signed APK (releases and dev) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Skipped
Three build-hygiene fixes that turned up while explaining the pathspec.

**version.sh derives from what SHIPPED.** It read bare HEAD, so any commit
moved the version — including one touching only CI or a README. Rules 148
and 149 both specify the pathspec form. Now a denylist, and the direction
is the point: as an allowlist the list must be updated by whoever adds a
directory and nothing fails if they don't, so the failure mode is a changed
artifact keeping its old version silently on a green run. Inverted, new
content counts by default.

android/ is deliberately NOT excluded, and that is the subtle part. This
repo ships TWO artifacts from ONE derivation: android/ is in no server
image, but it is the APK's entire source, and excluding it would stop an
Android-only commit from moving the APK's own version — the silent
downgrade the versioning rework exists to prevent. So the list is the
union: exclude only what ships in neither, and accept that an Android
commit also nudges the server's reported version. Over-inclusion across the
two, which is the harmless direction. roundtable/roundtable-android each
keep tighter lists because they are one-artifact repos; don't copy theirs.

**.dockerignore excluded the wrong CI directory.** It named .forgejo/ and
.github/, neither of which this repo has. Gitea Actions reads .gitea/, so
the one directory that exists was the one not excluded. The "Flutter mobile
client" block had also lost its PATTERN when flutter_client/ was deleted,
leaving a comment describing an exclusion that was not happening — android/
never took its place, so 4.1MB of Gradle project entered the context and
busted the `COPY . .` layer on every Android-only change. bin/ excluded too.

**bin/minstrel was tracked** — an 18MB binary last refreshed by a commit
about web test mocks, and re-dirtied by every `make build` since. Untracked
and ignored; the file stays on disk.

Guards are behavioural rather than textual: they build throwaway repos with
pinned commit timestamps and run version.sh against them, so they break when
the derivation changes rather than when the wording does. Falsified — drop
the .gitea exclusion and the CI-only commit moves the version; add an
android exclusion and an Android commit stops moving it; exclude everything
and a source commit refuses.

One honest note on the refusal test: the script already refused an empty
result via the downstream date check, so the new explicit check improves the
diagnostic ("no commit touches the shipped file set — shallow clone?") and
not the safety. The test pins the property, which is defended in depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 17:55:36 -04:00
bvandeusenandClaude Opus 5 8f4b76a638 fix(ci): artifacts move to stock upload-artifact@v7 / download-artifact@v8
test-go / test (push) Successful in 1m4s
test-go / integration (push) Successful in 3m54s
android / Build + lint + test (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
android.yml's debug upload and release.yml's minstrel-apk pair went
through the bvandeusen fork mirrors, with comments saying stock actions
refuse this hostname, that the pair had to be matched on the bundled
@actions/artifact major, and that download v7 was off-limits for node24.
None of that holds on gitea/runner 3.x: the runner edits the GHES refusal
out of the action bundles, every download major v4-v8 reads every upload
major v4-v7 (Scribe spike #3843, CI-runner run 6312), and every CI image
carries Node 24. The mirror pair itself was last verified at tag run 6286.

Same artifact names, paths and if-no-files-found. ci-requirements.md
drops the pairing table and keeps what is still true: @v3 is invisible.

Scribe snippet #2271, milestone 395.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 17:25:10 -04:00
bvandeusenandClaude Opus 5 aeb8781c4e fix(release): drop version image tags, mint the rollback unit on main
test-go / test (push) Successful in 1m43s
test-web / test (push) Successful in 1m13s
test-go / integration (push) Successful in 4m12s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
The image tag map was the inverse of family rules 145 and 147 on every
count: it published :vYYYY.MM.DD.HHMM that nobody pinned, published :main
that rule 147 says should not exist, and published no commit-addressable
image at all — so the rollback unit the rule names did not exist in this
repo. A bad main push had nothing to roll back to but the previous
release tag, which may be many commits back.

The whole map is now:

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

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

Two consequences that are not optional:

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 15:10:15 -04:00
13 changed files with 752 additions and 152 deletions
+19 -5
View File
@@ -6,9 +6,20 @@
**/build **/build
web/build web/build
# Flutter mobile client — built separately on developer machines / Flutter CI. # The Android client — built by its own job, never from this context. The APK
# Including it in the Go build context wastes ~70 files and invalidates the # reaches the image through client/, downloaded as a CI artifact, so nothing
# `COPY . .` layer cache on every Flutter-only change. # here reads android/ sources.
#
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
# that tree was deleted, leaving a comment describing an exclusion that was no
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
# has been entering the context and busting the `COPY . .` layer on every
# Android-only change.
android/
# Local `make build` output — an 18 MB binary the image never uses, since the
# builder stage compiles its own.
bin/
# Docs and IDE noise # Docs and IDE noise
docs/ docs/
@@ -26,5 +37,8 @@ docs/
!.env.example !.env.example
# CI workflow files don't need to ship in the image. # CI workflow files don't need to ship in the image.
.forgejo/ #
.github/ # This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
# Actions reads `.gitea/`, so the one directory that actually exists was the
# one not excluded, and every workflow edit invalidated the context.
.gitea/
+6 -9
View File
@@ -80,15 +80,12 @@ jobs:
- name: Upload debug APK - name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Mirrored action, never actions/upload-artifact. @v4+ throws # Stock action: it works on this forge since the runner moved to
# GHESNotSupportedError client-side on the hostname (no server setting # gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# reaches that check), and @v3 is worse — it reports success while Gitea # out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
# serves artifacts back only through the v4 API, so the upload is stored # success while Gitea serves artifacts back only through the v4 API, and
# and invisible to every retrieval path. @v3 is what left 72 unreachable # it is what left 72 unreachable artifacts on this repo (Scribe 2270).
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs; uses: actions/upload-artifact@v7
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
# See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with: with:
name: minstrel-android-debug-${{ github.sha }} name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk path: android/app/build/outputs/apk/debug/app-debug.apk
+115 -49
View File
@@ -2,11 +2,31 @@ 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
@@ -212,13 +232,12 @@ jobs:
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} -PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact - name: Upload APK as workflow artifact
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the # Stock action (snippet #2271) — never @v3, which uploads something Gitea
# hostname, @v3 uploads something Gitea will never serve back. This is # will never serve back. This is the producing half of a pair:
# the producing half of a pair: image-release downloads `minstrel-apk` # image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
# below with the matching download-artifact mirror. Both must stay on # any download v4+ on this forge (every combination tested 2026-09-10,
# the v4 protocol — mixing a v3 upload with a v4 download (or the # Scribe spike #3843), so the two pins need not move together.
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270. uses: actions/upload-artifact@v7
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with: with:
name: minstrel-apk name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk path: android/app/build/outputs/apk/release/app-release.apk
@@ -312,11 +331,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 +358,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
@@ -372,20 +434,11 @@ jobs:
if: >- if: >-
steps.guard.outputs.ready == 'true' && steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev') (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
# Consuming half of the pair — never actions/download-artifact. Same fork, # Consuming half of the pair: stock download-artifact, which works here for
# same reason: upstream's client-side GHES check rejects this hostname # the same reason as the upload (gitea/runner 3.x edits the GHES refusal
# before it connects. bvandeusen/download-artifact mirrors # out of the bundle; snippet #2271). v8 runs on node24, which every
# code.forgejo.org/forgejo/download-artifact. # CI-runner image carries — the runner uses the image's own node.
# uses: actions/download-artifact@v8
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# the action's own version number — the two actions release on unrelated
# cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
with: with:
name: minstrel-apk name: minstrel-apk
path: client/ path: client/
@@ -478,6 +531,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 +542,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 +594,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}"
+5
View File
@@ -12,6 +12,11 @@
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
# binary committed by accident, last refreshed by a commit about web test
# mocks, and re-dirtied by every local build since.
bin/
# Bundled Android APK + version sidecar (#397). Populated by CI for # Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow. # tag releases; never committed. README in client/ explains the flow.
client/minstrel.apk client/minstrel.apk
+13 -4
View File
@@ -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
+10 -7
View File
@@ -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).
BIN
View File
Binary file not shown.
+20 -47
View File
@@ -62,57 +62,30 @@ None.
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows. - **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved. - **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action. - **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.** - **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
```yaml ```yaml
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 uses: actions/upload-artifact@v7
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a uses: actions/download-artifact@v8
``` ```
Upstream's `@v4+` cannot work against this instance and no server-side change Stock works on this forge since the runner moved to gitea/runner 3.x, which
will help: `isGhes()` rejects any hostname that isn't `github.com` / edits the actions' client-side `isGhes()` refusal out of their bundles. Proven
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the on 2026-09-10 for upload v4v7 and download v4v8 (Scribe spike #3843). Until
server is never asked what it supports. `@v3` is worse — it reports success, then this repo pinned SHA mirrors of the Forgejo project's forks, because
and Gitea then serves artifacts back only through the v4 API upstream threw on the hostname before it opened a connection (Scribe 2255).
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
to every retrieval path. A green job producing nothing retrievable; that is how
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
Both are pull mirrors of the Forgejo project's forks `@v3` is still broken: it reports success, and Gitea serves artifacts back only
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream through the v4 API (`content_encoding = application/zip`), so a v3 upload is
disabling that check), mirrored so CI depends on commits we hold and pinned by stored but invisible to every retrieval path. That is how 72 unreachable
SHA because the mirrors auto-sync every 8h — a moved upstream tag would artifacts accumulated on this repo (Scribe 2270).
otherwise silently change what runs.
**Match the pins on `@actions/artifact`, not on the actions' own version **Pairing no longer needs managing.** This entry used to pin upload v5 against
numbers.** The two actions release on unrelated cadences, so equal version download v6 so both bundled `@actions/artifact` ^4.0.0, warning that a mismatch
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact` across `release.yml`'s producer/consumer pair would list empty. Tested, and not
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and true on this instance: every download major v4v8 read the artifacts of every
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This upload major v4v7, by name and by pattern (CI-runner run 6312). The only real
matters because `release.yml` is a producer/consumer pair — `android-release` protocol break is v3 → v4. node24 is no longer a concern either — every
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch CI-runner image carries Node 24 and the runner runs actions with the image's
across it yields an empty listing rather than an error, exactly the silent `node`.
failure this entry exists to prevent.
| tag | `@actions/artifact` | runtime |
|---|---|---|
| upload v4 | ^2.1.1 | node20 |
| **upload v5** ← pinned | **^4.0.0** | node20 |
| download v4 | ^2.1.1 | node20 |
| download v5 | ^2.3.2 | node20 |
| **download v6** ← pinned | **^4.0.0** | node20 |
| download v7 | ^5.0.0 | **node24** |
The only true protocol break in this history was **v3 → v4** (upstream:
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
below are not supported"); v4-and-up are one family. Later majors are mostly
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
at 500 artifacts; download v5 made by-ID extraction match by-name.
**Do not jump the download pin to v7.** That major is a runner requirement, not
a feature change: it moves to `runs.using: node24` and upstream states it
"requires a minimum Actions Runner version of 2.327.1 … if you are using
self-hosted runners, ensure they are updated before upgrading." act_runner is
not GitHub's runner and makes no such version claim, so node24 is unverified
here. Everything currently pinned is node20.
Upload steps set `if-no-files-found: error` rather than the default `warn`, so Upload steps set `if-no-files-found: error` rather than the default `warn`, so
an upload that matches nothing fails its own job instead of failing the an upload that matches nothing fails its own job instead of failing the
+77 -2
View File
@@ -2,7 +2,8 @@
# #
# Derives the three values a build is stamped with, and the tag that names it. # Derives the three values a build is stamped with, and the tag that names it.
# #
# name=YYYY.MM.DD.HHMM label for people, from the COMMIT's timestamp # name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time # code=<int> ordering key, minutes since 2020-01-01 at BUILD time
# tag=v<name> what a release of this commit must be called # tag=v<name> what a release of this commit must be called
# #
@@ -33,9 +34,83 @@ readonly REF="${1:-HEAD}"
# Both clocks are overridable so a test can pin them. Nothing but tests should # Both clocks are overridable so a test can pin them. Nothing but tests should
# set these — the defaults are the real derivation. # set these — the defaults are the real derivation.
# The paths that do NOT ship, in either artifact. Everything else counts.
#
# A DENYLIST, and the direction is the whole point. As an allowlist, the list
# has to be updated by whoever adds a directory and nothing fails if they
# don't — so the failure mode is a changed artifact keeping its old version,
# silently, on a green run. That is a build lying about what it is. Inverted,
# new content counts by default and the only way to wrongly EXCLUDE something
# is to name it here deliberately.
#
# The two error directions are not symmetric, which is why this is not taste:
# wrongly excluded → changed artifact, unchanged version. A silent lie.
# wrongly included → version moves when nothing shipped. Cosmetic noise in
# a string nobody sorts.
#
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
# is shorter than it looks like it should be. The server image ships cmd/,
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
# android/. Neither ships the other's sources — but excluding android/ here
# would stop an Android-only commit from moving the APK's OWN version, which
# is the dangerous direction. So this is the union: exclude only what ships in
# NEITHER, and accept that an Android commit also nudges the server's reported
# version. Over-inclusion across the two, which is the harmless direction.
#
# The family's other repos (roundtable / roundtable-android) each keep a
# tighter list because they are separate repos with one artifact apiece. Do
# not copy theirs onto this one.
readonly SHIPPED=(
.
':!.gitea' # CI workflows — including this script's own caller
':!ci' # CI scripts — including this script
':!docs'
':!tools' # asset/font generators; their OUTPUT ships, they do not
':!deploy' # test-database bootstrap SQL
':!bin' # local `make build` output
':!*.md'
':!Makefile'
':!docker-compose.yml'
':!.env.example'
':!.gitignore'
':!.dockerignore'
':!renovate.json'
':!.golangci.yml'
# TESTS DO NOT SHIP, so they must not re-version an artifact.
#
# Named as globs rather than a directory because this repo has no tests/
# tree to exclude: Go tests sit inline beside the code they cover, and the
# web suite sits beside its modules. `go build` drops *_test.go outright and
# the Vite build never imports a .test.ts, so neither reaches an artifact.
#
# A commit touching a test AND its source still moves the version — the
# source path matches on its own. Only a test-ONLY commit is inert, which is
# the whole intent.
#
# Patterns match what exists today and nothing speculative: there are no
# .spec.* files, no __tests__/ directories and no androidTest/ tree. If any
# appear they will re-version until named here, which is the harmless
# direction and the reason this list is a denylist.
':!*_test.go' # 158 files, inline beside the code
':!*.test.ts' # 114 files
':!*.test.js'
':!android/app/src/test' # JVM unit tests; no androidTest tree exists
':!web/vitest.config.ts' # test-harness config, not build config
':!web/vitest.setup.ts'
)
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}" commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
if [ -z "${commit_epoch}" ]; then if [ -z "${commit_epoch}" ]; then
commit_epoch="$(git log --format=%ct -1 "${REF}")" commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
# Loudly, on purpose. A silent fallback here is the landmine this whole
# script exists to avoid: a plausible-looking version that is quietly wrong,
# on a green run. Realistically this means a shallow clone (no commit in
# range touches the shipped set) rather than a repo of pure CI config.
if [ -z "${commit_epoch}" ]; then
echo "version.sh: no commit under '${REF}' touches the shipped file set — shallow clone? (needs fetch-depth: 0)" >&2
exit 1
fi
fi fi
now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}" now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}"
+444 -14
View File
@@ -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,369 @@ 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")
}
}
// gitRepo builds a throwaway repo and returns its path. Commit timestamps are
// pinned so the derivation is deterministic.
func gitRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("-c", "init.defaultBranch=main", "init", "-q")
run("config", "user.email", "t@example.invalid")
run("config", "user.name", "t")
return dir
}
// commitFile writes path and commits it with a pinned committer timestamp.
func commitFile(t *testing.T, dir, path, epoch string) {
t.Helper()
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
// Content must DIFFER from any earlier write to the same path, or git
// records nothing and the commit silently covers fewer files than the
// test believes. That is not hypothetical: it made the
// source-alongside-test case pass vacuously.
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", path}} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
}
// versionIn runs ci/version.sh inside dir, reading real git rather than the
// pinned-clock override, so the PATHSPEC is what is under test.
func versionIn(t *testing.T, dir string) (string, error) {
t.Helper()
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "HEAD")
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"MINSTREL_NOW_EPOCH=1789000920",
)
out, err := cmd.CombinedOutput()
return string(out), err
}
// The version names what SHIPPED, so a commit that changes nothing shippable
// must not move it.
//
// The failure this prevents is not the cosmetic one. The derivation is a
// denylist precisely so that new content counts by default: the direction that
// matters is a changed artifact keeping its OLD version, silently, on a green
// run. This test pins the cheap half of that (CI-only commits are inert) and,
// in the same breath, that a source commit still moves it — because a pathspec
// typo that excluded everything would satisfy the first assertion alone.
func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
const (
shipped = "1757443736" // 2025-09-09T18:48:56Z
ciOnly = "1789000920" // 2026-09-10T00:42:00Z, later
)
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", shipped)
commitFile(t, dir, ".gitea/workflows/release.yml", ciOnly)
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2025.09.09.1848") {
t.Errorf("a CI-only commit moved the version — the pathspec is not excluding it\n%s", out)
}
// ...and the pathspec must not be so broad it excludes everything.
commitFile(t, dir, "internal/server/other.go", ciOnly)
out, err = versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("a source commit did NOT move the version — the pathspec excludes too much, which is the silent-lie direction\n%s", out)
}
}
// android/ is deliberately NOT excluded, and that is the subtle half of the
// list. It ships in no server image — but it is the APK's entire source, and
// ONE script derives the version for both artifacts. Excluding it would stop
// an Android-only commit from moving the APK's own version, which is exactly
// the silent downgrade the versioning rework exists to prevent.
func TestVersionName_AndroidSourcesCount(t *testing.T) {
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", "1757443736")
commitFile(t, dir, "android/app/src/main/Thing.kt", "1789000920")
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("an Android commit did not move the version; the APK would ship new code under its old version name\n%s", out)
}
}
// No shipped commit in range means a shallow clone, and the script must refuse
// rather than emit something plausible. A wrong version builds, signs and
// publishes perfectly happily; it surfaces later as an update channel that has
// quietly stopped offering anything.
func TestVersionScript_RefusesWhenNothingShippedIsInRange(t *testing.T) {
dir := gitRepo(t)
commitFile(t, dir, "ci/version.sh", "1789000920")
out, err := versionIn(t, dir)
if err == nil {
t.Fatalf("script succeeded with no shipped commit in range; it should refuse\n%s", out)
}
if strings.Contains(out, "name=") {
t.Errorf("script emitted a version name while refusing — that value could still be consumed\n%s", out)
}
}
// commitFiles is commitFile for more than one path in a single commit.
func commitFiles(t *testing.T, dir, epoch string, paths ...string) {
t.Helper()
for _, rel := range paths {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", strings.Join(paths, " ")}} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
}
// gitOutput runs a git command in dir and returns its combined output.
func gitOutput(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return string(out)
}
// Tests do not ship, so a test-only commit must not re-version an artifact.
//
// `go build` drops *_test.go outright and the Vite build never imports a
// .test.ts, so neither reaches an image or an APK. This repo has no tests/
// tree — Go tests sit inline beside the code — so the exclusions are globs,
// and a glob is easy to get subtly wrong in a way that still looks right.
func TestVersionName_TestsDoNotReVersionTheArtifact(t *testing.T) {
const (
shipped = "1757443736" // 2025.09.09.1848
later = "1789000920" // 2026.09.10.0042
)
for _, tc := range []struct{ name, path string }{
{"go test beside its source", "internal/server/thing_test.go"},
{"web unit test", "web/src/lib/api/admin.test.ts"},
{"web script test", "web/scripts/tokens-to-css.test.js"},
{"android JVM unit test", "android/app/src/test/java/A.kt"},
{"vitest harness config", "web/vitest.config.ts"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", shipped)
commitFile(t, dir, tc.path, later)
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2025.09.09.1848") {
t.Errorf("a commit touching only %s moved the version; tests do not ship\n%s", tc.path, out)
}
})
}
}
// The direction that actually costs something, and the reason the exclusions
// above are globs over FILES rather than over their directories.
//
// `':!internal'` would satisfy every assertion in the test above while
// silently excluding the entire server. This pins the opposite: a commit that
// changes a test AND the source under it must still move the version, because
// the source path matches on its own. Without this, a too-broad exclusion
// reads as a passing test suite and ships an artifact under a stale version.
func TestVersionName_ATestAlongsideItsSourceStillCounts(t *testing.T) {
const (
shipped = "1757443736"
later = "1789000920"
)
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", shipped)
commitFiles(t, dir, later,
"internal/server/thing.go",
"internal/server/thing_test.go",
)
// The commit must actually contain BOTH paths. Rewriting a file with
// identical bytes records nothing, and this assertion would then be
// checking a test-only commit while appearing to check a mixed one.
touched := gitOutput(t, dir, "show", "--name-only", "--format=", "HEAD")
for _, want := range []string{"internal/server/thing.go", "internal/server/thing_test.go"} {
if !strings.Contains(touched, want) {
t.Fatalf("fixture is wrong: HEAD does not contain %s\n%s", want, touched)
}
}
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("a source change accompanied by a test change did NOT move the version — "+
"the exclusions are matching directories rather than test files\n%s", out)
}
}
+1
View File
@@ -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,
}) })
} }
+21 -6
View File
@@ -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"
+21 -9
View File
@@ -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}&nbsp;·&nbsp;{channel}{/if}
</p>
{/if} {/if}