docs: workflow, Dockerfile and brand-tool comments say what each does (#6277)
release.yml, the Dockerfile and tools/gen-brand-assets.py keep every constraint their comments explain (the tag map, the gate, immutable tags, the shared-daemon Postgres guard, the version/channel split) and drop the history around them: rule and family-idea numbers, issue and run ids, dates, an operator quote, the "this used to say the opposite" paragraphs and one-runner timings. Corrected: the Dockerfile said client/ is filled on tag pushes only; every build fills it (dev and tag push bundle their own APK, main bundles the latest release's). Only full-line `#` comments changed: non-comment lines are identical to HEAD in all three files, and the three run: scripts whose text changed differ only in shell comment lines. The release guard tests match raw workflow text; none of their required or forbidden strings appear in the new comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+116
-153
@@ -7,45 +7,39 @@ name: release
|
||||
# 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.
|
||||
# That is the whole tag map.
|
||||
#
|
||||
# :<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.
|
||||
# actually worth having: merges to main are deliberate, 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 are NO :<version> image tags. 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. Only the APK needs version numbers, for
|
||||
# its 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
|
||||
# There is no :main either. :latest tracks main's tip with no gate between
|
||||
# them, 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
|
||||
# freshly-built APK, signed with the SAME key as release builds — a different
|
||||
# key cannot install over the stable app, so anyone crossing channels would
|
||||
# have to uninstall and lose their data.
|
||||
# The dev channel exists so testing a build does not require shipping one:
|
||||
# without it the only way to get an APK onto a phone is to cut a release,
|
||||
# which makes `main` the staging area. `:dev` carries its own freshly-built
|
||||
# APK, signed with the SAME key as release builds — a different key cannot
|
||||
# install over the stable app, so anyone crossing channels would have to
|
||||
# uninstall and lose their data.
|
||||
#
|
||||
# :dev is published ALONE, with no per-commit tag. A rolling channel is
|
||||
# rolling by definition; a commit-addressable image for it would be a
|
||||
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
|
||||
# forward.
|
||||
#
|
||||
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
|
||||
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
|
||||
# the family because the app and the server live in separate repos. Minstrel
|
||||
# is a monorepo — one push builds the APK and the image in the same run from
|
||||
# the same commit, so the channel cannot go stale against its own artifact.
|
||||
# The requirement is satisfied structurally; copying the mechanism would add
|
||||
# a moving part to fix a problem that does not exist here.
|
||||
# No cross-repo dispatch is needed to refresh a channel when its bundled APK
|
||||
# is rebuilt: Minstrel is a monorepo, so one push builds the APK and the
|
||||
# image in the same run from the same commit, and the channel cannot go
|
||||
# stale against its own artifact.
|
||||
#
|
||||
# Release model: the tag IS the artifact's version name with a `v` in front.
|
||||
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
||||
@@ -55,15 +49,9 @@ name: release
|
||||
#
|
||||
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
|
||||
# same-day second release is not a collision — HHMM makes every tag unique
|
||||
# by construction, so the answer is simply another tag.
|
||||
#
|
||||
# This block used to say the opposite: that the per-day tag was
|
||||
# "intentionally mutable" and that a same-day re-cut should
|
||||
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
|
||||
# rulebook now forbids outright, and it has incidents behind it — moving a
|
||||
# same-day tag forward once took a published release down with it. Anyone
|
||||
# installing from a tag is holding something the tag no longer points at,
|
||||
# which is a worse failure than an extra row in the tag list.
|
||||
# by construction, so the answer is simply another tag. Anyone installing
|
||||
# from a moved tag would hold something the tag no longer points at, which
|
||||
# is a worse failure than an extra row in the tag list.
|
||||
#
|
||||
# :latest is updated by every main push AND every tag push, so it always
|
||||
# reflects the newest blessed image.
|
||||
@@ -83,15 +71,14 @@ name: release
|
||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||
# keep bundling their own freshly-built APK.
|
||||
#
|
||||
# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file —
|
||||
# THE GATE. Every verifying lane lives in this file —
|
||||
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
|
||||
# govulncheck — and every job that publishes something names each of them in
|
||||
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
|
||||
#
|
||||
# They used to be three separate workflows (test-go, test-web, android) on the
|
||||
# same push trigger as this one. Separate workflows cannot see each other's
|
||||
# verdict, so :dev meant "it built", never "it passed": a red test run and a
|
||||
# One workflow, because separate workflows cannot see each other's verdict:
|
||||
# :dev would mean "it built", never "it passed", and a red test run and a
|
||||
# fresh :dev could carry the same timestamp. One graph is the only place the
|
||||
# edge can be written.
|
||||
#
|
||||
@@ -125,10 +112,9 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# A rapid re-push to main should supersede the in-flight build — the
|
||||
# operator explicitly wants the later commit to win. Tags no longer enter
|
||||
# into this: they are immutable and unique, so no tag build can ever be
|
||||
# superseded by another run on the same ref.
|
||||
# A rapid re-push to main supersedes the in-flight build: the later commit
|
||||
# wins. Tags are immutable and unique, so no tag build can be superseded by
|
||||
# another run on the same ref.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -173,14 +159,14 @@ jobs:
|
||||
# Full `go test -race` against an ephemeral Postgres.
|
||||
#
|
||||
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker
|
||||
# daemon also runs the operator's dev compose stack, so service containers
|
||||
# get NO published ports (collision) and no service-name DNS. We discover the
|
||||
# daemon also runs a dev compose stack, so service containers get NO
|
||||
# published ports (collision) and no service-name DNS. We discover the
|
||||
# service container through the mounted docker socket and reach it by bridge
|
||||
# IP. The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||
# Postgres would truncate it.
|
||||
#
|
||||
# The key stays `integration` with no `name:` (rule 80): act_runner derives
|
||||
# the service container's name from the job's display name.
|
||||
# The key stays `integration` with no `name:`: act_runner derives the
|
||||
# service container's name from the job's display name.
|
||||
#
|
||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||
# without the SPA being built first.
|
||||
@@ -195,7 +181,7 @@ jobs:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel_test
|
||||
# No `ports:` — the runner shares the operator's dev compose
|
||||
# No `ports:` — the runner shares the dev compose
|
||||
# Docker daemon; publishing a fixed host port collides.
|
||||
|
||||
steps:
|
||||
@@ -209,13 +195,13 @@ jobs:
|
||||
# mounted docker socket. act_runner attaches the job
|
||||
# container and its service container(s) to a shared per-job
|
||||
# network, so scope discovery to a postgres that sits on a
|
||||
# network THIS job container is also on. The old
|
||||
# `--filter name=integration` matched EVERY concurrent
|
||||
# integration run's postgres (a dev push + the main-merge run
|
||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||
# this job's network; skip it explicitly as belt-and-suspenders
|
||||
# (a wrong target would truncate real data).
|
||||
# network THIS job container is also on. A name filter alone
|
||||
# would match EVERY concurrent integration run's postgres (a dev
|
||||
# push and a main-merge run overlapping give 2 candidates and a
|
||||
# false "expected exactly 1" abort). The dev compose
|
||||
# `minstrel-postgres-*` is never on this job's network; skip it
|
||||
# explicitly as belt-and-suspenders (a wrong target would
|
||||
# truncate real data).
|
||||
SELF=$(cat /etc/hostname)
|
||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||
test -n "$SELF_NETS"
|
||||
@@ -236,11 +222,11 @@ jobs:
|
||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||
|
||||
# Wait for Postgres to accept connections. Asked of the service
|
||||
# container itself: the run: shell is dash (rule 81), where the
|
||||
# old `/dev/tcp` probe never connects and the loop silently
|
||||
# burned its full two minutes on every run.
|
||||
# container itself: the run: shell is dash, where a `/dev/tcp`
|
||||
# probe never connects and the loop would silently burn its full
|
||||
# two minutes.
|
||||
#
|
||||
# Over TCP (-h 127.0.0.1), not the unix socket (#5426). On first
|
||||
# Over TCP (-h 127.0.0.1), not the unix socket. On first
|
||||
# boot the image's entrypoint runs initdb under a temporary server
|
||||
# that listens on the socket only, then stops it and starts the
|
||||
# real one. A socket probe can catch the temporary server, and the
|
||||
@@ -267,7 +253,7 @@ jobs:
|
||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||
# all three up with no restart.
|
||||
# Non-fatal: a failure degrades to "slower", never red CI. But
|
||||
# slower is ~15x on internal/api (#5426), so retry a few times and
|
||||
# slower is ~15x on internal/api, so retry a few times and
|
||||
# print what took effect.
|
||||
relaxed=""
|
||||
for i in 1 2 3 4 5; do
|
||||
@@ -291,10 +277,10 @@ jobs:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||
# package execution (the documented local invocation too).
|
||||
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
|
||||
# an idle runner, and a dev and a main run sharing the runner
|
||||
# pushed it past go test's default 10m (run 8368, 600.016s, the
|
||||
# running test 2s old — nothing hung).
|
||||
# -timeout 20m: internal/api alone takes several minutes under
|
||||
# -race on an idle runner, and a dev and a main run sharing the
|
||||
# runner can push it past go test's default 10m with nothing
|
||||
# hung.
|
||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||
go test -p 1 -race -timeout 20m ./...
|
||||
|
||||
@@ -314,11 +300,8 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
# The whole tree, build and test tooling included. Until #5021 this
|
||||
# audited only what ships to browsers (`--omit=dev`), because vite,
|
||||
# vitest, tailwind and kit carried advisories that needed major
|
||||
# upgrades. Those upgrades landed and the full tree audits clean, so
|
||||
# the tooling that builds the shipped bundle is held to the same bar.
|
||||
# The whole tree, build and test tooling included: the tooling that
|
||||
# builds the shipped bundle is held to the same bar as the bundle.
|
||||
- name: npm audit (all dependencies)
|
||||
run: npm audit --audit-level=moderate
|
||||
|
||||
@@ -329,8 +312,8 @@ jobs:
|
||||
run: npm test
|
||||
|
||||
android:
|
||||
# Using flutter-ci runner label because it's the only proven-working
|
||||
# label with docker that can pull our container.image.
|
||||
# The flutter-ci runner label is the only proven-working label with
|
||||
# docker that can pull our container.image.
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||
@@ -376,11 +359,10 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
# No debug APK is built or uploaded here. Main used to upload a
|
||||
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
||||
# every container, which no install can update (family idea #5103,
|
||||
# practice 2). Phones get builds from android-release, signed with
|
||||
# the one release key, on dev and on tags.
|
||||
# No debug APK is built or uploaded here: a debug build is signed by a
|
||||
# key regenerated in every container, which no install can update.
|
||||
# Phones get builds from android-release, signed with the one release
|
||||
# key, on dev and on tags.
|
||||
|
||||
# Known vulnerabilities in the Go code and the standard library it is built
|
||||
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
||||
@@ -388,7 +370,7 @@ jobs:
|
||||
# the ci-go image carries its own Go and would be checking a different
|
||||
# toolchain. Keep this image and the Dockerfile's builder in step.
|
||||
#
|
||||
# govulncheck is fetched at CI time, unpinned (rule 154): the vulnerability
|
||||
# govulncheck is fetched at CI time, unpinned: the vulnerability
|
||||
# database and the tool that reads it should both be current.
|
||||
govulncheck:
|
||||
runs-on: go-ci
|
||||
@@ -397,7 +379,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
# Plain git, not actions/checkout: that action runs on node, which the
|
||||
# golang image does not carry. This step is dash (rule 81).
|
||||
# golang image does not carry. This step is dash.
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
@@ -420,7 +402,7 @@ jobs:
|
||||
android-release:
|
||||
name: Build signed APK (releases and dev)
|
||||
# Also builds on `dev`, which is what makes a test channel possible at
|
||||
# all. Without it the only way to get a build onto a phone was to cut a
|
||||
# all: without it the only way to get a build onto a phone is to cut a
|
||||
# release, which quietly turns `main` into the staging area.
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
||||
runs-on: flutter-ci
|
||||
@@ -453,7 +435,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history. The version name now reads only the tip commit's
|
||||
# Full history. The version name reads only the tip commit's
|
||||
# timestamp, so a shallow clone would technically serve — but this
|
||||
# job derives a value that ships to devices, and a shallow checkout
|
||||
# changes what git-derived values resolve to WITHOUT failing. The
|
||||
@@ -487,9 +469,9 @@ jobs:
|
||||
|
||||
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
||||
# Release" below resolves the release by tag and fails if it is absent —
|
||||
# but that is the final step, so a tag pushed without a release built an
|
||||
# APK for several minutes first and only then discovered it had nowhere to
|
||||
# put it. Same check, seconds in instead of minutes.
|
||||
# but that is the final step, so a tag pushed without a release would
|
||||
# build an APK for several minutes first and only then find it has
|
||||
# nowhere to put it. Same check, seconds in instead of minutes.
|
||||
#
|
||||
# Releases are normally created through the API (which creates the tag and
|
||||
# the release together, so this passes). A bare `git push origin vX` is the
|
||||
@@ -549,16 +531,15 @@ jobs:
|
||||
# secret) reaches no installed phone. The certificate's digest is
|
||||
# pinned below; it is public, not a secret. Gradle signs with the
|
||||
# release key or leaves the APK unsigned, and an unsigned build fails
|
||||
# here too, as there is no app-release.apk to verify (family idea
|
||||
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
|
||||
# for a v2-only APK.
|
||||
# here too, as there is no app-release.apk to verify. apksigner, not
|
||||
# keytool: keytool prints nothing for a v2-only APK.
|
||||
#
|
||||
# Rotating the key on purpose means every install must be removed and
|
||||
# reinstalled; change the digest here in the same commit.
|
||||
- name: The APK carries the release key
|
||||
shell: bash
|
||||
env:
|
||||
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
|
||||
# CN=Minstrel, O=FabledSword.
|
||||
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -580,11 +561,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# 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.
|
||||
# Stock action — 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, so the two pins need not move together.
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: minstrel-apk
|
||||
@@ -687,7 +667,7 @@ jobs:
|
||||
|
||||
image-release:
|
||||
name: Build + push container image
|
||||
# Every lane must have SUCCEEDED, each named here (rule 177). Then the
|
||||
# Every lane must have SUCCEEDED, each named here. Then the
|
||||
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||
# pushes skip android-release and bundle the latest release's APK
|
||||
# instead, so for main alone a skipped android-release is expected.
|
||||
@@ -713,15 +693,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history, and rule 149 names this specifically: any job that
|
||||
# DERIVES the version name needs it, because a shallow clone changes
|
||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||
# value, silently, with every lane green.
|
||||
# Full history: 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.
|
||||
# This job runs ci/version.sh itself: with no :<version> image tags,
|
||||
# the server's self-reported version is the only thing that says which
|
||||
# build an image is.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect buildable project
|
||||
@@ -743,21 +721,16 @@ jobs:
|
||||
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).
|
||||
# branch decides the CHANNEL, never the version.
|
||||
#
|
||||
# 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.
|
||||
# A per-ref string ("main", "dev", the tag name) would not order, and
|
||||
# "dev" would be the same string forever — two dev images eight weeks
|
||||
# apart indistinguishable in the UI. With no version image tags, this
|
||||
# is how an admin 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.
|
||||
# kill the step at the assignment under the runner's pipefail.
|
||||
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"
|
||||
@@ -770,13 +743,13 @@ jobs:
|
||||
# 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.
|
||||
# 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).
|
||||
# indefinitely — a channel that cannot refresh itself.
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||
@@ -790,10 +763,9 @@ jobs:
|
||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||
else
|
||||
# The production line: :latest tracks main's tip (rule 147) and
|
||||
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
||||
# the family's other repos, so a rollback target is addressable
|
||||
# straight from the commit anyone is reading.
|
||||
# The production line: :latest tracks main's tip and :<sha> is
|
||||
# the rollback unit. Full 40-char SHA, 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}"
|
||||
@@ -818,8 +790,8 @@ jobs:
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# 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.
|
||||
# out of the bundle). 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
|
||||
@@ -887,10 +859,10 @@ jobs:
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
|
||||
# Take the version the release RECORDED rather than recomputing it.
|
||||
# This used to re-derive the name from the tagged commit, which meant
|
||||
# the formula lived in two files that had to be kept in step, and it
|
||||
# could only ever recover the name — the ordering key is build-time
|
||||
# minutes and does not exist anywhere after that build ends.
|
||||
# Re-deriving it from the tagged commit would keep the formula in two
|
||||
# files that must stay in step, and could only ever recover the name —
|
||||
# the ordering key is build-time minutes and exists nowhere after that
|
||||
# build ends.
|
||||
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
|
||||
if [ -n "${SIDECAR_URL}" ]; then
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
||||
@@ -924,18 +896,15 @@ jobs:
|
||||
# Verifies a tag release actually ended up complete, and names the specific
|
||||
# thing that's missing if not.
|
||||
#
|
||||
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
|
||||
# started — no log was written at all — so all eight of its steps reported
|
||||
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||
# already moved `:latest`, so the code was deployable and nothing looked
|
||||
# obviously wrong. The release was simply missing its APK and its image,
|
||||
# which is easy to skim past.
|
||||
# A release can be incomplete while looking fine. If android-release never
|
||||
# starts (a runner failing to launch), its steps all report `failure` with
|
||||
# none executed and image-release shows `skipped`. The run is red, but the
|
||||
# release page renders and main's own push build has already moved
|
||||
# `:latest`, so a release missing its APK and its image 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
|
||||
# explicit, named error instead of eight mystery step failures — so the
|
||||
# consequence is legible without having to infer it.
|
||||
# This job cannot prevent that; it turns an incomplete release into an
|
||||
# explicit, named error instead of a row of mystery step failures.
|
||||
#
|
||||
# `if: always()` is the whole point: it has to report precisely when the jobs
|
||||
# above did NOT succeed.
|
||||
@@ -977,22 +946,16 @@ jobs:
|
||||
echo "::notice::APK attached: ${APK}"
|
||||
|
||||
# The other half. Checking only the APK would report success on a release
|
||||
# whose image push failed — which is precisely the second thing that was
|
||||
# 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.
|
||||
# whose image push failed. `always()` on this job means it runs even when
|
||||
# image-release failed, so without this the guard would cheerfully verify
|
||||
# an incomplete release.
|
||||
#
|
||||
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
||||
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
||||
# that assertion would now fail every release for a tag nothing mints.
|
||||
# The rollback target it was really protecting is the :<sha> image, which
|
||||
# main's own build published for this same commit before the tag was cut.
|
||||
#
|
||||
# Checking it here earns its keep twice over: it still catches an image
|
||||
# push that silently did not happen, and it additionally proves the
|
||||
# ORDERING — a tag cut on a commit whose main build never completed has
|
||||
# no rollback target, and that is worth failing on rather than
|
||||
# discovering during an incident.
|
||||
# The image checked is the :<sha> rollback target, which main's own build
|
||||
# published for this same commit before the tag was cut (there are no
|
||||
# version image tags). It catches an image push that silently did not
|
||||
# happen, and it 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: |
|
||||
|
||||
+12
-11
@@ -19,11 +19,10 @@ ENV CGO_ENABLED=0
|
||||
# (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.
|
||||
# These are two values on purpose: 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 would make two dev images eight weeks apart
|
||||
# indistinguishable.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
ARG MINSTREL_CHANNEL=local
|
||||
RUN go build -trimpath \
|
||||
@@ -36,8 +35,8 @@ FROM debian:bookworm-slim
|
||||
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
||||
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
||||
# acoustic fingerprint that tells the same recording at two bitrates apart
|
||||
# from two different recordings (M400). Both are baked in at build time so a
|
||||
# deployed instance never fetches either (rule 164); fpcalc is shelled out
|
||||
# from two different recordings. Both are baked in at build time so a
|
||||
# deployed instance never fetches either; fpcalc is shelled out
|
||||
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
||||
@@ -57,10 +56,12 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
|
||||
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
||||
WORKDIR /app
|
||||
|
||||
# In-app update channel (#397). client/ in the build context holds
|
||||
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
|
||||
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
|
||||
# APK files aren't present, so non-tag images degrade gracefully.
|
||||
# In-app update channel. client/ in the build context holds
|
||||
# minstrel.apk + minstrel.apk.version, which release.yml fills on every
|
||||
# build (dev and tag pushes bundle their own APK, main bundles the latest
|
||||
# release's); a local build has only .gitkeep + README. Endpoints return
|
||||
# 404 when the APK files aren't present, so such an image degrades
|
||||
# gracefully.
|
||||
COPY --chown=minstrel:minstrel client/ /app/client/
|
||||
|
||||
USER minstrel
|
||||
|
||||
@@ -180,11 +180,10 @@ def main():
|
||||
" Literal colours, for any consumer that cannot inline the SVG and\n"
|
||||
" therefore cannot supply a currentColor. -->\n"))
|
||||
|
||||
# The tab icon is the full mark too (#5267). A hand-drawn reduced hat used
|
||||
# to stand in at tab size because the art loses detail at 16px, but a
|
||||
# redrawing reads as a different logo. The full mark keeps the logo's own
|
||||
# shape, and a tab on a high-resolution screen draws it from 32px, where it
|
||||
# holds. Do not swap a redrawn form back in here.
|
||||
# The tab icon is the full mark too. A hand-drawn reduced form would lose
|
||||
# less detail at 16px, but a redrawing reads as a different logo; the full
|
||||
# mark keeps the logo's own shape, and a tab on a high-resolution screen
|
||||
# draws it from 32px, where it holds. Do not swap a redrawn form in here.
|
||||
(ROOT / "web/static/brand/favicon.svg").write_text(svg_doc(
|
||||
vb, tr, "currentColor", ACCENT, hat_d, notes_d,
|
||||
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
|
||||
|
||||
Reference in New Issue
Block a user