From 90bb3538c6b73c2cf8d17de6c154b6c627b03025 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 9 Sep 2026 22:47:42 -0400 Subject: [PATCH] feat(release): build a dev channel so testing stops requiring a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH --- .gitea/workflows/release.yml | 81 +++++++++++++++++++++---- README.md | 7 ++- internal/server/release_version_test.go | 63 +++++++++++++++++++ 3 files changed, 138 insertions(+), 13 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 8389ab32..4c7f7f88 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,10 +2,31 @@ name: release # Builds and pushes the minstrel container image to the Gitea registry. # +# push to dev → :dev (freshly-built dev APK bundled) # push to main → :main and :latest (latest-release APK bundled) # push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled) # 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. # `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 @@ -48,7 +69,7 @@ name: release on: push: - branches: [main] + branches: [main, dev] tags: ['v*'] paths-ignore: - 'docs/**' @@ -65,8 +86,11 @@ concurrency: jobs: android-release: - name: Build signed APK (tag releases only) - if: startsWith(github.ref, 'refs/tags/v') + 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 + # 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 container: image: git.fabledsword.com/bvandeusen/ci-android:36 @@ -91,6 +115,7 @@ jobs: outputs: version_name: ${{ steps.ver.outputs.name }} version_code: ${{ steps.ver.outputs.code }} + channel: ${{ steps.ver.outputs.channel }} steps: - name: Checkout @@ -115,7 +140,18 @@ jobs: # unverifiable until a release is already running. out="$(ci/version.sh HEAD)" 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 # 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 # case this catches. - name: Release must exist for this tag + if: startsWith(github.ref, 'refs/tags/v') shell: bash working-directory: ${{ github.workspace }} env: @@ -190,6 +227,10 @@ jobs: if-no-files-found: error - 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 env: CI_TOKEN: ${{ secrets.CI_TOKEN }} @@ -298,6 +339,15 @@ jobs: echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" 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 # Main is the protected, post-PR-merge branch. Treat it as the # rolling stable channel — every main push moves :latest. @@ -316,9 +366,12 @@ jobs: | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin - name: Download signed APK artifact - # Tag pushes only — android-release just produced this. Non-tag - # builds take the "Bundle latest release APK" path below instead. - if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v') + # Tag and dev pushes — android-release just produced this. Only `main` + # takes the "Bundle latest release APK" path below, because it is the + # 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, # same reason: upstream's client-side GHES check rejects this hostname # before it connects. bvandeusen/download-artifact mirrors @@ -338,22 +391,26 @@ jobs: path: client/ - 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 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 # they are comparing against. APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }} APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }} + APK_CHANNEL: ${{ needs.android-release.outputs.channel }} run: | set -euxo pipefail # The artifact lands as `app-release.apk` (the original Gradle # output name). The Dockerfile COPYs client/* into /app/client/ # and the server reads minstrel.apk + minstrel.apk.version. mv client/app-release.apk client/minstrel.apk - printf '{"name":"%s","code":%s,"channel":"stable"}\n' \ - "${APK_VERSION_NAME}" "${APK_VERSION_CODE}" > client/minstrel.apk.version + printf '{"name":"%s","code":%s,"channel":"%s"}\n' \ + "${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \ + > client/minstrel.apk.version cat client/minstrel.apk.version ls -lh client/ @@ -365,7 +422,7 @@ jobs: # that build actually recorded rather than something re-derived here. # Degrades to an empty client/ (404 update channel) — never a wrong # 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 env: CI_TOKEN: ${{ secrets.CI_TOKEN }} diff --git a/README.md b/README.md index 14febf77..c9d8e8b1 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,13 @@ Image tags (`git.fabledsword.com/bvandeusen/minstrel:`): - `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators. - `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag. - `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line. +- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback. -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. diff --git a/internal/server/release_version_test.go b/internal/server/release_version_test.go index a63dcbcd..892103ff 100644 --- a/internal/server/release_version_test.go +++ b/internal/server/release_version_test.go @@ -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") } } + +// 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) + } +}