test-go / test (push) Successful in 1m39s
release / Build signed APK (releases and dev) (push) Successful in 6m11s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / integration (push) Successful in 5m59s
The first `main` build after the version rework failed, and the bug was
mine. :latest was never moved — "Build and push" was skipped — so nothing
reached production, but every subsequent main push would have failed the
same way.
The runner invokes `shell: bash` as `bash -e -o pipefail`. Under pipefail a
command substitution reports the FIRST non-zero status in its pipeline, not
the last, so
VAR="$(printf ... | grep -oP ... | grep -E '\.apk\.version$' | head -1)"
exits non-zero when that grep matches nothing, even though `head` succeeded.
With -e the step dies AT THE ASSIGNMENT — before reaching the `if` written
to handle exactly the empty case.
Which is what happened: v2026.09.09 predates sidecar assets, so its
`.apk.version` grep matched nothing and the step aborted instead of falling
through to the name-only branch I added in 9f3e0b8c for precisely that
release. The transition case was described correctly in that commit message
and then not handled in code.
The other two assignments carried the same latent hazard and had simply
never fired, because a release always has a tag_name and an .apk asset. So
the step's documented promise — "degrades to an empty client/ (404 update
channel) — never a wrong version — if no release or APK asset can be
resolved" — was never actually reachable under pipefail. All three now
carry `|| true`.
Reproduced under the runner's exact shell before fixing: without `|| true`
the script exits 1 with no output at all, proving it never reaches the
branch; with it, the fallback runs and emits the name-only sidecar.
Guarded, since the graceful degradation depends on this and the failure is
invisible until the one release that triggers it: the new test asserts every
command-substitution grep in that step ends with `|| true`, and was
falsified by removing it from the sidecar line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
289 lines
10 KiB
Go
289 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Guards the version derivation that stamps every build.
|
|
//
|
|
// These assertions used to be impossible to run. The derivation lived inline
|
|
// in release.yml, which triggers only on `main` and on tags — so a mistake in
|
|
// it could not surface until a release was already under way, and its failure
|
|
// mode is silence: a version nobody can compare looks exactly like being up to
|
|
// date, and nobody reports an update they were never offered.
|
|
//
|
|
// Moving it to ci/version.sh made it executable, so this runs on every push
|
|
// that touches the release machinery. That is the whole point of the file; the
|
|
// specific assertions below matter less than the fact that they run at all.
|
|
//
|
|
// The tests EXECUTE the script rather than asserting on its text, so they
|
|
// break when the behaviour changes rather than when the wording does.
|
|
|
|
// highestOldSchemeCode is the largest versionCode ever shipped under the
|
|
// retired commit-count scheme (v2026.09.09 shipped 1895). Every code the new
|
|
// scheme emits must clear it, or Android would refuse the upgrade as a
|
|
// downgrade and the update channel would be a one-way door.
|
|
const highestOldSchemeCode = 1895
|
|
|
|
func repoRoot(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatalf("no go.mod above %s", dir)
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// runVersion executes ci/version.sh with both clocks pinned, so the result is
|
|
// deterministic. Returns the parsed KEY=VALUE output.
|
|
func runVersion(t *testing.T, commitEpoch, nowEpoch string) map[string]string {
|
|
t.Helper()
|
|
out, err := versionScript(t, commitEpoch, nowEpoch)
|
|
if err != nil {
|
|
t.Fatalf("ci/version.sh failed: %v\n%s", err, out)
|
|
}
|
|
parsed := map[string]string{}
|
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
|
if k, v, ok := strings.Cut(line, "="); ok {
|
|
parsed[k] = v
|
|
}
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func versionScript(t *testing.T, commitEpoch, nowEpoch string) (string, error) {
|
|
t.Helper()
|
|
root := repoRoot(t)
|
|
cmd := exec.Command(filepath.Join(root, "ci", "version.sh"))
|
|
cmd.Dir = root
|
|
cmd.Env = append(os.Environ(),
|
|
"MINSTREL_COMMIT_EPOCH="+commitEpoch,
|
|
"MINSTREL_NOW_EPOCH="+nowEpoch,
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
func TestVersionName_IsCommitTimeToTheMinute(t *testing.T) {
|
|
// 2025-09-09T18:48:56Z
|
|
got := runVersion(t, "1757443736", "1789000920")
|
|
if want := "2025.09.09.1848"; got["name"] != want {
|
|
t.Errorf("name = %q, want %q", got["name"], want)
|
|
}
|
|
}
|
|
|
|
// HHMM is the segment most likely to be silently mangled, and it only bites
|
|
// for about a tenth of the day — a build just after midnight must emit "0042",
|
|
// never "42". A stripped leading zero shifts the segment by two orders of
|
|
// magnitude and reverses comparisons against every other build that day.
|
|
func TestVersionName_PadsTheMinuteSegment(t *testing.T) {
|
|
// 2026-09-10T00:42:00Z
|
|
got := runVersion(t, "1789000920", "1789000920")
|
|
if want := "2026.09.10.0042"; got["name"] != want {
|
|
t.Errorf("name = %q, want %q — leading zero lost?", got["name"], want)
|
|
}
|
|
}
|
|
|
|
func TestVersionName_DerivesFromCommitNotBuildClock(t *testing.T) {
|
|
// Same commit, two different build clocks: the NAME must not move, or a
|
|
// dev build and a main build of one commit would report different strings
|
|
// and the channel field would stop being the only thing separating them.
|
|
a := runVersion(t, "1757443736", "1789000920")
|
|
b := runVersion(t, "1757443736", "1789500000")
|
|
if a["name"] != b["name"] {
|
|
t.Errorf("name moved with the build clock: %q vs %q", a["name"], b["name"])
|
|
}
|
|
if a["code"] == b["code"] {
|
|
t.Errorf("code did NOT move with the build clock (%q) — it is not build-derived", a["code"])
|
|
}
|
|
}
|
|
|
|
func TestVersionCode_IsMinutesSince2020AndClearsTheOldScheme(t *testing.T) {
|
|
got := runVersion(t, "1789000920", "1789000920")
|
|
code, err := strconv.Atoi(got["code"])
|
|
if err != nil {
|
|
t.Fatalf("code %q is not an integer: %v", got["code"], err)
|
|
}
|
|
if want := (1789000920 - 1577836800) / 60; code != want {
|
|
t.Errorf("code = %d, want %d", code, want)
|
|
}
|
|
if code <= highestOldSchemeCode {
|
|
t.Errorf("code %d does not clear the retired commit-count scheme (%d) — "+
|
|
"Android would refuse the upgrade as a downgrade", code, highestOldSchemeCode)
|
|
}
|
|
if int64(code) > 2147483647 {
|
|
t.Errorf("code %d overflows versionCode's int32 ceiling", code)
|
|
}
|
|
}
|
|
|
|
// The tag is not chosen, it is the name with a `v`. Anything else reintroduces
|
|
// the mismatch between what a tag claims and what the artifact reports.
|
|
func TestTag_IsTheNameWithAPrefix(t *testing.T) {
|
|
got := runVersion(t, "1757443736", "1789000920")
|
|
if want := "v" + got["name"]; got["tag"] != want {
|
|
t.Errorf("tag = %q, want %q", got["tag"], want)
|
|
}
|
|
}
|
|
|
|
// A guard that cannot fail is worse than no guard. These prove the script
|
|
// rejects the shapes it claims to reject, rather than emitting something
|
|
// plausible and letting it ship.
|
|
func TestVersionScript_RejectsUnusableClocks(t *testing.T) {
|
|
for _, tc := range []struct{ name, commit, now string }{
|
|
{"unreadable commit timestamp", "notanumber", "1789000920"},
|
|
{"non-numeric build clock", "1789000920", "abc"},
|
|
{"build clock before 2020", "1789000920", "1000000000"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out, err := versionScript(t, tc.commit, tc.now)
|
|
if err == nil {
|
|
t.Errorf("script succeeded on %s, output: %s", tc.name, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Pins the wiring, not the formula: if release.yml stops calling the script,
|
|
// every assertion above keeps passing while the thing that actually ships goes
|
|
// unguarded again. That silent decoupling is the specific regression here.
|
|
func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
yaml := string(body)
|
|
|
|
if !strings.Contains(yaml, "ci/version.sh") {
|
|
t.Error("release.yml no longer calls ci/version.sh — the derivation has drifted out of test coverage")
|
|
}
|
|
// The retired scheme, which must not come back. A presence check is safe
|
|
// against prose; the historical note in that file names the old formula
|
|
// only in comments, so match the executable form.
|
|
if strings.Contains(yaml, "$(git rev-list --count") {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
}
|