#!/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 timestamp of the newest # commit that CHANGED SOMETHING SHIPPED (see SHIPPED) # code= ordering key, minutes since 2020-01-01 at BUILD time # tag=v what a release of this commit must be called # # Usage: ci/version.sh [] (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. # The paths that do NOT ship, in either artifact. Everything else counts. # # A DENYLIST, and the direction is the whole point. As an allowlist, the list # has to 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. That is a build lying about what it is. Inverted, # new content counts by default and the only way to wrongly EXCLUDE something # is to name it here deliberately. # # The two error directions are not symmetric, which is why this is not taste: # wrongly excluded → changed artifact, unchanged version. A silent lie. # wrongly included → version moves when nothing shipped. Cosmetic noise in # a string nobody sorts. # # THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list # is shorter than it looks like it should be. The server image ships cmd/, # internal/, shared/, web/, config.example.yaml and client/; the APK ships # android/. Neither ships the other's sources — but excluding android/ here # would stop an Android-only commit from moving the APK's OWN version, which # is the dangerous direction. So this 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. # # The family's other repos (roundtable / roundtable-android) each keep a # tighter list because they are separate repos with one artifact apiece. Do # not copy theirs onto this one. readonly SHIPPED=( . ':!.gitea' # CI workflows — including this script's own caller ':!ci' # CI scripts — including this script ':!docs' ':!tools' # asset/font generators; their OUTPUT ships, they do not ':!deploy' # test-database bootstrap SQL ':!bin' # local `make build` output ':!*.md' ':!Makefile' ':!docker-compose.yml' ':!.env.example' ':!.gitignore' ':!.dockerignore' ':!renovate.json' ':!.golangci.yml' # TESTS DO NOT SHIP, so they must not re-version an artifact. # # Named as globs rather than a directory because this repo has no tests/ # tree to exclude: Go tests sit inline beside the code they cover, and the # web suite sits beside its modules. `go build` drops *_test.go outright and # the Vite build never imports a .test.ts, so neither reaches an artifact. # # A commit touching a test AND its source still moves the version — the # source path matches on its own. Only a test-ONLY commit is inert, which is # the whole intent. # # Patterns match what exists today and nothing speculative: there are no # .spec.* files, no __tests__/ directories and no androidTest/ tree. If any # appear they will re-version until named here, which is the harmless # direction and the reason this list is a denylist. ':!*_test.go' # 158 files, inline beside the code ':!*.test.ts' # 114 files ':!*.test.js' ':!android/app/src/test' # JVM unit tests; no androidTest tree exists ':!web/vitest.config.ts' # test-harness config, not build config ':!web/vitest.setup.ts' ) commit_epoch="${MINSTREL_COMMIT_EPOCH:-}" if [ -z "${commit_epoch}" ]; then commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")" # Loudly, on purpose. A silent fallback here is the landmine this whole # script exists to avoid: a plausible-looking version that is quietly wrong, # on a green run. Realistically this means a shallow clone (no commit in # range touches the shipped set) rather than a repo of pure CI config. if [ -z "${commit_epoch}" ]; then echo "version.sh: no commit under '${REF}' touches the shipped file set — shallow clone? (needs fetch-depth: 0)" >&2 exit 1 fi 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 # Android's versionCode is a signed 32-bit int and the platform refuses an APK # whose code exceeds it. At ~525k minutes a year this is four thousand years # away in normal operation, so the realistic cause is a build machine with a # badly wrong clock — which produces a code that is not merely too large but # also unreachably high, permanently blocking every real build that follows # from ever outranking it. Cheaper to refuse the build than to discover that # from a phone that will not update. readonly VERSION_CODE_CEILING=2147483647 if [ "${code}" -gt "${VERSION_CODE_CEILING}" ]; then echo "version.sh: ordering key '${code}' exceeds versionCode's int32 ceiling — 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}"