Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17212e9eb4 | ||
|
|
8f4b76a638 | ||
|
|
aeb8781c4e |
+19
-5
@@ -6,9 +6,20 @@
|
||||
**/build
|
||||
web/build
|
||||
|
||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||
# `COPY . .` layer cache on every Flutter-only change.
|
||||
# The Android client — built by its own job, never from this context. The APK
|
||||
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
||||
# 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/
|
||||
@@ -26,5 +37,8 @@ docs/
|
||||
!.env.example
|
||||
|
||||
# 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/
|
||||
|
||||
@@ -80,15 +80,12 @@ jobs:
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# serves artifacts back only through the v4 API, so the upload is stored
|
||||
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||
# 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
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||
# success while Gitea serves artifacts back only through the v4 API, and
|
||||
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
+115
-49
@@ -2,11 +2,31 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# That is the whole tag map, and it is family rule 145 + 147 as written.
|
||||
#
|
||||
# :<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
|
||||
@@ -212,13 +232,12 @@ jobs:
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||
# below with the matching download-artifact mirror. Both must stay on
|
||||
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||
# will never serve back. This is the producing half of a pair:
|
||||
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
||||
# any download v4+ on this forge (every combination tested 2026-09-10,
|
||||
# Scribe spike #3843), so the two pins need not move together.
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
@@ -312,11 +331,16 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Shallow is fine here. This job used to need full history + tags to
|
||||
# re-derive the bundled APK's version from the tagged commit; it now
|
||||
# downloads the sidecar the release recorded, and touches git for
|
||||
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
|
||||
fetch-depth: 1
|
||||
# Full history, and rule 149 names this specifically: any job that
|
||||
# DERIVES the version name needs it, because a shallow clone changes
|
||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||
# value, silently, with every lane green.
|
||||
#
|
||||
# This job was depth-1 while it took the version from GITHUB_REF. It
|
||||
# now runs ci/version.sh itself, because with :<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
|
||||
id: guard
|
||||
@@ -334,30 +358,68 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# THE VERSION, and it is derived the same way on every ref — the
|
||||
# branch decides the CHANNEL, never the version (family rule 149).
|
||||
#
|
||||
# This used to be three different things: the literal string "main"
|
||||
# on main, "dev" on dev, and the tag name on a tag. None of them
|
||||
# ordered, and the first two were the same string forever — two dev
|
||||
# images eight weeks apart were indistinguishable in the UI. That
|
||||
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
|
||||
# build; with version image tags gone, this IS how an operator tells
|
||||
# which build a container is running.
|
||||
#
|
||||
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
|
||||
# so the empty check below is actually reachable. A grep here would
|
||||
# kill the step at the assignment under the runner's pipefail — the
|
||||
# exact bug that took down the first main build after the version
|
||||
# rework.
|
||||
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "::error::could not derive a build version from ci/version.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
# A release refreshes the CHANNEL and mints nothing else.
|
||||
#
|
||||
# The tag build exists to produce the signed APK and attach it to
|
||||
# the release; the image it rebuilds is the SAME SOURCE as the main
|
||||
# build minutes earlier, differing only in which APK is baked in.
|
||||
# Rule 145 is explicit about that case: when the same source is
|
||||
# rebuilt with different contents, publish the moving channel tag
|
||||
# and never a commit-addressable one.
|
||||
#
|
||||
# :latest must move here rather than waiting for the next main
|
||||
# push, or the channel would carry the PREVIOUS release's APK
|
||||
# indefinitely — a channel that cannot refresh itself (rule 146).
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
|
||||
# The rolling test channel, and :dev ALONE — deliberately no
|
||||
# per-commit tag. A rolling channel is rolling by definition, so a
|
||||
# commit-addressable image here would be a rollback target nobody
|
||||
# has ever pulled, accumulating in the registry forever. Recovery
|
||||
# on dev is to fix forward.
|
||||
CHANNEL=dev
|
||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||
echo "version=dev" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Dev-branch build: :dev"
|
||||
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||
else
|
||||
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||
# rolling stable channel — every main push moves :latest.
|
||||
# Pinned consumers can target :vYYYY.MM.DD.HHMM, which never
|
||||
# moves; everyone else gets the newest main.
|
||||
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main + :latest"
|
||||
# The production line: :latest tracks main's tip (rule 147) and
|
||||
# :<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}"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
@@ -372,20 +434,11 @@ jobs:
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||
# same reason: upstream's client-side GHES check rejects this hostname
|
||||
# before it connects. bvandeusen/download-artifact mirrors
|
||||
# code.forgejo.org/forgejo/download-artifact.
|
||||
#
|
||||
# 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
|
||||
# Consuming half of the pair: stock download-artifact, which works here for
|
||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||
# out of the bundle; snippet #2271). v8 runs on node24, which every
|
||||
# CI-runner image carries — the runner uses the image's own node.
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
@@ -478,6 +531,7 @@ jobs:
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
# Verifies a tag release actually ended up complete, and names the specific
|
||||
@@ -488,8 +542,8 @@ jobs:
|
||||
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||
# already moved `:latest`, so the code was deployable and nothing looked
|
||||
# obviously wrong. The release was simply missing its APK and its immutable
|
||||
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
||||
# obviously wrong. The release was simply missing its APK and its image,
|
||||
# which is easy to skim past.
|
||||
#
|
||||
# This job cannot prevent that (the cause was a runner failing to launch, not
|
||||
# anything in this file). What it does is turn an incomplete release into an
|
||||
@@ -540,18 +594,30 @@ jobs:
|
||||
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
||||
# it runs even when image-release failed, so without this the guard would
|
||||
# cheerfully verify an incomplete release.
|
||||
- name: Immutable image tag must exist
|
||||
#
|
||||
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
||||
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
||||
# that assertion would now fail every release for a tag nothing mints.
|
||||
# The rollback target it was really protecting is the :<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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
|
||||
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
|
||||
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
# Test binary, built with `go test -c`
|
||||
*.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
|
||||
# tag releases; never committed. README in client/ explains the flow.
|
||||
client/minstrel.apk
|
||||
|
||||
+13
-4
@@ -15,12 +15,21 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
# Version stamping. release.yml passes the DERIVED version name
|
||||
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||
# to "dev"/"local". Both are surfaced at /healthz.
|
||||
#
|
||||
# These are two values on purpose (family rule 149): the same commit built on
|
||||
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
|
||||
# the channel into the version string is what the rule forbids — the version
|
||||
# used to BE the channel word here ("main"/"dev"), which meant two dev images
|
||||
# eight weeks apart were indistinguishable.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
ARG MINSTREL_CHANNEL=local
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-ldflags="-s -w \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -112,18 +112,21 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
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.
|
||||
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||
- `:<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.
|
||||
|
||||
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.
|
||||
|
||||
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
|
||||
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -157,7 +160,7 @@ Two concurrent dev processes:
|
||||
|
||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||
- `main` is **protected** — changes land via PR from `dev`.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
|
||||
Binary file not shown.
+20
-47
@@ -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.
|
||||
- **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.
|
||||
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||
- **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
|
||||
```yaml
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
```
|
||||
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||
and Gitea then serves artifacts back only through the v4 API
|
||||
(`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.
|
||||
Stock works on this forge since the runner moved to gitea/runner 3.x, which
|
||||
edits the actions' client-side `isGhes()` refusal out of their bundles. Proven
|
||||
on 2026-09-10 for upload v4–v7 and download v4–v8 (Scribe spike #3843). Until
|
||||
then this repo pinned SHA mirrors of the Forgejo project's forks, because
|
||||
upstream threw on the hostname before it opened a connection (Scribe 2255).
|
||||
|
||||
Both are pull mirrors of the Forgejo project's forks
|
||||
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||
otherwise silently change what runs.
|
||||
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
||||
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
||||
stored but invisible to every retrieval path. That is how 72 unreachable
|
||||
artifacts accumulated on this repo (Scribe 2270).
|
||||
|
||||
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||
numbers.** The two actions release on unrelated cadences, so equal version
|
||||
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||
across it yields an empty listing rather than an error, exactly the silent
|
||||
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.
|
||||
**Pairing no longer needs managing.** This entry used to pin upload v5 against
|
||||
download v6 so both bundled `@actions/artifact` ^4.0.0, warning that a mismatch
|
||||
across `release.yml`'s producer/consumer pair would list empty. Tested, and not
|
||||
true on this instance: every download major v4–v8 read the artifacts of every
|
||||
upload major v4–v7, by name and by pattern (CI-runner run 6312). The only real
|
||||
protocol break is v3 → v4. node24 is no longer a concern either — every
|
||||
CI-runner image carries Node 24 and the runner runs actions with the image's
|
||||
`node`.
|
||||
|
||||
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
|
||||
|
||||
+55
-2
@@ -2,7 +2,8 @@
|
||||
#
|
||||
# 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
|
||||
# tag=v<name> what a release of this commit must be called
|
||||
#
|
||||
@@ -33,9 +34,61 @@ readonly REF="${1:-HEAD}"
|
||||
|
||||
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
||||
# 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'
|
||||
)
|
||||
|
||||
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
||||
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
|
||||
now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -178,19 +179,86 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
|
||||
func devArm(t *testing.T, yaml string) string {
|
||||
// jobBoundary matches a blank line followed by job-level (two-space)
|
||||
// indentation — the end of the last step in a job.
|
||||
var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`)
|
||||
|
||||
// stepBody returns one workflow step's text, from its `- name:` line to the
|
||||
// start of the next step or the next job.
|
||||
//
|
||||
// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body
|
||||
// for the last step in a job, and every assertion over it then passes
|
||||
// vacuously. That is the failure rule 167 names: a check that reads as
|
||||
// coverage while asserting on nothing. Cutting at a blank line followed by
|
||||
// job-level indentation handles the last-step case, which is exactly where
|
||||
// `Build and push` sits.
|
||||
func stepBody(t *testing.T, yaml, name string) string {
|
||||
t.Helper()
|
||||
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||
i := strings.Index(yaml, marker)
|
||||
i := strings.Index(yaml, "- name: "+name)
|
||||
if i < 0 {
|
||||
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
|
||||
t.Fatalf("no %q step in release.yml", name)
|
||||
}
|
||||
rest := yaml[i+len(marker):]
|
||||
if j := strings.Index(rest, "\n else"); j >= 0 {
|
||||
return rest[:j]
|
||||
body := yaml[i:]
|
||||
end := len(body)
|
||||
if j := strings.Index(body, "\n - name:"); j >= 0 {
|
||||
end = j
|
||||
}
|
||||
return rest
|
||||
// A blank line followed by EXACTLY two spaces and then content starts a
|
||||
// new job or job-level comment. The "exactly" matters: a blank line inside
|
||||
// a `run:` block is followed by ten-space indentation and would otherwise
|
||||
// match, truncating the step mid-body — which is how this helper first
|
||||
// sliced the verify step down to its first two lines.
|
||||
if loc := jobBoundary.FindStringIndex(body); loc != nil && loc[0] < end {
|
||||
end = loc[0]
|
||||
}
|
||||
body = body[:end]
|
||||
if strings.TrimSpace(strings.TrimPrefix(body, "- name: "+name)) == "" {
|
||||
t.Fatalf("step %q sliced to an empty body — the assertions below would pass vacuously", name)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// releaseYAML reads the workflow once per test.
|
||||
func releaseYAML(t *testing.T) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// imageTagArms splits "Compute image tags" into its three ref branches. The
|
||||
// whole tag policy lives in that if/elif/else, so the arms are the unit worth
|
||||
// asserting on — a tag published from the wrong arm reaches the wrong
|
||||
// audience, and every such mistake still builds and still pushes a valid
|
||||
// image.
|
||||
func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) {
|
||||
t.Helper()
|
||||
const (
|
||||
tagMarker = `if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then`
|
||||
devMarker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||
mainMarker = "\n else"
|
||||
endMarker = "\n fi"
|
||||
)
|
||||
iTag := strings.Index(yaml, tagMarker)
|
||||
iDev := strings.Index(yaml, devMarker)
|
||||
iMain := -1
|
||||
if iDev >= 0 {
|
||||
if k := strings.Index(yaml[iDev:], mainMarker); k >= 0 {
|
||||
iMain = iDev + k
|
||||
}
|
||||
}
|
||||
if iTag < 0 || iDev < 0 || iMain < 0 {
|
||||
t.Fatal("Compute image tags no longer has a tag/dev/main arm — the tag policy has been restructured, so these guards are pinning nothing")
|
||||
}
|
||||
iEnd := iMain
|
||||
if k := strings.Index(yaml[iMain:], endMarker); k >= 0 {
|
||||
iEnd = iMain + k
|
||||
} else {
|
||||
t.Fatal("no closing fi after the main arm")
|
||||
}
|
||||
return yaml[iTag:iDev], yaml[iDev:iMain], yaml[iMain:iEnd]
|
||||
}
|
||||
|
||||
// The worst regression this wiring can produce: a dev push that also moves
|
||||
@@ -198,11 +266,7 @@ func devArm(t *testing.T, yaml string) string {
|
||||
// next pull. Nothing else in the suite would notice — the build stays green
|
||||
// and the image is valid, it is simply the wrong audience.
|
||||
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arm := devArm(t, string(body))
|
||||
_, arm, _ := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:dev") {
|
||||
t.Errorf("dev arm does not publish :dev\n%s", arm)
|
||||
@@ -286,3 +350,252 @@ 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)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("x\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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": ServerVersion,
|
||||
"channel": ServerChannel,
|
||||
"min_client_version": MinClientVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,12 +5,27 @@ package server
|
||||
// older clients see version_too_old at /healthz and refuse to operate.
|
||||
const MinClientVersion = "0.1.0"
|
||||
|
||||
// ServerVersion is the deployed server image's version tag. Defaults to
|
||||
// "dev" for local builds; overridden at link time via:
|
||||
// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived
|
||||
// from the commit it was built from by ci/version.sh. Defaults to "dev" for
|
||||
// local builds; overridden at link time via:
|
||||
//
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=v2026.05.10.2'"
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'"
|
||||
//
|
||||
// release.yml passes the git tag through MINSTREL_VERSION build-arg →
|
||||
// Dockerfile ldflag. Surfaced at /healthz so operators can verify which
|
||||
// image their container is running without exec'ing into it.
|
||||
// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
|
||||
// ldflag. Surfaced at /healthz so operators can verify which image their
|
||||
// container is running without exec'ing into it.
|
||||
//
|
||||
// This carried the literal strings "main" and "dev" until 2026-09-10, which
|
||||
// made every image on a channel report the same thing forever. It stopped
|
||||
// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule
|
||||
// 145): this is now the ONLY thing that says which build is running.
|
||||
var ServerVersion = "dev"
|
||||
|
||||
// ServerChannel is which line this build came off — "stable" or "dev", or
|
||||
// "local" for a plain `docker build`.
|
||||
//
|
||||
// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The
|
||||
// same commit built on both lanes reports the same version and differs only
|
||||
// here; folding the two together is what makes a version string stop being
|
||||
// comparable.
|
||||
var ServerChannel = "local"
|
||||
|
||||
@@ -6,26 +6,36 @@
|
||||
// is actually running (came up debugging the in-app update flow when
|
||||
// it wasn't obvious whether v2026.05.10.0 or .1 was deployed).
|
||||
//
|
||||
// This is now the ONLY place an operator can see which build they are on.
|
||||
// Image tags stopped carrying the version on 2026-09-10 — :latest and :dev
|
||||
// are rolling names and :<sha> answers "which commit", not "which build" —
|
||||
// so the server's self-report is the answer.
|
||||
//
|
||||
// The channel is shown BESIDE the version, never spliced into it: the same
|
||||
// commit built on both lanes reports an identical version and differs only
|
||||
// in channel, so "2026.09.10.1449 · dev" and "2026.09.10.1449 · stable" are
|
||||
// the same code on two lines. Suppressed for stable, which is the
|
||||
// unremarkable case and would just be noise on every install.
|
||||
//
|
||||
// /healthz is unauthenticated, so the bare fetch works without
|
||||
// credentials. Renders nothing on parse failure or pre-version
|
||||
// images that don't include the field — graceful degradation.
|
||||
|
||||
type Health = { status: string; version?: string };
|
||||
type Health = { status: string; version?: string; channel?: string };
|
||||
|
||||
let version = $state<string | null>(null);
|
||||
let channel = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/healthz');
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as Partial<Health>;
|
||||
if (body.version && body.version !== 'dev') {
|
||||
version = body.version;
|
||||
} else if (body.version === 'dev') {
|
||||
// Local dev images report "dev" — show it so the operator
|
||||
// can tell they're not on a release tag.
|
||||
version = 'dev';
|
||||
}
|
||||
if (!body.version) return;
|
||||
version = body.version;
|
||||
// Reported verbatim rather than validated against an enum — a build
|
||||
// claiming something unexpected is better shown than dropped.
|
||||
channel = body.channel && body.channel !== 'stable' ? body.channel : null;
|
||||
} catch {
|
||||
// network / parse error — silent.
|
||||
}
|
||||
@@ -33,5 +43,7 @@
|
||||
</script>
|
||||
|
||||
{#if version}
|
||||
<p class="text-xs text-text-secondary">Server {version}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
Server {version}{#if channel} · {channel}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user