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>
This commit is contained in:
2026-10-26 12:49:39 -04:00
co-authored by Claude Opus 5.5
parent 7d5dcd454e
commit be642cd53f
2 changed files with 73 additions and 168 deletions
+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)