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
138 lines
6.2 KiB
Bash
Executable File
138 lines
6.2 KiB
Bash
Executable File
#!/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=<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.
|
|
# 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'
|
|
)
|
|
|
|
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}"
|