feat(release): build a dev channel so testing stops requiring a release
test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped

There was no test channel at all. release.yml ran only on main and tags, so
no :dev image existed and no APK was produced outside a release — the only
way to get a build onto a phone was to ship one, which made `main` the
staging area by default.

A push to dev now builds a signed APK, bundles it, and publishes :dev.

Signed with the SAME key as release builds, deliberately. A differently
signed APK cannot install over the stable app, so anyone moving between
channels would have to uninstall and lose their local data. Same key means
both directions work.

: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,
and that is a deliberate trade rather than an omission.

The channel is derived from the REF, not the commit, which is why it is
computed in the workflow and not in ci/version.sh. The same commit built on
dev and on main reports the same version NAME and differs only in the
channel field — that separation is the entire point of keeping the three
values apart.

What this repo deliberately does NOT get: 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 server live in separate repos, and a
channel that can only be refreshed by an unrelated commit is not a channel.
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 met structurally; copying the mechanism would
add a moving part to fix a problem that does not exist here.

Two guards, for the two ways this wiring can fail quietly:

A dev push must never move :latest. That would ship untested code to every
stable operator on their next pull, with the build green and the image
perfectly valid — just the wrong audience. Nothing else in the suite would
notice.

The two bundling paths must stay mutually exclusive. The rebundle step is
now gated to main specifically, not to "not a tag": under the looser
condition a dev push would run BOTH steps, staging its fresh APK and then
overwriting it with the previous release's. The image still builds, the
sidecar still parses, and the channel whose whole job is being current
quietly serves stale art.

Both falsified against the regressions they name before committing.

Scribe task #3819, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-09 22:47:42 -04:00
co-authored by Claude Opus 5
parent a687ef439c
commit 90bb3538c6
3 changed files with 138 additions and 13 deletions
+69 -12
View File
@@ -2,10 +2,31 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry. # Builds and pushes the minstrel container image to the Gitea registry.
# #
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :main and :latest (latest-release APK bundled) # push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled) # push 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)
# #
# 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.
#
# :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.
#
# 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
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile # the tagged commit's UTC timestamp — so there is no mismatch to reconcile
@@ -48,7 +69,7 @@ name: release
on: on:
push: push:
branches: [main] branches: [main, dev]
tags: ['v*'] tags: ['v*']
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
@@ -65,8 +86,11 @@ concurrency:
jobs: jobs:
android-release: android-release:
name: Build signed APK (tag releases only) name: Build signed APK (releases and dev)
if: startsWith(github.ref, 'refs/tags/v') # 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
# 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 runs-on: flutter-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-android:36 image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -91,6 +115,7 @@ jobs:
outputs: outputs:
version_name: ${{ steps.ver.outputs.name }} version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }} version_code: ${{ steps.ver.outputs.code }}
channel: ${{ steps.ver.outputs.channel }}
steps: steps:
- name: Checkout - name: Checkout
@@ -115,7 +140,18 @@ jobs:
# unverifiable until a release is already running. # unverifiable until a release is already running.
out="$(ci/version.sh HEAD)" out="$(ci/version.sh HEAD)"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT" printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ')"
# The channel is a property of the LANE, not of the commit, which is
# why it is derived here rather than in version.sh. Same commit built
# on dev and on main reports the same NAME and differs only here —
# that is the whole point of separating the two values.
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
channel=dev
else
channel=stable
fi
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
# 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 —
@@ -127,6 +163,7 @@ jobs:
# 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
# case this catches. # case this catches.
- name: Release must exist for this tag - name: Release must exist for this tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
env: env:
@@ -190,6 +227,10 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Attach APK to gitea Release - name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and
# does not need one — the :dev image bundles the APK, and the server
# serves it from /api/client/apk like any other.
if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -298,6 +339,15 @@ jobs:
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "::notice::Release build: ${VERSION} + latest" echo "::notice::Release build: ${VERSION} + latest"
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
# The rolling test channel, and :dev ALONE — deliberately no
# per-commit tag. A rolling channel is rolling by definition, so a
# commit-addressable image here would be a rollback target nobody
# has ever pulled, accumulating in the registry forever. Recovery
# on dev is to fix forward.
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "version=dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build: :dev"
else else
# Main is the protected, post-PR-merge branch. Treat it as the # Main is the protected, post-PR-merge branch. Treat it as the
# rolling stable channel — every main push moves :latest. # rolling stable channel — every main push moves :latest.
@@ -316,9 +366,12 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact - name: Download signed APK artifact
# Tag pushes only — android-release just produced this. Non-tag # Tag and dev pushes — android-release just produced this. Only `main`
# builds take the "Bundle latest release APK" path below instead. # takes the "Bundle latest release APK" path below, because it is the
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') # one ref that moves a channel without building an APK of its own.
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
# Consuming half of the pair — never actions/download-artifact. Same fork, # Consuming half of the pair — never actions/download-artifact. Same fork,
# same reason: upstream's client-side GHES check rejects this hostname # same reason: upstream's client-side GHES check rejects this hostname
# before it connects. bvandeusen/download-artifact mirrors # before it connects. bvandeusen/download-artifact mirrors
@@ -338,22 +391,26 @@ jobs:
path: client/ path: client/
- name: Stage bundled APK + version sidecar - name: Stage bundled APK + version sidecar
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
shell: bash shell: bash
env: env:
# Both pulled from android-release's outputs so the sidecar the # All three pulled from android-release's outputs so the sidecar the
# server hands clients matches exactly what is baked into the APK # server hands clients matches exactly what is baked into the APK
# they are comparing against. # they are comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }} APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }} APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
run: | run: |
set -euxo pipefail set -euxo pipefail
# The artifact lands as `app-release.apk` (the original Gradle # The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/ # output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version. # and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk mv client/app-release.apk client/minstrel.apk
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \ printf '{"name":"%s","code":%s,"channel":"%s"}\n' \
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" > client/minstrel.apk.version "${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
> client/minstrel.apk.version
cat client/minstrel.apk.version cat client/minstrel.apk.version
ls -lh client/ ls -lh client/
@@ -365,7 +422,7 @@ jobs:
# that build actually recorded rather than something re-derived here. # that build actually recorded rather than something re-derived here.
# Degrades to an empty client/ (404 update channel) — never a wrong # Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release or APK asset can be resolved. # version — if no release or APK asset can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v') if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
+6 -1
View File
@@ -115,8 +115,13 @@ Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators. - `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag. - `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line. - `: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.
Every `:latest` and every `:vYYYY.MM.DD.HHMM` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump. 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.
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended. Releases 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.
+63
View File
@@ -177,3 +177,66 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
t.Error("release.yml derives a commit count again — that is not monotonic across branches") t.Error("release.yml derives a commit count again — that is not monotonic across branches")
} }
} }
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
func devArm(t *testing.T, yaml string) string {
t.Helper()
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
i := strings.Index(yaml, marker)
if i < 0 {
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
}
rest := yaml[i+len(marker):]
if j := strings.Index(rest, "\n else"); j >= 0 {
return rest[:j]
}
return rest
}
// The worst regression this wiring can produce: a dev push that also moves
// :latest would ship untested code to every stable operator, silently, on the
// next pull. Nothing else in the suite would notice — the build stays green
// and the image is valid, it is simply the wrong audience.
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
arm := devArm(t, string(body))
if !strings.Contains(arm, "${IMAGE}:dev") {
t.Errorf("dev arm does not publish :dev\n%s", arm)
}
if strings.Contains(arm, ":latest") {
t.Errorf("dev arm moves :latest — that ships dev code to every stable operator\n%s", arm)
}
// Rule 145: a rolling channel gets no commit-addressable tag.
if strings.Contains(arm, "GITHUB_SHA") {
t.Errorf("dev arm publishes a per-commit tag; a rolling channel should not\n%s", arm)
}
}
// The two bundling paths must stay mutually exclusive. If the rebundle step's
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
// its freshly-built APK, then overwrite it with the previous release's. The
// image would still build and the sidecar would still parse — it would just
// quietly serve stale art to the channel whose whole job is being current.
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
yaml := string(body)
i := strings.Index(yaml, "- name: Bundle latest release APK")
if i < 0 {
t.Fatal("no 'Bundle latest release APK' step")
}
step := yaml[i:]
if j := strings.Index(step, "\n - name:"); j >= 0 {
step = step[:j]
}
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
}
}