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) + } +}