test-go / test (push) Successful in 1m11s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
test-go / integration (push) Successful in 5m55s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
There was no test channel at all. release.yml ran only on main and tags, so no :dev image existed and no APK was produced outside a release — the only way to get a build onto a phone was to ship one, which made `main` the staging area by default. A push to dev now builds a signed APK, bundles it, and publishes :dev. Signed with the SAME key as release builds, deliberately. A differently signed APK cannot install over the stable app, so anyone moving between channels would have to uninstall and lose their local data. Same key means both directions work. :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, and that is a deliberate trade rather than an omission. The channel is derived from the REF, not the commit, which is why it is computed in the workflow and not in ci/version.sh. The same commit built on dev and on main reports the same version NAME and differs only in the channel field — that separation is the entire point of keeping the three values apart. What this repo deliberately does NOT get: 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 server live in separate repos, and a channel that can only be refreshed by an unrelated commit is not a channel. 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 met structurally; copying the mechanism would add a moving part to fix a problem that does not exist here. Two guards, for the two ways this wiring can fail quietly: A dev push must never move :latest. That would ship untested code to every stable operator on their next pull, with the build green and the image perfectly valid — just the wrong audience. Nothing else in the suite would notice. The two bundling paths must stay mutually exclusive. The rebundle step is now gated to main specifically, not to "not a tag": under the looser condition a dev push would run BOTH steps, staging its fresh APK and then overwriting it with the previous release's. The image still builds, the sidecar still parses, and the channel whose whole job is being current quietly serves stale art. Both falsified against the regressions they name before committing. Scribe task #3819, milestone #390. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
243 lines
8.8 KiB
Go
243 lines
8.8 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)
|
|
}
|
|
}
|