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)
|
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# That is the whole tag map, and it is family rule 145 + 147 as written.
|
# That is the whole tag map.
|
||||||
#
|
#
|
||||||
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
||||||
# without a release ceremony. It is minted only on main, where rollback is
|
# 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
|
# actually worth having: merges to main are deliberate, so they number in the
|
||||||
# per year, while on dev they would be one per push, forever, for a channel
|
# dozens per year, while on dev they would be one per push, forever, for a
|
||||||
# whose entire contract is that it moves.
|
# channel whose entire contract is that it moves.
|
||||||
#
|
#
|
||||||
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
|
# There are NO :<version> image tags. Git and the build's own self-reported
|
||||||
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
|
# version answer "which build is this"; a third name for the same thing is
|
||||||
# a version tag nobody pinned while the rollback unit the rule names did not
|
# upkeep for a model we do not run. Only the APK needs version numbers, for
|
||||||
# exist here at all. Git and the build's own self-reported version answer
|
# its update process.
|
||||||
# "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
|
# There is no :main either. :latest tracks main's tip with no gate between
|
||||||
# (rule 147), so a second name for the same image sends readers looking for a
|
# them, so a second name for the same image sends readers looking for a
|
||||||
# distinction that does not exist.
|
# 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,
|
# without it the only way to get an APK onto a phone is to cut a release,
|
||||||
# which made `main` the staging area by default. `:dev` carries its own
|
# which makes `main` the staging area. `:dev` carries its own freshly-built
|
||||||
# freshly-built APK, signed with the SAME key as release builds — a different
|
# APK, signed with the SAME key as release builds — a different key cannot
|
||||||
# key cannot install over the stable app, so anyone crossing channels would
|
# install over the stable app, so anyone crossing channels would have to
|
||||||
# have to uninstall and lose their data.
|
# uninstall and lose their data.
|
||||||
#
|
#
|
||||||
# :dev is published ALONE, with no per-commit tag. A rolling channel is
|
# :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
|
# 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
|
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
|
||||||
# forward.
|
# forward.
|
||||||
#
|
#
|
||||||
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
|
# No cross-repo dispatch is needed to refresh a channel when its bundled APK
|
||||||
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
|
# is rebuilt: Minstrel is a monorepo, so one push builds the APK and the
|
||||||
# the family because the app and the server live in separate repos. Minstrel
|
# image in the same run from the same commit, and the channel cannot go
|
||||||
# is a monorepo — one push builds the APK and the image in the same run from
|
# stale against its own artifact.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Release model: the tag IS the artifact's version name with a `v` in front.
|
# 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
|
# `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
|
# 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
|
# same-day second release is not a collision — HHMM makes every tag unique
|
||||||
# by construction, so the answer is simply another tag.
|
# 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
|
||||||
# This block used to say the opposite: that the per-day tag was
|
# is a worse failure than an extra row in the tag list.
|
||||||
# "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.
|
|
||||||
#
|
#
|
||||||
# :latest is updated by every main push AND every tag push, so it always
|
# :latest is updated by every main push AND every tag push, so it always
|
||||||
# reflects the newest blessed image.
|
# reflects the newest blessed image.
|
||||||
@@ -83,15 +71,14 @@ name: release
|
|||||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||||
# keep bundling their own freshly-built APK.
|
# 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
|
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||||
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
|
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
|
||||||
# govulncheck — and every job that publishes something names each of them in
|
# govulncheck — and every job that publishes something names each of them in
|
||||||
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
|
# `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
|
# One workflow, because separate workflows cannot see each other's verdict:
|
||||||
# same push trigger as this one. Separate workflows cannot see each other's
|
# :dev would mean "it built", never "it passed", and a red test run and a
|
||||||
# verdict, so :dev meant "it built", never "it passed": a red test run and a
|
|
||||||
# fresh :dev could carry the same timestamp. One graph is the only place the
|
# fresh :dev could carry the same timestamp. One graph is the only place the
|
||||||
# edge can be written.
|
# edge can be written.
|
||||||
#
|
#
|
||||||
@@ -125,10 +112,9 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
# A rapid re-push to main should supersede the in-flight build — the
|
# A rapid re-push to main supersedes the in-flight build: the later commit
|
||||||
# operator explicitly wants the later commit to win. Tags no longer enter
|
# wins. Tags are immutable and unique, so no tag build can be superseded by
|
||||||
# into this: they are immutable and unique, so no tag build can ever be
|
# another run on the same ref.
|
||||||
# superseded by another run on the same ref.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -173,14 +159,14 @@ jobs:
|
|||||||
# Full `go test -race` against an ephemeral Postgres.
|
# Full `go test -race` against an ephemeral Postgres.
|
||||||
#
|
#
|
||||||
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker
|
# 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
|
# daemon also runs a dev compose stack, so service containers get NO
|
||||||
# get NO published ports (collision) and no service-name DNS. We discover the
|
# published ports (collision) and no service-name DNS. We discover the
|
||||||
# service container through the mounted docker socket and reach it by bridge
|
# 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
|
# 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 key stays `integration` with no `name:`: act_runner derives the
|
||||||
# the service container's name from the job's display name.
|
# service container's name from the job's display name.
|
||||||
#
|
#
|
||||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||||
# without the SPA being built first.
|
# without the SPA being built first.
|
||||||
@@ -195,7 +181,7 @@ jobs:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: minstrel_test
|
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.
|
# Docker daemon; publishing a fixed host port collides.
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -209,13 +195,13 @@ jobs:
|
|||||||
# mounted docker socket. act_runner attaches the job
|
# mounted docker socket. act_runner attaches the job
|
||||||
# container and its service container(s) to a shared per-job
|
# container and its service container(s) to a shared per-job
|
||||||
# network, so scope discovery to a postgres that sits on a
|
# network, so scope discovery to a postgres that sits on a
|
||||||
# network THIS job container is also on. The old
|
# network THIS job container is also on. A name filter alone
|
||||||
# `--filter name=integration` matched EVERY concurrent
|
# would match EVERY concurrent integration run's postgres (a dev
|
||||||
# integration run's postgres (a dev push + the main-merge run
|
# push and a main-merge run overlapping give 2 candidates and a
|
||||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
# false "expected exactly 1" abort). The dev compose
|
||||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
# `minstrel-postgres-*` is never on this job's network; skip it
|
||||||
# this job's network; skip it explicitly as belt-and-suspenders
|
# explicitly as belt-and-suspenders (a wrong target would
|
||||||
# (a wrong target would truncate real data).
|
# truncate real data).
|
||||||
SELF=$(cat /etc/hostname)
|
SELF=$(cat /etc/hostname)
|
||||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||||
test -n "$SELF_NETS"
|
test -n "$SELF_NETS"
|
||||||
@@ -236,11 +222,11 @@ jobs:
|
|||||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
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
|
# Wait for Postgres to accept connections. Asked of the service
|
||||||
# container itself: the run: shell is dash (rule 81), where the
|
# container itself: the run: shell is dash, where a `/dev/tcp`
|
||||||
# old `/dev/tcp` probe never connects and the loop silently
|
# probe never connects and the loop would silently burn its full
|
||||||
# burned its full two minutes on every run.
|
# 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
|
# boot the image's entrypoint runs initdb under a temporary server
|
||||||
# that listens on the socket only, then stops it and starts the
|
# that listens on the socket only, then stops it and starts the
|
||||||
# real one. A socket probe can catch the temporary server, and 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
|
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||||
# all three up with no restart.
|
# all three up with no restart.
|
||||||
# Non-fatal: a failure degrades to "slower", never red CI. But
|
# 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.
|
# print what took effect.
|
||||||
relaxed=""
|
relaxed=""
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
@@ -291,10 +277,10 @@ jobs:
|
|||||||
# every integration package TRUNCATEs the one shared test DB;
|
# every integration package TRUNCATEs the one shared test DB;
|
||||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||||
# package execution (the documented local invocation too).
|
# package execution (the documented local invocation too).
|
||||||
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
|
# -timeout 20m: internal/api alone takes several minutes under
|
||||||
# an idle runner, and a dev and a main run sharing the runner
|
# -race on an idle runner, and a dev and a main run sharing the
|
||||||
# pushed it past go test's default 10m (run 8368, 600.016s, the
|
# runner can push it past go test's default 10m with nothing
|
||||||
# running test 2s old — nothing hung).
|
# hung.
|
||||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||||
go test -p 1 -race -timeout 20m ./...
|
go test -p 1 -race -timeout 20m ./...
|
||||||
|
|
||||||
@@ -314,11 +300,8 @@ jobs:
|
|||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# The whole tree, build and test tooling included. Until #5021 this
|
# The whole tree, build and test tooling included: the tooling that
|
||||||
# audited only what ships to browsers (`--omit=dev`), because vite,
|
# builds the shipped bundle is held to the same bar as the bundle.
|
||||||
# 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.
|
|
||||||
- name: npm audit (all dependencies)
|
- name: npm audit (all dependencies)
|
||||||
run: npm audit --audit-level=moderate
|
run: npm audit --audit-level=moderate
|
||||||
|
|
||||||
@@ -329,8 +312,8 @@ jobs:
|
|||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
android:
|
android:
|
||||||
# Using flutter-ci runner label because it's the only proven-working
|
# The flutter-ci runner label is the only proven-working label with
|
||||||
# label with docker that can pull our container.image.
|
# docker that can pull our container.image.
|
||||||
runs-on: flutter-ci
|
runs-on: flutter-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
@@ -376,11 +359,10 @@ jobs:
|
|||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
# No debug APK is built or uploaded here. Main used to upload a
|
# No debug APK is built or uploaded here: a debug build is signed by a
|
||||||
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
# key regenerated in every container, which no install can update.
|
||||||
# every container, which no install can update (family idea #5103,
|
# Phones get builds from android-release, signed with the one release
|
||||||
# practice 2). Phones get builds from android-release, signed with
|
# key, on dev and on tags.
|
||||||
# the one release key, on dev and on tags.
|
|
||||||
|
|
||||||
# Known vulnerabilities in the Go code and the standard library it is built
|
# 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
|
# 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
|
# 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.
|
# 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.
|
# database and the tool that reads it should both be current.
|
||||||
govulncheck:
|
govulncheck:
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
@@ -397,7 +379,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Plain git, not actions/checkout: that action runs on node, which the
|
# 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
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ github.token }}
|
TOKEN: ${{ github.token }}
|
||||||
@@ -420,7 +402,7 @@ jobs:
|
|||||||
android-release:
|
android-release:
|
||||||
name: Build signed APK (releases and dev)
|
name: Build signed APK (releases and dev)
|
||||||
# Also builds on `dev`, which is what makes a test channel possible at
|
# 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.
|
# release, which quietly turns `main` into the staging area.
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
||||||
runs-on: flutter-ci
|
runs-on: flutter-ci
|
||||||
@@ -453,7 +435,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
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
|
# timestamp, so a shallow clone would technically serve — but this
|
||||||
# job derives a value that ships to devices, and a shallow checkout
|
# job derives a value that ships to devices, and a shallow checkout
|
||||||
# changes what git-derived values resolve to WITHOUT failing. The
|
# 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
|
# 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 —
|
# 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
|
# but that is the final step, so a tag pushed without a release would
|
||||||
# APK for several minutes first and only then discovered it had nowhere to
|
# build an APK for several minutes first and only then find it has
|
||||||
# put it. Same check, seconds in instead of minutes.
|
# nowhere to put it. Same check, seconds in instead of minutes.
|
||||||
#
|
#
|
||||||
# Releases are normally created through the API (which creates the tag and
|
# 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
|
# 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
|
# secret) reaches no installed phone. The certificate's digest is
|
||||||
# pinned below; it is public, not a secret. Gradle signs with the
|
# pinned below; it is public, not a secret. Gradle signs with the
|
||||||
# release key or leaves the APK unsigned, and an unsigned build fails
|
# 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
|
# here too, as there is no app-release.apk to verify. apksigner, not
|
||||||
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
|
# keytool: keytool prints nothing for a v2-only APK.
|
||||||
# for a v2-only APK.
|
|
||||||
#
|
#
|
||||||
# Rotating the key on purpose means every install must be removed and
|
# Rotating the key on purpose means every install must be removed and
|
||||||
# reinstalled; change the digest here in the same commit.
|
# reinstalled; change the digest here in the same commit.
|
||||||
- name: The APK carries the release key
|
- name: The APK carries the release key
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
|
# CN=Minstrel, O=FabledSword.
|
||||||
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -580,11 +561,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
# Stock action — never @v3, which uploads something Gitea will never
|
||||||
# will never serve back. This is the producing half of a pair:
|
# serve back. This is the producing half of a pair: image-release
|
||||||
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
# downloads `minstrel-apk` below. Any upload v4+ pairs with any
|
||||||
# any download v4+ on this forge (every combination tested 2026-09-10,
|
# download v4+ on this forge, so the two pins need not move together.
|
||||||
# Scribe spike #3843), so the two pins need not move together.
|
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
@@ -687,7 +667,7 @@ jobs:
|
|||||||
|
|
||||||
image-release:
|
image-release:
|
||||||
name: Build + push container image
|
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
|
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||||
# pushes skip android-release and bundle the latest release's APK
|
# pushes skip android-release and bundle the latest release's APK
|
||||||
# instead, so for main alone a skipped android-release is expected.
|
# instead, so for main alone a skipped android-release is expected.
|
||||||
@@ -713,15 +693,13 @@ 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
|
# Full history: any job that DERIVES the version name needs it,
|
||||||
# DERIVES the version name needs it, because a shallow clone changes
|
# because a shallow clone changes what git-derived values resolve to
|
||||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
# WITHOUT failing — a too-low value, silently, with every lane green.
|
||||||
# value, silently, with every lane green.
|
|
||||||
#
|
#
|
||||||
# This job was depth-1 while it took the version from GITHUB_REF. It
|
# This job runs ci/version.sh itself: with no :<version> image tags,
|
||||||
# now runs ci/version.sh itself, because with :<version> image tags
|
# the server's self-reported version is the only thing that says which
|
||||||
# gone the server's self-reported version is the only thing that says
|
# build an image is.
|
||||||
# which build an image is.
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Detect buildable project
|
- name: Detect buildable project
|
||||||
@@ -743,21 +721,16 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# THE VERSION, and it is derived the same way on every ref — the
|
# 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"
|
# A per-ref string ("main", "dev", the tag name) would not order, and
|
||||||
# on main, "dev" on dev, and the tag name on a tag. None of them
|
# "dev" would be the same string forever — two dev images eight weeks
|
||||||
# ordered, and the first two were the same string forever — two dev
|
# apart indistinguishable in the UI. With no version image tags, this
|
||||||
# images eight weeks apart were indistinguishable in the UI. That
|
# is how an admin tells which build a container is running.
|
||||||
# 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,
|
# `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
|
# so the empty check below is actually reachable. A grep here would
|
||||||
# kill the step at the assignment under the runner's pipefail — the
|
# kill the step at the assignment under the runner's pipefail.
|
||||||
# exact bug that took down the first main build after the version
|
|
||||||
# rework.
|
|
||||||
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
||||||
if [ -z "${VERSION}" ]; then
|
if [ -z "${VERSION}" ]; then
|
||||||
echo "::error::could not derive a build version from ci/version.sh"
|
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 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
|
# the release; the image it rebuilds is the SAME SOURCE as the main
|
||||||
# build minutes earlier, differing only in which APK is baked in.
|
# build minutes earlier, differing only in which APK is baked in.
|
||||||
# Rule 145 is explicit about that case: when the same source is
|
# When the same source is rebuilt with different contents,
|
||||||
# rebuilt with different contents, publish the moving channel tag
|
# publish the moving channel tag and never a commit-addressable
|
||||||
# and never a commit-addressable one.
|
# one.
|
||||||
#
|
#
|
||||||
# :latest must move here rather than waiting for the next main
|
# :latest must move here rather than waiting for the next main
|
||||||
# push, or the channel would carry the PREVIOUS release's APK
|
# 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
|
CHANNEL=stable
|
||||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||||
@@ -790,10 +763,9 @@ jobs:
|
|||||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||||
else
|
else
|
||||||
# The production line: :latest tracks main's tip (rule 147) and
|
# The production line: :latest tracks main's tip and :<sha> is
|
||||||
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
# the rollback unit. Full 40-char SHA, so a rollback target is
|
||||||
# the family's other repos, so a rollback target is addressable
|
# addressable straight from the commit anyone is reading.
|
||||||
# straight from the commit anyone is reading.
|
|
||||||
CHANNEL=stable
|
CHANNEL=stable
|
||||||
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
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')
|
(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: stock download-artifact, which works here for
|
||||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
# 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
|
# out of the bundle). v8 runs on node24, which every CI-runner image
|
||||||
# CI-runner image carries — the runner uses the image's own node.
|
# carries — the runner uses the image's own node.
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
@@ -887,10 +859,10 @@ jobs:
|
|||||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||||
|
|
||||||
# Take the version the release RECORDED rather than recomputing it.
|
# Take the version the release RECORDED rather than recomputing it.
|
||||||
# This used to re-derive the name from the tagged commit, which meant
|
# Re-deriving it from the tagged commit would keep the formula in two
|
||||||
# the formula lived in two files that had to be kept in step, and it
|
# files that must stay in step, and could only ever recover the name —
|
||||||
# could only ever recover the name — the ordering key is build-time
|
# the ordering key is build-time minutes and exists nowhere after that
|
||||||
# minutes and does not exist anywhere after that build ends.
|
# build ends.
|
||||||
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
|
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
|
if [ -n "${SIDECAR_URL}" ]; then
|
||||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
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
|
# Verifies a tag release actually ended up complete, and names the specific
|
||||||
# thing that's missing if not.
|
# thing that's missing if not.
|
||||||
#
|
#
|
||||||
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
|
# A release can be incomplete while looking fine. If android-release never
|
||||||
# started — no log was written at all — so all eight of its steps reported
|
# starts (a runner failing to launch), its steps all report `failure` with
|
||||||
# `failure` with none executed and image-release showed `skipped`. The run was
|
# none executed and image-release shows `skipped`. The run is red, but the
|
||||||
# red, but the *release page rendered fine*, and `main`'s own push build had
|
# release page renders and main's own push build has already moved
|
||||||
# already moved `:latest`, so the code was deployable and nothing looked
|
# `:latest`, so a release missing its APK and its image is easy to skim
|
||||||
# obviously wrong. The release was simply missing its APK and its image,
|
# past.
|
||||||
# which is easy to skim past.
|
|
||||||
#
|
#
|
||||||
# This job cannot prevent that (the cause was a runner failing to launch, not
|
# This job cannot prevent that; it turns an incomplete release into an
|
||||||
# anything in this file). What it does is turn an incomplete release into an
|
# explicit, named error instead of a row of mystery step failures.
|
||||||
# explicit, named error instead of eight mystery step failures — so the
|
|
||||||
# consequence is legible without having to infer it.
|
|
||||||
#
|
#
|
||||||
# `if: always()` is the whole point: it has to report precisely when the jobs
|
# `if: always()` is the whole point: it has to report precisely when the jobs
|
||||||
# above did NOT succeed.
|
# above did NOT succeed.
|
||||||
@@ -977,22 +946,16 @@ jobs:
|
|||||||
echo "::notice::APK attached: ${APK}"
|
echo "::notice::APK attached: ${APK}"
|
||||||
|
|
||||||
# The other half. Checking only the APK would report success on a release
|
# 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
|
# whose image push failed. `always()` on this job means it runs even when
|
||||||
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
# image-release failed, so without this the guard would cheerfully verify
|
||||||
# it runs even when image-release failed, so without this the guard would
|
# an incomplete release.
|
||||||
# cheerfully verify an incomplete release.
|
|
||||||
#
|
#
|
||||||
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
# The image checked is the :<sha> rollback target, which main's own build
|
||||||
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
# published for this same commit before the tag was cut (there are no
|
||||||
# that assertion would now fail every release for a tag nothing mints.
|
# version image tags). It catches an image push that silently did not
|
||||||
# The rollback target it was really protecting is the :<sha> image, which
|
# happen, and it proves the ORDERING — a tag cut on a commit whose main
|
||||||
# main's own build published for this same commit before the tag was cut.
|
# build never completed has no rollback target, and that is worth failing
|
||||||
#
|
# on rather than discovering during an incident.
|
||||||
# Checking it here earns its keep twice over: it still catches an image
|
|
||||||
# push that silently did not happen, and it additionally proves the
|
|
||||||
# ORDERING — a tag cut on a commit whose main build never completed has
|
|
||||||
# no rollback target, and that is worth failing on rather than
|
|
||||||
# discovering during an incident.
|
|
||||||
- name: Rollback image must exist for the tagged commit
|
- name: Rollback image must exist for the tagged commit
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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
|
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||||
# to "dev"/"local". Both are surfaced at /healthz.
|
# to "dev"/"local". Both are surfaced at /healthz.
|
||||||
#
|
#
|
||||||
# These are two values on purpose (family rule 149): the same commit built on
|
# These are two values on purpose: the same commit built on dev and on main
|
||||||
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
|
# reports the same NAME and differs only in CHANNEL. Folding the channel
|
||||||
# the channel into the version string is what the rule forbids — the version
|
# into the version string would make two dev images eight weeks apart
|
||||||
# used to BE the channel word here ("main"/"dev"), which meant two dev images
|
# indistinguishable.
|
||||||
# eight weeks apart were indistinguishable.
|
|
||||||
ARG MINSTREL_VERSION=dev
|
ARG MINSTREL_VERSION=dev
|
||||||
ARG MINSTREL_CHANNEL=local
|
ARG MINSTREL_CHANNEL=local
|
||||||
RUN go build -trimpath \
|
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
|
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
||||||
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
||||||
# acoustic fingerprint that tells the same recording at two bitrates apart
|
# 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
|
# from two different recordings. Both are baked in at build time so a
|
||||||
# deployed instance never fetches either (rule 164); fpcalc is shelled out
|
# deployed instance never fetches either; fpcalc is shelled out
|
||||||
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
&& 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
|
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# In-app update channel (#397). client/ in the build context holds
|
# In-app update channel. client/ in the build context holds
|
||||||
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
|
# minstrel.apk + minstrel.apk.version, which release.yml fills on every
|
||||||
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
|
# build (dev and tag pushes bundle their own APK, main bundles the latest
|
||||||
# APK files aren't present, so non-tag images degrade gracefully.
|
# 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/
|
COPY --chown=minstrel:minstrel client/ /app/client/
|
||||||
|
|
||||||
USER minstrel
|
USER minstrel
|
||||||
|
|||||||
@@ -180,11 +180,10 @@ def main():
|
|||||||
" Literal colours, for any consumer that cannot inline the SVG and\n"
|
" Literal colours, for any consumer that cannot inline the SVG and\n"
|
||||||
" therefore cannot supply a currentColor. -->\n"))
|
" therefore cannot supply a currentColor. -->\n"))
|
||||||
|
|
||||||
# The tab icon is the full mark too (#5267). A hand-drawn reduced hat used
|
# The tab icon is the full mark too. A hand-drawn reduced form would lose
|
||||||
# to stand in at tab size because the art loses detail at 16px, but a
|
# less detail at 16px, but a redrawing reads as a different logo; the full
|
||||||
# redrawing reads as a different logo. The full mark keeps the logo's own
|
# mark keeps the logo's own shape, and a tab on a high-resolution screen
|
||||||
# shape, and a tab on a high-resolution screen draws it from 32px, where it
|
# draws it from 32px, where it holds. Do not swap a redrawn form in here.
|
||||||
# holds. Do not swap a redrawn form back in here.
|
|
||||||
(ROOT / "web/static/brand/favicon.svg").write_text(svg_doc(
|
(ROOT / "web/static/brand/favicon.svg").write_text(svg_doc(
|
||||||
vb, tr, "currentColor", ACCENT, hat_d, notes_d,
|
vb, tr, "currentColor", ACCENT, hat_d, notes_d,
|
||||||
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
|
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user