release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / integration (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
ci/version.sh --apk names the APK from android/ alone, and the APK lane calls it. The app offers an update when the server's APK name differs from its own, so a name derived from the whole repo offered every phone a rebuild of the app it already had on each server-only deploy. The image keeps the union set: it bundles the APK under client/. Casting (#6456): - setMediaItems with the tracks already queued (play the queued album from another track) cleared the cast and waited for a resync that the id-keyed collector never ran. It is now a move within the queue: Seek TRACK_NR on a Sonos, start that track on a one-track renderer; the play() goes through. - A queue replaced through setMediaItems could take the Sonos incremental diff, which cleared target without restoring active, so the app thought it was back on the phone while the speaker played on, and the chosen start track was ignored. The diff now runs only while the route is still active; a replaced queue reloads in full at its start index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
772 lines
30 KiB
Go
772 lines
30 KiB
Go
package server
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Guards the version derivation that stamps every build, in ci/version.sh.
|
|
// 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. Keeping it in a script lets these tests run it on every push.
|
|
//
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// jobBoundary matches a blank line followed by job-level (two-space)
|
|
// indentation — the end of the last step in a job.
|
|
var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`)
|
|
|
|
// stepBody returns one workflow step's text, from its `- name:` line to the
|
|
// start of the next step or the next job.
|
|
//
|
|
// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body
|
|
// for the last step in a job, and every assertion over it then passes
|
|
// vacuously: a check that reads as coverage while asserting on nothing. Cutting
|
|
// at a blank line followed by job-level indentation handles the last-step case,
|
|
// which is exactly where `Build and push` sits.
|
|
func stepBody(t *testing.T, yaml, name string) string {
|
|
t.Helper()
|
|
i := strings.Index(yaml, "- name: "+name)
|
|
if i < 0 {
|
|
t.Fatalf("no %q step in release.yml", name)
|
|
}
|
|
body := yaml[i:]
|
|
end := len(body)
|
|
if j := strings.Index(body, "\n - name:"); j >= 0 {
|
|
end = j
|
|
}
|
|
// A blank line followed by EXACTLY two spaces and then content starts a
|
|
// new job or job-level comment. The "exactly" matters: a blank line inside
|
|
// a `run:` block is followed by ten-space indentation and would otherwise
|
|
// match, truncating the step mid-body — which is how this helper first
|
|
// sliced the verify step down to its first two lines.
|
|
if loc := jobBoundary.FindStringIndex(body); loc != nil && loc[0] < end {
|
|
end = loc[0]
|
|
}
|
|
body = body[:end]
|
|
if strings.TrimSpace(strings.TrimPrefix(body, "- name: "+name)) == "" {
|
|
t.Fatalf("step %q sliced to an empty body — the assertions below would pass vacuously", name)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// releaseYAML reads the workflow once per test.
|
|
func releaseYAML(t *testing.T) string {
|
|
t.Helper()
|
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(body)
|
|
}
|
|
|
|
// imageTagArms splits "Compute image tags" into its three ref branches. The
|
|
// whole tag policy lives in that if/elif/else, so the arms are the unit worth
|
|
// asserting on — a tag published from the wrong arm reaches the wrong
|
|
// audience, and every such mistake still builds and still pushes a valid
|
|
// image.
|
|
func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) {
|
|
t.Helper()
|
|
const (
|
|
tagMarker = `if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then`
|
|
devMarker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
|
mainMarker = "\n else"
|
|
endMarker = "\n fi"
|
|
)
|
|
iTag := strings.Index(yaml, tagMarker)
|
|
iDev := strings.Index(yaml, devMarker)
|
|
iMain := -1
|
|
if iDev >= 0 {
|
|
if k := strings.Index(yaml[iDev:], mainMarker); k >= 0 {
|
|
iMain = iDev + k
|
|
}
|
|
}
|
|
if iTag < 0 || iDev < 0 || iMain < 0 {
|
|
t.Fatal("Compute image tags no longer has a tag/dev/main arm — the tag policy has been restructured, so these guards are pinning nothing")
|
|
}
|
|
iEnd := iMain
|
|
if k := strings.Index(yaml[iMain:], endMarker); k >= 0 {
|
|
iEnd = iMain + k
|
|
} else {
|
|
t.Fatal("no closing fi after the main arm")
|
|
}
|
|
return yaml[iTag:iDev], yaml[iDev:iMain], yaml[iMain:iEnd]
|
|
}
|
|
|
|
// 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) {
|
|
_, arm, _ := imageTagArms(t, releaseYAML(t))
|
|
|
|
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)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The rollback unit, and the reason it is worth a guard: it is invisible until
|
|
// the moment it is needed. Nothing pulls :<sha> during normal operation, so if
|
|
// this arm stopped minting one, every build would stay green and every image
|
|
// would be valid — and the absence would surface only during an incident, as
|
|
// "there is nothing to roll back to."
|
|
//
|
|
// Rules 145 and 147: main push → :latest + :<sha>.
|
|
func TestMainChannel_PublishesLatestAndTheRollbackUnit(t *testing.T) {
|
|
_, _, arm := imageTagArms(t, releaseYAML(t))
|
|
|
|
if !strings.Contains(arm, "${IMAGE}:latest") {
|
|
t.Errorf("main arm does not move :latest — production would stop tracking main's tip\n%s", arm)
|
|
}
|
|
if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") {
|
|
t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm)
|
|
}
|
|
// :latest tracks main's tip, and a second name for the same
|
|
// image sends readers looking for a distinction that does not exist.
|
|
if strings.Contains(arm, "${IMAGE}:main") {
|
|
t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm)
|
|
}
|
|
}
|
|
|
|
// A release refreshes the CHANNEL and mints nothing else.
|
|
//
|
|
// The regression to guard: a :<sha> tag here. The tag build rebuilds the SAME
|
|
// SOURCE as main's build minutes earlier, differing only in which APK is baked
|
|
// in — so a :<sha> minted here would overwrite main's immutable rollback
|
|
// target with different contents, under the same name.
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// No version-numbered image tags anywhere, on any arm: the build's version is
|
|
// self-reported, and the tag map is :latest, :<sha> and :dev.
|
|
//
|
|
// Asserted across the whole step rather than per-arm because the mistake this
|
|
// catches is re-adding one ANYWHERE, and the tag arm is only the likeliest
|
|
// spot. `${VERSION}` still legitimately appears in the step as the build's
|
|
// self-reported version, so the assertion has to name the image-tag form
|
|
// specifically rather than the variable — otherwise it would fire on correct
|
|
// code and get "fixed" by deleting the guard.
|
|
func TestNoVersionNumberedImageTags(t *testing.T) {
|
|
yaml := releaseYAML(t)
|
|
tagArm, devArm, mainArm := imageTagArms(t, yaml)
|
|
|
|
for _, tc := range []struct{ name, arm string }{
|
|
{"tag", tagArm}, {"dev", devArm}, {"main", mainArm},
|
|
} {
|
|
for _, forbidden := range []string{
|
|
"${IMAGE}:${VERSION}",
|
|
"${IMAGE}:v",
|
|
"${IMAGE}:${GITHUB_REF#refs/tags/}",
|
|
} {
|
|
if strings.Contains(tc.arm, forbidden) {
|
|
t.Errorf("%s arm publishes a version-numbered image tag (%q); git and the build's self-reported version answer \"which build is this\"\n%s",
|
|
tc.name, forbidden, tc.arm)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The verify job asserted the :<version> image existed. With version tags
|
|
// gone that assertion would fail every release for a tag nothing mints — so
|
|
// this pins that it was re-pointed rather than deleted, since deleting it is
|
|
// the tempting way to make a failing guard go green.
|
|
func TestVerifyJob_ChecksTheRollbackImageNotAVersionTag(t *testing.T) {
|
|
yaml := releaseYAML(t)
|
|
|
|
if !strings.Contains(yaml, "- name: Rollback image must exist for the tagged commit") {
|
|
t.Fatal("the release-verification step that checks an image exists is gone; an image push that silently did not happen would now pass verification")
|
|
}
|
|
step := stepBody(t, yaml, "Rollback image must exist for the tagged commit")
|
|
if !strings.Contains(step, "${IMAGE}:${GITHUB_SHA}") {
|
|
t.Errorf("the verify step does not inspect the commit's rollback image\n%s", step)
|
|
}
|
|
if strings.Contains(step, "${IMAGE}:${TAG}") {
|
|
t.Errorf("the verify step still inspects a version-numbered image, which is no longer published — this would fail every release\n%s", step)
|
|
}
|
|
}
|
|
|
|
// The server's self-reported version is the ONLY thing that identifies a
|
|
// build, so a lane that stamps a channel word instead of a version silently
|
|
// removes that ability.
|
|
func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
|
|
yaml := releaseYAML(t)
|
|
|
|
step := stepBody(t, yaml, "Build and push")
|
|
for _, want := range []string{
|
|
"MINSTREL_VERSION=",
|
|
"MINSTREL_CHANNEL=",
|
|
} {
|
|
if !strings.Contains(step, want) {
|
|
t.Errorf("Build and push does not pass %s — the image cannot report which build it is\n%s", want, step)
|
|
}
|
|
}
|
|
|
|
// The version must come from the shared derivation, not from the ref.
|
|
// Reading it off GITHUB_REF is what produced "main" and "dev" as version
|
|
// strings, which is the regression this pins.
|
|
_, _, mainArm := imageTagArms(t, yaml)
|
|
if strings.Contains(mainArm, `version=main`) {
|
|
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
|
|
}
|
|
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
|
|
t.Error("the image job no longer derives its version from ci/version.sh — the image's version has drifted out of test coverage")
|
|
}
|
|
}
|
|
|
|
// gitRepo builds a throwaway repo and returns its path. Commit timestamps are
|
|
// pinned so the derivation is deterministic.
|
|
func gitRepo(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
run := func(args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
run("-c", "init.defaultBranch=main", "init", "-q")
|
|
run("config", "user.email", "t@example.invalid")
|
|
run("config", "user.name", "t")
|
|
return dir
|
|
}
|
|
|
|
// commitFile writes path and commits it with a pinned committer timestamp.
|
|
func commitFile(t *testing.T, dir, path, epoch string) {
|
|
t.Helper()
|
|
full := filepath.Join(dir, path)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Content must DIFFER from any earlier write to the same path, or git
|
|
// records nothing and the commit silently covers fewer files than the
|
|
// test believes. That is not hypothetical: it made the
|
|
// source-alongside-test case pass vacuously.
|
|
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", path}} {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
|
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
|
|
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// versionIn runs ci/version.sh inside dir, reading real git rather than the
|
|
// pinned-clock override, so the PATHSPEC is what is under test. flags go
|
|
// before the ref, as in `--apk`.
|
|
func versionIn(t *testing.T, dir string, flags ...string) (string, error) {
|
|
t.Helper()
|
|
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), append(flags, "HEAD")...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
|
"MINSTREL_NOW_EPOCH=1789000920",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
// The version names what SHIPPED, so a commit that changes nothing shippable
|
|
// must not move it.
|
|
//
|
|
// The failure this prevents is not the cosmetic one. The derivation is a
|
|
// denylist precisely so that new content counts by default: the direction that
|
|
// matters is a changed artifact keeping its OLD version, silently, on a green
|
|
// run. This test pins the cheap half of that (CI-only commits are inert) and,
|
|
// in the same breath, that a source commit still moves it — because a pathspec
|
|
// typo that excluded everything would satisfy the first assertion alone.
|
|
func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
|
|
const (
|
|
shipped = "1757443736" // 2025-09-09T18:48:56Z
|
|
ciOnly = "1789000920" // 2026-09-10T00:42:00Z, later
|
|
)
|
|
dir := gitRepo(t)
|
|
commitFile(t, dir, "internal/server/thing.go", shipped)
|
|
commitFile(t, dir, ".gitea/workflows/release.yml", ciOnly)
|
|
|
|
out, err := versionIn(t, dir)
|
|
if err != nil {
|
|
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2025.09.09.1848") {
|
|
t.Errorf("a CI-only commit moved the version — the pathspec is not excluding it\n%s", out)
|
|
}
|
|
|
|
// ...and the pathspec must not be so broad it excludes everything.
|
|
commitFile(t, dir, "internal/server/other.go", ciOnly)
|
|
out, err = versionIn(t, dir)
|
|
if err != nil {
|
|
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2026.09.10.0042") {
|
|
t.Errorf("a source commit did NOT move the version — the pathspec excludes too much, which is the silent-lie direction\n%s", out)
|
|
}
|
|
}
|
|
|
|
// android/ is deliberately NOT excluded from the image's name: the image
|
|
// bundles the APK under client/, so an Android-only commit changes what the
|
|
// image carries, and an image that changed must not keep its old name.
|
|
func TestVersionName_AndroidSourcesCount(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
commitFile(t, dir, "internal/server/thing.go", "1757443736")
|
|
commitFile(t, dir, "android/app/src/main/Thing.kt", "1789000920")
|
|
|
|
out, err := versionIn(t, dir)
|
|
if err != nil {
|
|
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2026.09.10.0042") {
|
|
t.Errorf("an Android commit did not move the image's version; it carries a new APK under its old name\n%s", out)
|
|
}
|
|
}
|
|
|
|
// The app offers an update whenever the server's APK has a different name
|
|
// from its own, so the APK's name must move for Android commits and ONLY for
|
|
// them. Named from the whole repo, every server-only deploy offered each
|
|
// phone a rebuild of the app it already had.
|
|
func TestApkName_MovesOnlyForAndroidCommits(t *testing.T) {
|
|
const (
|
|
app = "1757443736" // 2025.09.09.1848
|
|
server = "1789000920" // 2026.09.10.0042, later
|
|
again = "1789600000" // 2026.09.16.2306, later still
|
|
)
|
|
dir := gitRepo(t)
|
|
commitFile(t, dir, "android/app/src/main/Thing.kt", app)
|
|
commitFile(t, dir, "internal/server/thing.go", server)
|
|
commitFile(t, dir, "web/src/routes/page.svelte", server)
|
|
|
|
out, err := versionIn(t, dir, "--apk")
|
|
if err != nil {
|
|
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2025.09.09.1848") {
|
|
t.Errorf("a server-only commit moved the APK's name; every phone would be offered the app it already has\n%s", out)
|
|
}
|
|
// The image's name still follows the server commit, so the test above
|
|
// is not passing because the flag is ignored and everything is stale.
|
|
out, err = versionIn(t, dir)
|
|
if err != nil {
|
|
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2026.09.10.0042") {
|
|
t.Errorf("without --apk the name should follow the server commit\n%s", out)
|
|
}
|
|
|
|
// A test-only Android commit ships nothing either.
|
|
commitFile(t, dir, "android/app/src/test/java/A.kt", again)
|
|
out, err = versionIn(t, dir, "--apk")
|
|
if err != nil {
|
|
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2025.09.09.1848") {
|
|
t.Errorf("an Android test-only commit moved the APK's name\n%s", out)
|
|
}
|
|
|
|
// ...and a real Android commit must move it, or new app code would ship
|
|
// under the name of the old and never be offered.
|
|
commitFile(t, dir, "android/app/src/main/Thing.kt", again)
|
|
out, err = versionIn(t, dir, "--apk")
|
|
if err != nil {
|
|
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2026.09.16.2306") {
|
|
t.Errorf("an Android commit did not move the APK's name; the new app would never be offered\n%s", out)
|
|
}
|
|
}
|
|
|
|
// The APK lane must ask for the APK's name. Without the flag it would still
|
|
// build, sign and publish, and quietly go back to offering an update on every
|
|
// server deploy.
|
|
func TestReleaseWorkflow_NamesTheApkFromAndroidAlone(t *testing.T) {
|
|
step := stepBody(t, releaseYAML(t), "Compute release version")
|
|
if !strings.Contains(step, "ci/version.sh --apk HEAD") {
|
|
t.Errorf("the APK lane does not derive its version with --apk\n%s", step)
|
|
}
|
|
}
|
|
|
|
// No shipped commit in range means a shallow clone, and the script must refuse
|
|
// rather than emit something plausible. A wrong version builds, signs and
|
|
// publishes perfectly happily; it surfaces later as an update channel that has
|
|
// quietly stopped offering anything.
|
|
func TestVersionScript_RefusesWhenNothingShippedIsInRange(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
commitFile(t, dir, "ci/version.sh", "1789000920")
|
|
|
|
out, err := versionIn(t, dir)
|
|
if err == nil {
|
|
t.Fatalf("script succeeded with no shipped commit in range; it should refuse\n%s", out)
|
|
}
|
|
if strings.Contains(out, "name=") {
|
|
t.Errorf("script emitted a version name while refusing — that value could still be consumed\n%s", out)
|
|
}
|
|
}
|
|
|
|
// commitFiles is commitFile for more than one path in a single commit.
|
|
func commitFiles(t *testing.T, dir, epoch string, paths ...string) {
|
|
t.Helper()
|
|
for _, rel := range paths {
|
|
full := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", strings.Join(paths, " ")}} {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
|
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
|
|
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// gitOutput runs a git command in dir and returns its combined output.
|
|
func gitOutput(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// Tests do not ship, so a test-only commit must not re-version an artifact.
|
|
//
|
|
// `go build` drops *_test.go outright and the Vite build never imports a
|
|
// .test.ts, so neither reaches an image or an APK. This repo has no tests/
|
|
// tree — Go tests sit inline beside the code — so the exclusions are globs,
|
|
// and a glob is easy to get subtly wrong in a way that still looks right.
|
|
func TestVersionName_TestsDoNotReVersionTheArtifact(t *testing.T) {
|
|
const (
|
|
shipped = "1757443736" // 2025.09.09.1848
|
|
later = "1789000920" // 2026.09.10.0042
|
|
)
|
|
for _, tc := range []struct{ name, path string }{
|
|
{"go test beside its source", "internal/server/thing_test.go"},
|
|
{"web unit test", "web/src/lib/api/admin.test.ts"},
|
|
{"web script test", "web/scripts/tokens-to-css.test.js"},
|
|
{"android JVM unit test", "android/app/src/test/java/A.kt"},
|
|
{"vitest harness config", "web/vitest.config.ts"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := gitRepo(t)
|
|
commitFile(t, dir, "internal/server/thing.go", shipped)
|
|
commitFile(t, dir, tc.path, later)
|
|
|
|
out, err := versionIn(t, dir)
|
|
if err != nil {
|
|
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2025.09.09.1848") {
|
|
t.Errorf("a commit touching only %s moved the version; tests do not ship\n%s", tc.path, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The direction that actually costs something, and the reason the exclusions
|
|
// above are globs over FILES rather than over their directories.
|
|
//
|
|
// `':!internal'` would satisfy every assertion in the test above while
|
|
// silently excluding the entire server. This pins the opposite: a commit that
|
|
// changes a test AND the source under it must still move the version, because
|
|
// the source path matches on its own. Without this, a too-broad exclusion
|
|
// reads as a passing test suite and ships an artifact under a stale version.
|
|
func TestVersionName_ATestAlongsideItsSourceStillCounts(t *testing.T) {
|
|
const (
|
|
shipped = "1757443736"
|
|
later = "1789000920"
|
|
)
|
|
dir := gitRepo(t)
|
|
commitFile(t, dir, "internal/server/thing.go", shipped)
|
|
commitFiles(t, dir, later,
|
|
"internal/server/thing.go",
|
|
"internal/server/thing_test.go",
|
|
)
|
|
|
|
// The commit must actually contain BOTH paths. Rewriting a file with
|
|
// identical bytes records nothing, and this assertion would then be
|
|
// checking a test-only commit while appearing to check a mixed one.
|
|
touched := gitOutput(t, dir, "show", "--name-only", "--format=", "HEAD")
|
|
for _, want := range []string{"internal/server/thing.go", "internal/server/thing_test.go"} {
|
|
if !strings.Contains(touched, want) {
|
|
t.Fatalf("fixture is wrong: HEAD does not contain %s\n%s", want, touched)
|
|
}
|
|
}
|
|
|
|
out, err := versionIn(t, dir)
|
|
if err != nil {
|
|
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(out, "name=2026.09.10.0042") {
|
|
t.Errorf("a source change accompanied by a test change did NOT move the version — "+
|
|
"the exclusions are matching directories rather than test files\n%s", out)
|
|
}
|
|
}
|