test-go / test (push) Successful in 1m5s
test-go / integration (push) Successful in 3m30s
release / Build signed APK (releases and dev) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Skipped
Three build-hygiene fixes that turned up while explaining the pathspec.
**version.sh derives from what SHIPPED.** It read bare HEAD, so any commit
moved the version — including one touching only CI or a README. Rules 148
and 149 both specify the pathspec form. Now a denylist, and the direction
is the point: as an allowlist the list must be updated by whoever adds a
directory and nothing fails if they don't, so the failure mode is a changed
artifact keeping its old version silently on a green run. Inverted, new
content counts by default.
android/ is deliberately NOT excluded, and that is the subtle part. This
repo ships TWO artifacts from ONE derivation: android/ is in no server
image, but it is the APK's entire source, and excluding it would stop an
Android-only commit from moving the APK's own version — the silent
downgrade the versioning rework exists to prevent. So the list is the
union: exclude only what ships in neither, and accept that an Android
commit also nudges the server's reported version. Over-inclusion across the
two, which is the harmless direction. roundtable/roundtable-android each
keep tighter lists because they are one-artifact repos; don't copy theirs.
**.dockerignore excluded the wrong CI directory.** It named .forgejo/ and
.github/, neither of which this repo has. Gitea Actions reads .gitea/, so
the one directory that exists was the one not excluded. The "Flutter mobile
client" block had also lost its PATTERN when flutter_client/ was deleted,
leaving a comment describing an exclusion that was not happening — android/
never took its place, so 4.1MB of Gradle project entered the context and
busted the `COPY . .` layer on every Android-only change. bin/ excluded too.
**bin/minstrel was tracked** — an 18MB binary last refreshed by a commit
about web test mocks, and re-dirtied by every `make build` since. Untracked
and ignored; the file stays on disk.
Guards are behavioural rather than textual: they build throwaway repos with
pinned commit timestamps and run version.sh against them, so they break when
the derivation changes rather than when the wording does. Falsified — drop
the .gitea exclusion and the CI-only commit moves the version; add an
android exclusion and an Android commit stops moving it; exclude everything
and a source commit refuses.
One honest note on the refusal test: the script already refused an empty
result via the downstream date check, so the new explicit check improves the
diagnostic ("no commit touches the shipped file set — shallow clone?") and
not the safety. The test pins the property, which is defended in depth.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
602 lines
23 KiB
Go
602 lines
23 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)
|
|
}
|
|
if err := os.WriteFile(full, []byte("x\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)
|
|
}
|
|
}
|