Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+5
-19
@@ -6,20 +6,9 @@
|
|||||||
**/build
|
**/build
|
||||||
web/build
|
web/build
|
||||||
|
|
||||||
# The Android client — built by its own job, never from this context. The APK
|
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||||
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||||
# here reads android/ sources.
|
# `COPY . .` layer cache on every Flutter-only change.
|
||||||
#
|
|
||||||
# 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/
|
||||||
@@ -37,8 +26,5 @@ 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/
|
||||||
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
|
.github/
|
||||||
# Actions reads `.gitea/`, so the one directory that actually exists was the
|
|
||||||
# one not excluded, and every workflow edit invalidated the context.
|
|
||||||
.gitea/
|
|
||||||
|
|||||||
@@ -80,12 +80,15 @@ 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'
|
||||||
# Stock action: it works on this forge since the runner moved to
|
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||||
# success while Gitea serves artifacts back only through the v4 API, and
|
# serves artifacts back only through the v4 API, so the upload is stored
|
||||||
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
|
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||||
uses: actions/upload-artifact@v7
|
# 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
|
||||||
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
|
||||||
|
|||||||
+49
-115
@@ -2,31 +2,11 @@ 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 → :latest + :<sha> (latest-release APK bundled)
|
# push to main → :main and :latest (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# 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
|
||||||
@@ -232,12 +212,13 @@ 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
|
||||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||||
# will never serve back. This is the producing half of a pair:
|
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||||
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||||
# any download v4+ on this forge (every combination tested 2026-09-10,
|
# below with the matching download-artifact mirror. Both must stay on
|
||||||
# Scribe spike #3843), so the two pins need not move together.
|
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||||
uses: actions/upload-artifact@v7
|
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||||
|
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
|
||||||
@@ -331,16 +312,11 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# Full history, and rule 149 names this specifically: any job that
|
# Shallow is fine here. This job used to need full history + tags to
|
||||||
# DERIVES the version name needs it, because a shallow clone changes
|
# re-derive the bundled APK's version from the tagged commit; it now
|
||||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
# downloads the sidecar the release recorded, and touches git for
|
||||||
# value, silently, with every lane green.
|
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
|
||||||
#
|
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
|
||||||
@@ -358,68 +334,30 @@ 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
|
||||||
# A release refreshes the CHANNEL and mints nothing else.
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
#
|
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
# The tag build exists to produce the signed APK and attach it to
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
# the release; the image it rebuilds is the SAME SOURCE as the main
|
echo "::notice::Release build: ${VERSION} + latest"
|
||||||
# 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 "::notice::Dev-branch build ${VERSION}: :dev"
|
echo "version=dev" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Dev-branch build: :dev"
|
||||||
else
|
else
|
||||||
# The production line: :latest tracks main's tip (rule 147) and
|
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||||
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
# rolling stable channel — every main push moves :latest.
|
||||||
# the family's other repos, so a rollback target is addressable
|
# Pinned consumers can target :vYYYY.MM.DD.HHMM, which never
|
||||||
# straight from the commit anyone is reading.
|
# moves; everyone else gets the newest main.
|
||||||
CHANNEL=stable
|
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
echo "::notice::Main-branch build: :main + :latest"
|
||||||
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
|
||||||
@@ -434,11 +372,20 @@ 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: stock download-artifact, which works here for
|
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
# same reason: upstream's client-side GHES check rejects this hostname
|
||||||
# out of the bundle; snippet #2271). v8 runs on node24, which every
|
# before it connects. bvandeusen/download-artifact mirrors
|
||||||
# CI-runner image carries — the runner uses the image's own node.
|
# code.forgejo.org/forgejo/download-artifact.
|
||||||
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/
|
||||||
@@ -531,7 +478,6 @@ 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
|
||||||
@@ -542,8 +488,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 image,
|
# obviously wrong. The release was simply missing its APK and its immutable
|
||||||
# which is easy to skim past.
|
# `:vYYYY.MM.DD` image, 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
|
||||||
@@ -594,30 +540,18 @@ 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}:${GITHUB_SHA}" > /dev/null 2>&1; then
|
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
||||||
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
|
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::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::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||||
|
|||||||
@@ -12,11 +12,6 @@
|
|||||||
# 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
|
||||||
|
|||||||
+4
-13
@@ -15,21 +15,12 @@ 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 DERIVED version name
|
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||||
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||||
# to "dev"/"local". Both are surfaced at /healthz.
|
# /healthz for operator-side image-version verification.
|
||||||
#
|
|
||||||
# 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 \
|
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
|
||||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
|
||||||
-o /out/minstrel ./cmd/minstrel
|
-o /out/minstrel ./cmd/minstrel
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -112,21 +112,18 @@ 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` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||||
- `:<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.
|
- `: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.
|
||||||
- `: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.
|
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||||
|
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||||
|
|
||||||
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
|
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.
|
||||||
|
|
||||||
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 up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
|
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
|
||||||
|
|
||||||
## Specs
|
## Specs
|
||||||
|
|
||||||
@@ -160,7 +157,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 the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||||
|
|
||||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||||
|
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
+47
-20
@@ -62,30 +62,57 @@ 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 — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
|
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||||
```yaml
|
```yaml
|
||||||
uses: actions/upload-artifact@v7
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
uses: actions/download-artifact@v8
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||||
```
|
```
|
||||||
Stock works on this forge since the runner moved to gitea/runner 3.x, which
|
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||||
edits the actions' client-side `isGhes()` refusal out of their bundles. Proven
|
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||||
on 2026-09-10 for upload v4–v7 and download v4–v8 (Scribe spike #3843). Until
|
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||||
then this repo pinned SHA mirrors of the Forgejo project's forks, because
|
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||||
upstream threw on the hostname before it opened a connection (Scribe 2255).
|
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.
|
||||||
|
|
||||||
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
Both are pull mirrors of the Forgejo project's forks
|
||||||
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||||
stored but invisible to every retrieval path. That is how 72 unreachable
|
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||||
artifacts accumulated on this repo (Scribe 2270).
|
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||||
|
otherwise silently change what runs.
|
||||||
|
|
||||||
**Pairing no longer needs managing.** This entry used to pin upload v5 against
|
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||||
download v6 so both bundled `@actions/artifact` ^4.0.0, warning that a mismatch
|
numbers.** The two actions release on unrelated cadences, so equal version
|
||||||
across `release.yml`'s producer/consumer pair would list empty. Tested, and not
|
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||||
true on this instance: every download major v4–v8 read the artifacts of every
|
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||||
upload major v4–v7, by name and by pattern (CI-runner run 6312). The only real
|
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||||
protocol break is v3 → v4. node24 is no longer a concern either — every
|
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||||
CI-runner image carries Node 24 and the runner runs actions with the image's
|
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||||
`node`.
|
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.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+2
-77
@@ -2,8 +2,7 @@
|
|||||||
#
|
#
|
||||||
# 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 timestamp of the newest
|
# name=YYYY.MM.DD.HHMM label for people, from the COMMIT's timestamp
|
||||||
# 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
|
||||||
#
|
#
|
||||||
@@ -34,83 +33,9 @@ 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}" -- "${SHIPPED[@]}")"
|
commit_epoch="$(git log --format=%ct -1 "${REF}")"
|
||||||
# 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)}"
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -179,86 +178,19 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobBoundary matches a blank line followed by job-level (two-space)
|
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
|
||||||
// indentation — the end of the last step in a job.
|
func devArm(t *testing.T, yaml string) string {
|
||||||
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()
|
||||||
i := strings.Index(yaml, "- name: "+name)
|
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||||
|
i := strings.Index(yaml, marker)
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
t.Fatalf("no %q step in release.yml", name)
|
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
|
||||||
}
|
}
|
||||||
body := yaml[i:]
|
rest := yaml[i+len(marker):]
|
||||||
end := len(body)
|
if j := strings.Index(rest, "\n else"); j >= 0 {
|
||||||
if j := strings.Index(body, "\n - name:"); j >= 0 {
|
return rest[:j]
|
||||||
end = j
|
|
||||||
}
|
}
|
||||||
// A blank line followed by EXACTLY two spaces and then content starts a
|
return rest
|
||||||
// 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
|
||||||
@@ -266,7 +198,11 @@ func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) {
|
|||||||
// next pull. Nothing else in the suite would notice — the build stays green
|
// 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) {
|
||||||
_, arm, _ := imageTagArms(t, releaseYAML(t))
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
arm := devArm(t, string(body))
|
||||||
|
|
||||||
if !strings.Contains(arm, "${IMAGE}:dev") {
|
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)
|
||||||
@@ -350,369 +286,3 @@ func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rollback unit, and the reason it is worth a guard: it is invisible until
|
|
||||||
// the moment it is needed. Nothing pulls :<sha> during normal operation, so if
|
|
||||||
// this arm stopped minting one, every build would stay green and every image
|
|
||||||
// would be valid — and the absence would surface only during an incident, as
|
|
||||||
// "there is nothing to roll back to."
|
|
||||||
//
|
|
||||||
// Rules 145 and 147: main push → :latest + :<sha>.
|
|
||||||
func TestMainChannel_PublishesLatestAndTheRollbackUnit(t *testing.T) {
|
|
||||||
_, _, arm := imageTagArms(t, releaseYAML(t))
|
|
||||||
|
|
||||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
|
||||||
t.Errorf("main arm does not move :latest — production would stop tracking main's tip\n%s", arm)
|
|
||||||
}
|
|
||||||
if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") {
|
|
||||||
t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm)
|
|
||||||
}
|
|
||||||
// Rule 147: :latest tracks main's tip, and a second name for the same
|
|
||||||
// image sends readers looking for a distinction that does not exist.
|
|
||||||
if strings.Contains(arm, "${IMAGE}:main") {
|
|
||||||
t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A release refreshes the CHANNEL and mints nothing else (rules 145 + 146).
|
|
||||||
//
|
|
||||||
// The specific regression: re-adding :<sha> here. The tag build rebuilds the
|
|
||||||
// SAME SOURCE as main's build minutes earlier, differing only in which APK is
|
|
||||||
// baked in — so a :<sha> minted here would overwrite main's immutable rollback
|
|
||||||
// target with different contents, under the same name. That is the exact thing
|
|
||||||
// rule 145's immutability clause exists to prevent, and it is the half with
|
|
||||||
// the incidents behind it.
|
|
||||||
func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
|
|
||||||
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
|
||||||
|
|
||||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
|
||||||
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
|
|
||||||
}
|
|
||||||
if strings.Contains(arm, "GITHUB_SHA") {
|
|
||||||
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No version-numbered image tags anywhere, on any arm (rule 145, and the
|
|
||||||
// operator's 2026-09-10 decision to drop them across every project).
|
|
||||||
//
|
|
||||||
// Asserted across the whole step rather than per-arm because the mistake this
|
|
||||||
// catches is re-adding one ANYWHERE, and the tag arm is only the likeliest
|
|
||||||
// spot. `${VERSION}` still legitimately appears in the step as the build's
|
|
||||||
// self-reported version, so the assertion has to name the image-tag form
|
|
||||||
// specifically rather than the variable — otherwise it would fire on correct
|
|
||||||
// code and get "fixed" by deleting the guard.
|
|
||||||
func TestNoVersionNumberedImageTags(t *testing.T) {
|
|
||||||
yaml := releaseYAML(t)
|
|
||||||
tagArm, devArm, mainArm := imageTagArms(t, yaml)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, arm string }{
|
|
||||||
{"tag", tagArm}, {"dev", devArm}, {"main", mainArm},
|
|
||||||
} {
|
|
||||||
for _, forbidden := range []string{
|
|
||||||
"${IMAGE}:${VERSION}",
|
|
||||||
"${IMAGE}:v",
|
|
||||||
"${IMAGE}:${GITHUB_REF#refs/tags/}",
|
|
||||||
} {
|
|
||||||
if strings.Contains(tc.arm, forbidden) {
|
|
||||||
t.Errorf("%s arm publishes a version-numbered image tag (%q); git and the build's self-reported version answer \"which build is this\"\n%s",
|
|
||||||
tc.name, forbidden, tc.arm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The verify job asserted the :<version> image existed. With version tags
|
|
||||||
// gone that assertion would fail every release for a tag nothing mints — so
|
|
||||||
// this pins that it was re-pointed rather than deleted, since deleting it is
|
|
||||||
// the tempting way to make a failing guard go green.
|
|
||||||
func TestVerifyJob_ChecksTheRollbackImageNotAVersionTag(t *testing.T) {
|
|
||||||
yaml := releaseYAML(t)
|
|
||||||
|
|
||||||
if !strings.Contains(yaml, "- name: Rollback image must exist for the tagged commit") {
|
|
||||||
t.Fatal("the release-verification step that checks an image exists is gone; an image push that silently did not happen would now pass verification")
|
|
||||||
}
|
|
||||||
step := stepBody(t, yaml, "Rollback image must exist for the tagged commit")
|
|
||||||
if !strings.Contains(step, "${IMAGE}:${GITHUB_SHA}") {
|
|
||||||
t.Errorf("the verify step does not inspect the commit's rollback image\n%s", step)
|
|
||||||
}
|
|
||||||
if strings.Contains(step, "${IMAGE}:${TAG}") {
|
|
||||||
t.Errorf("the verify step still inspects a version-numbered image, which is no longer published — this would fail every release\n%s", step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server's self-reported version is now the ONLY thing that identifies a
|
|
||||||
// build, so a lane that stamps a channel word instead of a version silently
|
|
||||||
// removes that ability. It used to stamp the literal "main"/"dev".
|
|
||||||
func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
|
|
||||||
yaml := releaseYAML(t)
|
|
||||||
|
|
||||||
step := stepBody(t, yaml, "Build and push")
|
|
||||||
for _, want := range []string{
|
|
||||||
"MINSTREL_VERSION=",
|
|
||||||
"MINSTREL_CHANNEL=",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(step, want) {
|
|
||||||
t.Errorf("Build and push does not pass %s — the image cannot report which build it is\n%s", want, step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The version must come from the shared derivation, not from the ref.
|
|
||||||
// Reading it off GITHUB_REF is what produced "main" and "dev" as version
|
|
||||||
// strings, which is the regression this pins.
|
|
||||||
_, _, mainArm := imageTagArms(t, yaml)
|
|
||||||
if strings.Contains(mainArm, `version=main`) {
|
|
||||||
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
|
|
||||||
}
|
|
||||||
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
|
|
||||||
t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"version": ServerVersion,
|
"version": ServerVersion,
|
||||||
"channel": ServerChannel,
|
|
||||||
"min_client_version": MinClientVersion,
|
"min_client_version": MinClientVersion,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,12 @@ 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 build's own version name — YYYY.MM.DD.HHMM, derived
|
// ServerVersion is the deployed server image's version tag. Defaults to
|
||||||
// from the commit it was built from by ci/version.sh. Defaults to "dev" for
|
// "dev" for local builds; overridden at link time via:
|
||||||
// local builds; overridden at link time via:
|
|
||||||
//
|
//
|
||||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'"
|
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=v2026.05.10.2'"
|
||||||
//
|
//
|
||||||
// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
|
// release.yml passes the git tag through MINSTREL_VERSION build-arg →
|
||||||
// ldflag. Surfaced at /healthz so operators can verify which image their
|
// Dockerfile ldflag. Surfaced at /healthz so operators can verify which
|
||||||
// container is running without exec'ing into it.
|
// 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"
|
var ServerVersion = "dev"
|
||||||
|
|
||||||
// ServerChannel is which line this build came off — "stable" or "dev", or
|
|
||||||
// "local" for a plain `docker build`.
|
|
||||||
//
|
|
||||||
// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The
|
|
||||||
// same commit built on both lanes reports the same version and differs only
|
|
||||||
// here; folding the two together is what makes a version string stop being
|
|
||||||
// comparable.
|
|
||||||
var ServerChannel = "local"
|
|
||||||
|
|||||||
@@ -6,36 +6,26 @@
|
|||||||
// is actually running (came up debugging the in-app update flow when
|
// 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; channel?: string };
|
type Health = { status: string; version?: 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) return;
|
if (body.version && body.version !== 'dev') {
|
||||||
version = body.version;
|
version = body.version;
|
||||||
// Reported verbatim rather than validated against an enum — a build
|
} else if (body.version === 'dev') {
|
||||||
// claiming something unexpected is better shown than dropped.
|
// Local dev images report "dev" — show it so the operator
|
||||||
channel = body.channel && body.channel !== 'stable' ? body.channel : null;
|
// can tell they're not on a release tag.
|
||||||
|
version = 'dev';
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// network / parse error — silent.
|
// network / parse error — silent.
|
||||||
}
|
}
|
||||||
@@ -43,7 +33,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if version}
|
{#if version}
|
||||||
<p class="text-xs text-text-secondary">
|
<p class="text-xs text-text-secondary">Server {version}</p>
|
||||||
Server {version}{#if channel} · {channel}{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user