From be642cd53ff62bbec1e030d228db9db0d747760c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 26 Oct 2026 12:49:39 -0400 Subject: [PATCH] ci: main builds and bundles its own signed APK, so a merge ships the app without a release (#6465) android-release now runs on main as well as dev and tags, and image-release requires its success on every ref. :latest carries the stable APK built from main's own commit instead of the newest release's, so a merged app change reaches stable phones on the next pull. Releases stay as optional bookmarks that attach the APK for download. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/release.yml | 141 +++++++----------------- internal/server/release_version_test.go | 100 ++++++----------- 2 files changed, 73 insertions(+), 168 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 06bf08da..f065f4ff 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,9 +2,9 @@ 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 → :latest + : (latest-release APK bundled) -# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled) +# push to dev → :dev (freshly-built dev APK bundled) +# push to main → :latest + : (freshly-built stable APK bundled) +# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release) # workflow_dispatch → manual trigger (same rules based on the ref) # # That is the whole tag map. @@ -41,6 +41,12 @@ name: release # image in the same run from the same commit, and the channel cannot go # stale against its own artifact. # +# Every branch build carries the app built from its own commit, so a merge to +# main is all it takes to put a new app in front of stable phones: `:latest` +# serves it from /api/client/apk and the in-app update offers it. A release +# is an optional bookmark — a named point in history with the APK attached +# for download — never a step the app has to wait for. +# # 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 @@ -56,20 +62,14 @@ name: release # :latest is updated by every main push AND every tag push, so it always # reflects the newest blessed image. # -# APK pipeline: on tag pushes the android-release job builds + signs the -# Android APK and uploads it as a workflow artifact. The image-release -# job declares `needs: android-release`, so the docker image cannot -# start building until the APK is guaranteed-ready — no polling, no -# race, no silent-failure mode. Attaching the APK to the gitea Release is -# its own job (release-assets), behind the test gate below. -# -# :latest always carries an APK. Because every main push also moves -# :latest (not just tags), a main build with no APK would silently strip -# the in-app update channel off :latest until the next release. So on -# non-tag builds image-release pulls the MOST RECENT release's signed APK -# AND the version sidecar published beside it — the recorded values, not -# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds -# keep bundling their own freshly-built APK. +# APK pipeline: on every push — dev, main and tags alike — the +# android-release job builds + signs the Android APK and uploads it as a +# workflow artifact. The image-release job declares `needs: android-release` +# and requires its success, so the docker image cannot start building until +# the APK is guaranteed-ready — no polling, no race, no silent-failure mode, +# and no image that moves a channel without an app inside it. Attaching the +# APK to the gitea Release is its own job (release-assets), tags only, behind +# the test gate below. # # THE GATE. Every verifying lane lives in this file — # Go (vet, lint, short tests), the Postgres integration suite, the web app @@ -400,11 +400,14 @@ jobs: # ------------------------------------------------------------ artifacts -- android-release: - name: Build signed APK (releases and dev) - # Also builds on `dev`, which is what makes a test channel possible at - # all: without it the only way to get a build onto a phone is to cut a - # release, which quietly turns `main` into the staging area. - if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev' + name: Build signed APK + # Builds on every ref, so each channel's image carries the app from its + # own commit: `dev` for the test channel, `main` for stable phones, and a + # tag for the copy attached to its Release. + if: >- + startsWith(github.ref, 'refs/tags/v') + || github.ref == 'refs/heads/dev' + || github.ref == 'refs/heads/main' runs-on: flutter-ci container: image: git.fabledsword.com/bvandeusen/ci-android:36 @@ -601,9 +604,9 @@ jobs: path: release-apk/ - 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. + # Tag releases only. A branch build has no Release to hang assets on + # and does not need one — its image bundles the APK, and the server + # serves it from /api/client/apk. shell: bash env: CI_TOKEN: ${{ secrets.CI_TOKEN }} @@ -667,10 +670,9 @@ jobs: image-release: name: Build + push container image - # Every lane must have SUCCEEDED, each named here. Then the - # APK: tag and dev pushes build one and it must have succeeded; main - # pushes skip android-release and bundle the latest release's APK - # instead, so for main alone a skipped android-release is expected. + # Every lane must have SUCCEEDED, each named here, and so must the APK + # build: every ref bundles the APK built from its own commit, so an + # image without one would move a channel with no app for phones to take. needs: [go, integration, web, android, govulncheck, android-release] if: >- ${{ @@ -680,8 +682,7 @@ jobs: && needs.web.result == 'success' && needs.android.result == 'success' && needs.govulncheck.result == 'success' - && (needs.android-release.result == 'success' - || (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }} + && needs.android-release.result == 'success' }} runs-on: go-ci container: image: git.fabledsword.com/bvandeusen/ci-go:1.26 @@ -747,9 +748,10 @@ jobs: # publish the moving channel tag and never a commit-addressable # one. # - # :latest must move here rather than waiting for the next main - # push, or the channel would carry the PREVIOUS release's APK - # indefinitely — a channel that cannot refresh itself. + # :latest moves here so the server hands out the same APK build + # that is attached to the Release: a phone that installed from the + # Release page and one that updated in-app hold one build, with one + # ordering key. Cut a release on main's tip, as :latest follows it. CHANNEL=stable echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT" echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK" @@ -782,12 +784,8 @@ jobs: | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin - name: Download signed APK artifact - # 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') + # android-release just produced this, on every ref. + if: steps.guard.outputs.ready == 'true' # Consuming half of the pair: stock download-artifact, which works here for # the same reason as the upload (gitea/runner 3.x edits the GHES refusal # out of the bundle). v8 runs on node24, which every CI-runner image @@ -798,9 +796,7 @@ jobs: path: client/ - name: Stage bundled APK + version sidecar - if: >- - steps.guard.outputs.ready == 'true' && - (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev') + if: steps.guard.outputs.ready == 'true' shell: bash env: # All three pulled from android-release's outputs so the sidecar the @@ -821,65 +817,6 @@ jobs: cat client/minstrel.apk.version ls -lh client/ - - name: Bundle latest release APK (non-tag :latest builds) - # Main pushes don't build an APK, but they DO move :latest — so - # without this the in-app update channel would vanish from :latest - # until the next tag. Pull the most-recent release's signed APK and - # the sidecar published beside it, so what the server reports is what - # 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. That - # degradation only actually works because the greps below carry - # `|| true`; under the runner's default pipefail a non-matching grep - # kills the step instead of falling through to the empty-case branch. - if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main' - shell: bash - env: - CI_TOKEN: ${{ secrets.CI_TOKEN }} - run: | - set -eu - REPO="${GITHUB_REPOSITORY}" - REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \ - "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)" - if [ -z "${REL_JSON}" ]; then - echo "::notice::no published release — image ships without bundled APK"; exit 0 - fi - # `|| true` on every one of these, and it is load-bearing rather - # than defensive habit. The runner already invokes this shell as - # `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING - # exits non-zero even though `head` succeeded — and the step dies at - # the assignment, before ever reaching the `if` written to handle the - # empty case. Every "degrades gracefully" branch below is unreachable - # without this. - TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true - APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true - if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then - echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0 - fi - curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}" - - # Take the version the release RECORDED rather than recomputing it. - # Re-deriving it from the tagged commit would keep the formula in two - # files that must stay in step, and could only ever recover the name — - # the ordering key is build-time minutes and exists nowhere after that - # build ends. - SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true - if [ -n "${SIDECAR_URL}" ]; then - curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}" - cat client/minstrel.apk.version - else - # Releases published before sidecars were attached. Their name is - # still recoverable from the tag, but their ordering key genuinely - # is not — so it is reported ABSENT rather than guessed. A wrong - # key is an install the platform refuses; an absent one just tells - # the client to fall back to comparing names, which is exactly - # what those builds already do. - echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key" - printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version - fi - echo "::notice::bundled release APK from ${TAG}" - ls -lh client/ - - name: Build and push if: steps.guard.outputs.ready == 'true' # --pull: the Dockerfile's base images are floating tags (golang:1.26, diff --git a/internal/server/release_version_test.go b/internal/server/release_version_test.go index ac95415d..6244a5df 100644 --- a/internal/server/release_version_test.go +++ b/internal/server/release_version_test.go @@ -272,73 +272,41 @@ func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) { } } -// 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) +// A merge to main is enough to put a new app in front of stable phones: main +// builds the signed APK from its own commit and bakes it into :latest, as dev +// does for :dev. The regression to guard is main falling back to a tag-only +// APK build, which leaves every merged app change waiting on a release while +// each build stays green. +func TestEveryBranchShipsTheAppBuiltFromItsOwnCommit(t *testing.T) { + jobs := releaseJobs(t) - 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) - } -} - -// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any -// command substitution whose grep matches NOTHING fatal at the assignment — -// pipefail reports the grep's non-zero status even though `head` succeeded — -// so the step dies before reaching the `if` written to handle the empty case. -// -// This is not hypothetical. It took down the first `main` build after the -// version rework: the newest release predated sidecar assets, its -// `.apk.version` grep matched nothing, and the step aborted instead of -// falling through to the name-only branch that exists precisely for it. The -// same latent hazard sat on the other two assignments and had simply never -// fired, because their greps always matched. -// -// Pins the property that makes every "degrades gracefully" branch in that -// step reachable at all. -func TestBundleStep_GrepsCannotKillTheStep(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] - } - - for _, line := range strings.Split(step, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") { - continue + build := strings.Join(strings.Fields(jobs["android-release"].If), " ") + for _, ref := range []string{"refs/heads/main", "refs/heads/dev", "refs/tags/v"} { + if !strings.Contains(build, ref) { + t.Errorf("android-release does not build on %s, so that ref's image carries no app of its own\n if: %s", ref, build) } - // Only assignments from a command substitution can abort the step. - if !strings.Contains(trimmed, `="$(`) { - continue - } - if !strings.HasSuffix(trimmed, "|| true") { - t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+ - "skipping the empty-case branch below it:\n %s", trimmed) + } + + image := strings.Join(strings.Fields(jobs["image-release"].If), " ") + if !strings.Contains(image, "needs.android-release.result == 'success'") || strings.Contains(image, "'skipped'") { + t.Errorf("image-release can publish without a freshly-built APK\n if: %s", image) + } + + yaml := releaseYAML(t) + if strings.Contains(yaml, "releases/latest") { + t.Error("release.yml fetches the latest release; an image bundling a release's APK ships an app older than its own commit") + } + + // Both bundling steps run on every ref. Sliced from image-release on, + // because release-assets has a step of the same name. + i := strings.Index(yaml, "\n image-release:") + if i < 0 { + t.Fatal("no image-release job") + } + for _, name := range []string{"Download signed APK artifact", "Stage bundled APK + version sidecar"} { + step := stepBody(t, yaml[i:], name) + if strings.Contains(step, "github.ref") { + t.Errorf("%q is gated by ref, so some ref's image ships without its APK\n%s", name, step) } } } @@ -376,7 +344,7 @@ func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) { arm, _, _ := imageTagArms(t, releaseYAML(t)) if !strings.Contains(arm, "${IMAGE}:latest") { - t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm) + t.Errorf("tag arm does not refresh :latest — the server would hand out a different APK build from the one attached to the Release\n%s", arm) } if strings.Contains(arm, "GITHUB_SHA") { t.Errorf("tag arm mints a : image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm) -- 2.54.0