Versioning rework, a dev channel, and the miniplayer gap #129
@@ -110,45 +110,12 @@ jobs:
|
|||||||
working-directory: ${{ github.workspace }}
|
working-directory: ${{ github.workspace }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# The derivation lives in ci/version.sh, not here, so it can be
|
||||||
# Two different clocks, deliberately. They answer different
|
# executed by a test on every push. Anything inline in this file is
|
||||||
# questions, and using one for both breaks whichever it fits worse.
|
# unverifiable until a release is already running.
|
||||||
#
|
out="$(ci/version.sh HEAD)"
|
||||||
# The NAME answers "is this the same code?" — so it derives from
|
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
||||||
# COMMIT time and reads identically on every lane that builds this
|
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ')"
|
||||||
# source. A dev build and a main build of one commit must report the
|
|
||||||
# same string; build time cannot do that, it prints two numbers for
|
|
||||||
# one thing.
|
|
||||||
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
|
||||||
VERSION_NAME=$(date -u -d "@${COMMIT_TS}" +%Y.%m.%d.%H%M)
|
|
||||||
|
|
||||||
# The ORDERING KEY answers "may this be installed over that?" — so it
|
|
||||||
# must be monotonic BY CONSTRUCTION. Minutes since 2020-01-01: ~3.5M
|
|
||||||
# today, ~525k/year, against a 2^31 ceiling.
|
|
||||||
#
|
|
||||||
# This replaced `git rev-list --count HEAD`, which was NOT monotonic
|
|
||||||
# and was commented as if it were. A commit count runs ahead on `dev`,
|
|
||||||
# so a dev build outranked the `main` release that superseded it and
|
|
||||||
# Android refused the install as a downgrade — a channel you could
|
|
||||||
# enter and not leave without uninstalling.
|
|
||||||
#
|
|
||||||
# Commit time would be wrong here too, for the mirror-image reason:
|
|
||||||
# rebuild an older commit and it goes DOWN, which on a phone is a
|
|
||||||
# refused install rather than a merely confusing label.
|
|
||||||
VERSION_CODE=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
|
||||||
|
|
||||||
# Assert the emitted shape at the source. A malformed name still
|
|
||||||
# builds, signs and publishes perfectly happily, and only surfaces as
|
|
||||||
# an update nobody is ever offered — which nobody reports, because
|
|
||||||
# "no update available" and "I cannot read this" look identical.
|
|
||||||
if [[ ! "${VERSION_NAME}" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}$ ]]; then
|
|
||||||
echo "::error::version name '${VERSION_NAME}' is not YYYY.MM.DD.HHMM"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "code=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "::notice::APK version: ${VERSION_NAME} (code=${VERSION_CODE})"
|
|
||||||
|
|
||||||
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
||||||
# Release" below resolves the release by tag and fails if it is absent —
|
# Release" below resolves the release by tag and fails if it is absent —
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ on:
|
|||||||
- 'cmd/**'
|
- 'cmd/**'
|
||||||
- '.golangci.yml'
|
- '.golangci.yml'
|
||||||
- '.gitea/workflows/test-go.yml'
|
- '.gitea/workflows/test-go.yml'
|
||||||
|
# The release lane's own trigger is `main` + tags, so nothing it
|
||||||
|
# contains is exercised until a release is already running. These two
|
||||||
|
# entries are what let internal/server/release_version_test.go guard
|
||||||
|
# the version derivation on ordinary dev pushes instead.
|
||||||
|
- 'ci/**'
|
||||||
|
- '.gitea/workflows/release.yml'
|
||||||
|
|
||||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
# pull_request trigger intentionally omitted — see test-web.yml for
|
||||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||||
|
|||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Derives the three values a build is stamped with, and the tag that names it.
|
||||||
|
#
|
||||||
|
# name=YYYY.MM.DD.HHMM label for people, from the COMMIT's timestamp
|
||||||
|
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
|
||||||
|
# tag=v<name> what a release of this commit must be called
|
||||||
|
#
|
||||||
|
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
|
||||||
|
#
|
||||||
|
# This exists as a script rather than inline workflow YAML for one reason:
|
||||||
|
# release.yml only runs on `main` and on tags, so anything living inside it is
|
||||||
|
# unverifiable until a release is already happening — which is the worst
|
||||||
|
# possible moment to discover the version is wrong, because the failure mode
|
||||||
|
# is silent (an update nobody is offered looks exactly like being current).
|
||||||
|
# As a script it can be executed by a test on every push instead.
|
||||||
|
#
|
||||||
|
# The two clocks are deliberate and are NOT interchangeable:
|
||||||
|
#
|
||||||
|
# The NAME answers "is this the same code?" — so it must read identically on
|
||||||
|
# every lane that builds this commit. Commit time does that; build time
|
||||||
|
# prints two different strings for one thing.
|
||||||
|
#
|
||||||
|
# The CODE answers "may this be installed over that?" — so it must be
|
||||||
|
# monotonic BY CONSTRUCTION. Build time is; commit time is not (rebuild an
|
||||||
|
# older commit and it goes down, which on a phone is a refused install), and
|
||||||
|
# a commit COUNT is worse still, because it runs ahead on `dev` and inverts
|
||||||
|
# against `main`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
|
||||||
|
readonly REF="${1:-HEAD}"
|
||||||
|
|
||||||
|
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
||||||
|
# set these — the defaults are the real derivation.
|
||||||
|
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
||||||
|
if [ -z "${commit_epoch}" ]; then
|
||||||
|
commit_epoch="$(git log --format=%ct -1 "${REF}")"
|
||||||
|
fi
|
||||||
|
now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}"
|
||||||
|
|
||||||
|
if ! name="$(date -u -d "@${commit_epoch}" +%Y.%m.%d.%H%M 2>/dev/null)"; then
|
||||||
|
echo "version.sh: could not read a commit timestamp from '${commit_epoch}'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [ "${now_epoch}" -eq "${now_epoch}" ] 2>/dev/null; then
|
||||||
|
echo "version.sh: build timestamp '${now_epoch}' is not a number" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
code=$(( (now_epoch - EPOCH_2020) / 60 ))
|
||||||
|
|
||||||
|
# Assert the shape here, at the source. A malformed name builds, signs and
|
||||||
|
# publishes perfectly happily; it only surfaces later as an update channel
|
||||||
|
# that has quietly stopped offering anything.
|
||||||
|
if [[ ! "${name}" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}$ ]]; then
|
||||||
|
echo "version.sh: name '${name}' is not YYYY.MM.DD.HHMM" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A non-positive key means the build clock is set before 2020, and every
|
||||||
|
# comparison downstream would be nonsense.
|
||||||
|
if [ "${code}" -le 0 ]; then
|
||||||
|
echo "version.sh: ordering key '${code}' is not positive — build clock wrong?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# KEY=VALUE, which is also exactly $GITHUB_OUTPUT's format.
|
||||||
|
echo "name=${name}"
|
||||||
|
echo "code=${code}"
|
||||||
|
echo "tag=v${name}"
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guards the version derivation that stamps every build.
|
||||||
|
//
|
||||||
|
// These assertions used to be impossible to run. The derivation lived inline
|
||||||
|
// in release.yml, which triggers only on `main` and on tags — so a mistake in
|
||||||
|
// it could not surface until a release was already under way, and its failure
|
||||||
|
// mode is silence: a version nobody can compare looks exactly like being up to
|
||||||
|
// date, and nobody reports an update they were never offered.
|
||||||
|
//
|
||||||
|
// Moving it to ci/version.sh made it executable, so this runs on every push
|
||||||
|
// that touches the release machinery. That is the whole point of the file; the
|
||||||
|
// specific assertions below matter less than the fact that they run at all.
|
||||||
|
//
|
||||||
|
// The tests EXECUTE the script rather than asserting on its text, so they
|
||||||
|
// break when the behaviour changes rather than when the wording does.
|
||||||
|
|
||||||
|
// highestOldSchemeCode is the largest versionCode ever shipped under the
|
||||||
|
// retired commit-count scheme (v2026.09.09 shipped 1895). Every code the new
|
||||||
|
// scheme emits must clear it, or Android would refuse the upgrade as a
|
||||||
|
// downgrade and the update channel would be a one-way door.
|
||||||
|
const highestOldSchemeCode = 1895
|
||||||
|
|
||||||
|
func repoRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
t.Fatalf("no go.mod above %s", dir)
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVersion executes ci/version.sh with both clocks pinned, so the result is
|
||||||
|
// deterministic. Returns the parsed KEY=VALUE output.
|
||||||
|
func runVersion(t *testing.T, commitEpoch, nowEpoch string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := versionScript(t, commitEpoch, nowEpoch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ci/version.sh failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
parsed := map[string]string{}
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, "="); ok {
|
||||||
|
parsed[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionScript(t *testing.T, commitEpoch, nowEpoch string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
root := repoRoot(t)
|
||||||
|
cmd := exec.Command(filepath.Join(root, "ci", "version.sh"))
|
||||||
|
cmd.Dir = root
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"MINSTREL_COMMIT_EPOCH="+commitEpoch,
|
||||||
|
"MINSTREL_NOW_EPOCH="+nowEpoch,
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionName_IsCommitTimeToTheMinute(t *testing.T) {
|
||||||
|
// 2025-09-09T18:48:56Z
|
||||||
|
got := runVersion(t, "1757443736", "1789000920")
|
||||||
|
if want := "2025.09.09.1848"; got["name"] != want {
|
||||||
|
t.Errorf("name = %q, want %q", got["name"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HHMM is the segment most likely to be silently mangled, and it only bites
|
||||||
|
// for about a tenth of the day — a build just after midnight must emit "0042",
|
||||||
|
// never "42". A stripped leading zero shifts the segment by two orders of
|
||||||
|
// magnitude and reverses comparisons against every other build that day.
|
||||||
|
func TestVersionName_PadsTheMinuteSegment(t *testing.T) {
|
||||||
|
// 2026-09-10T00:42:00Z
|
||||||
|
got := runVersion(t, "1789000920", "1789000920")
|
||||||
|
if want := "2026.09.10.0042"; got["name"] != want {
|
||||||
|
t.Errorf("name = %q, want %q — leading zero lost?", got["name"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionName_DerivesFromCommitNotBuildClock(t *testing.T) {
|
||||||
|
// Same commit, two different build clocks: the NAME must not move, or a
|
||||||
|
// dev build and a main build of one commit would report different strings
|
||||||
|
// and the channel field would stop being the only thing separating them.
|
||||||
|
a := runVersion(t, "1757443736", "1789000920")
|
||||||
|
b := runVersion(t, "1757443736", "1789500000")
|
||||||
|
if a["name"] != b["name"] {
|
||||||
|
t.Errorf("name moved with the build clock: %q vs %q", a["name"], b["name"])
|
||||||
|
}
|
||||||
|
if a["code"] == b["code"] {
|
||||||
|
t.Errorf("code did NOT move with the build clock (%q) — it is not build-derived", a["code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionCode_IsMinutesSince2020AndClearsTheOldScheme(t *testing.T) {
|
||||||
|
got := runVersion(t, "1789000920", "1789000920")
|
||||||
|
code, err := strconv.Atoi(got["code"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("code %q is not an integer: %v", got["code"], err)
|
||||||
|
}
|
||||||
|
if want := (1789000920 - 1577836800) / 60; code != want {
|
||||||
|
t.Errorf("code = %d, want %d", code, want)
|
||||||
|
}
|
||||||
|
if code <= highestOldSchemeCode {
|
||||||
|
t.Errorf("code %d does not clear the retired commit-count scheme (%d) — "+
|
||||||
|
"Android would refuse the upgrade as a downgrade", code, highestOldSchemeCode)
|
||||||
|
}
|
||||||
|
if int64(code) > 2147483647 {
|
||||||
|
t.Errorf("code %d overflows versionCode's int32 ceiling", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tag is not chosen, it is the name with a `v`. Anything else reintroduces
|
||||||
|
// the mismatch between what a tag claims and what the artifact reports.
|
||||||
|
func TestTag_IsTheNameWithAPrefix(t *testing.T) {
|
||||||
|
got := runVersion(t, "1757443736", "1789000920")
|
||||||
|
if want := "v" + got["name"]; got["tag"] != want {
|
||||||
|
t.Errorf("tag = %q, want %q", got["tag"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guard that cannot fail is worse than no guard. These prove the script
|
||||||
|
// rejects the shapes it claims to reject, rather than emitting something
|
||||||
|
// plausible and letting it ship.
|
||||||
|
func TestVersionScript_RejectsUnusableClocks(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ name, commit, now string }{
|
||||||
|
{"unreadable commit timestamp", "notanumber", "1789000920"},
|
||||||
|
{"non-numeric build clock", "1789000920", "abc"},
|
||||||
|
{"build clock before 2020", "1789000920", "1000000000"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, err := versionScript(t, tc.commit, tc.now)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("script succeeded on %s, output: %s", tc.name, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pins the wiring, not the formula: if release.yml stops calling the script,
|
||||||
|
// every assertion above keeps passing while the thing that actually ships goes
|
||||||
|
// unguarded again. That silent decoupling is the specific regression here.
|
||||||
|
func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
||||||
|
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
yaml := string(body)
|
||||||
|
|
||||||
|
if !strings.Contains(yaml, "ci/version.sh") {
|
||||||
|
t.Error("release.yml no longer calls ci/version.sh — the derivation has drifted out of test coverage")
|
||||||
|
}
|
||||||
|
// The retired scheme, which must not come back. A presence check is safe
|
||||||
|
// against prose; the historical note in that file names the old formula
|
||||||
|
// only in comments, so match the executable form.
|
||||||
|
if strings.Contains(yaml, "$(git rev-list --count") {
|
||||||
|
t.Error("release.yml derives a commit count again — that is not monotonic across branches")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user