Files
minstrel/internal/server/release_version_test.go
T
bvandeusenandClaude Opus 5 270ad7a71b
test-go / test (push) Successful in 1m1s
test-go / integration (push) Successful in 3m17s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(ci): tests do not ship, so they must not re-version an artifact
Completes the pathspec. 17212e9e excluded CI, docs and tooling but left
tests in the shipped set, so its own commit re-versioned the image on the
strength of a _test.go file. `go build` drops *_test.go outright and the
Vite build never imports a .test.ts — neither reaches an image or an APK.

Globs over files rather than a directory exclusion, because this repo has
no tests/ tree to exclude: Go tests sit inline beside the code they cover
(158 files) and the web suite beside its modules (115). Patterns match what
exists and nothing speculative — there are no .spec.* files, no __tests__/
directories and no androidTest/ tree. If any appear they re-version until
named, which is the harmless direction and the point of a denylist.

The guard that matters is not "a test-only commit is inert" — it is that a
commit touching a test AND its source still moves the version. `':!internal'`
would satisfy every inertness assertion while silently excluding the entire
server, which is the stale-version-on-changed-artifact failure this whole
derivation exists to prevent.

Falsified: drop the Go exclusion and a _test.go commit moves the version;
drop the web one and a .test.ts does; replace the globs with `':!internal'`
and the source-alongside-test case breaks.

That last check failed first time, on a bug in the FIXTURE rather than the
derivation, and it is worth recording because it makes a test pass for the
wrong reason. Both commit helpers wrote the constant "x\n", so re-writing a
file with identical bytes recorded NOTHING — the "source and test together"
commit actually contained only the test, and the assertion was quietly
checking the case it was meant to contrast against. Content is now derived
from the commit's epoch, and the test asserts HEAD really contains both
paths before drawing any conclusion from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 18:21:12 -04:00

719 lines
28 KiB
Go

package server
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"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")
}
}
// 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. That is the failure rule 167 names: 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)
}
// 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)
}
}
}
// 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)
}
// Rule 147: :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 (rules 145 + 146).
//
// The specific regression: re-adding :<sha> 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. That is the exact thing
// rule 145's immutability clause exists to prevent, and it is the half with
// the incidents behind it.
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 (rule 145, and the
// operator's 2026-09-10 decision to drop them across every project).
//
// 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 now the ONLY thing that identifies a
// build, so a lane that stamps a channel word instead of a version silently
// removes that ability. It used to stamp the literal "main"/"dev".
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 version and the APK's version can now drift apart")
}
}
// 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.
func versionIn(t *testing.T, dir string) (string, error) {
t.Helper()
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "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, and that is the subtle half of the
// list. It ships in no server image — but it is the APK's entire source, and
// ONE script derives the version for both artifacts. Excluding it would stop
// an Android-only commit from moving the APK's own version, which is exactly
// the silent downgrade the versioning rework exists to prevent.
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 version; the APK would ship new code under its old version name\n%s", out)
}
}
// 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)
}
}