Compare commits

..
1 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 be642cd53f ci: main builds and bundles its own signed APK, so a merge ships the app without a release (#6465)
release / web (push) Successful in 1m51s
release / go (push) Successful in 2m6s
release / govulncheck (push) Successful in 20s
release / integration (push) Successful in 6m53s
release / android (push) Successful in 5m41s
release / Build signed APK (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 18s
release / Verify release artifacts (tag releases only) (push) Skipped
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 <noreply@anthropic.com>
2026-10-26 12:49:39 -04:00
2 changed files with 73 additions and 168 deletions
+39 -102
View File
@@ -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 + :<sha> (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 + :<sha> (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,
+34 -66
View File
@@ -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 :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)