#!/usr/bin/env sh # # What version an artifact carries, derived from its OWN shipped files. # # Replaces desktop/packaging/build-version.sh, which was one generator feeding the # desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`. A Kotlin-only commit # re-versioned the desktop; a Rust-only commit re-versioned the phone. It read as # tidy — one definition, no drift — which is exactly why it survived review. One # definition of HOW to derive is right; one VALUE for unrelated artifacts is not. # (Note 3127 §3, which cites this repo as its example of the failure.) # # Lives at the repo root, not under desktop/, because it now serves three artifacts # and a shared thing filed under one consumer is how it ends up owned by that one. # # version.sh display the human-readable version — 2026.08.28.1815 # version.sh key the ordering key a comparator reads # version.sh paths the shipped file set (for tests and debugging) # # TWO VALUES, NOT ONE, and which you want depends on the question: # # "is this the same code?" -> display. A dev build and the main build of one # commit read identically, because they ARE the # same bytes (note 3127 §2, reason 4). # "may this replace that?" -> key. What an updater or an install gate # compares, and never shown to a person. # # The desktop needs both because Tauri's updater parses `latest.json`'s version with # the semver crate, and `2026.08.28.1815` is not valid semver — four segments where # the spec allows three, and `08` is a leading zero, which it forbids outright. A # non-semver string does not sort low: the feed fails to DESERIALIZE and every client # reports "no update available" forever. So the platform's field takes an opaque key # and the display version lives beside it. See #3142's spike. # # WHY NOT A `-dev.N` PRERELEASE for the dev channel — carried over from the script # this replaces, because it is a real finding and the reasoning is not obvious: # a prerelease sorts BELOW the release it qualifies (`0.1.0-dev.5` < `0.1.0`), so a # dev build could never be offered as an update to a tagged one, and Windows # installer metadata wants a numeric X.Y.Z anyway. The channel goes in a sibling # field, never in the version — note 3127 §7, and rule 149. set -eu # ANCHOR AT THE REPO ROOT BEFORE ANYTHING ELSE. # # `git log -- ` resolves pathspecs relative to the CURRENT DIRECTORY, not to # the repo root. Callers run from wherever suits them — the desktop build from # `desktop/src-tauri`, the Android build from `android`, the manifest job from the # root — so without this the same request answers differently per caller. # # It is not a tidy failure. Measured on run 4796, one push produced THREE versions: # the desktop build (cwd `desktop/src-tauri`) said 1.0.3494522, while the pacman # packager and the manifest job both said 1.0.3502131. The build's pathspec had # matched `desktop/src-tauri/Cargo.toml` — a real file — so git returned the newest # commit touching THAT, six days stale. Non-empty, so the guard below could not fire; # the manifest then found no bundle matching its own answer and the lane went red for # a reason two steps removed from the cause. # # The Android job failed loudly in the same run only because its pathspec happened to # match nothing from `android/`. Same bug, louder symptom, pure luck. cd "$(git rev-parse --show-toplevel)" # 2020-01-01T00:00:00Z. The counter epoch, and it must NEVER move: shifting it # renumbers every artifact downwards, which is the one direction you cannot recover # from (note 3127 §6.4). EPOCH=1577836800 # --- the shipped file sets --------------------------------------------------- # # ONE definition, read by every consumer. The `paths:` filters in the three # workflows are a second, independent statement of the same fact today; they come # out in step 6 when skip-if-exists replaces them. Until then, a change here that is # not mirrored there means a lane that does not fire — check both. # # Read off what actually PACKAGES each artifact, not off intuition. Miss a file and # a stale build keeps its version; include one that does not ship and you re-version # for nothing. # # THE BUILD DEFINITION IS IN THE SET, and it is the part that is easy to leave out. # A workflow file is not "shipped" — but change a Gradle flag or a `cargo tauri # build` argument and the bytes change while the source does not. Once step 6 skips # a build whose version already exists, that combination serves the OLD artifact on # a green run: exactly the "miss a file and a stale build keeps its version" failure, # arriving through the build recipe rather than the source. Same reason `packaging/` # is in every set: this script decides identity, so a change to it is a change to # what each artifact claims to be. paths_for() { case "$1" in # tauri's generate_context! embeds the BUILT frontend in the binary, so a # frontend commit is a desktop change even though nothing under desktop/ moved. desktop) echo "desktop core frontend Cargo.toml Cargo.lock .forgejo/workflows/desktop.yml packaging" ;; # The .so is cross-compiled from core/ through uniffi. android) echo "android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;; # BUNDLED ARTIFACT: the image bakes in the Android client (ci.yml fetches the APK # from the channel release and copies it into the package). So the image's set # must contain the APK's set — an APK-only change genuinely changes what this # image ships. Note 3127 §3 names this trap; FC's web image embeds the extension # the same way. # # The base images are NOT listed and do not need to be: `Dockerfile` is in the # set, so pinning `FROM` by digest (step 6) puts the base inside the set for # free. Resolving a digest at derive time would work too and is WRONG — it is an # external lookup, which §7's corollary forbids because it makes the value depend # on when it was computed. server) echo "src frontend alembic alembic.ini Dockerfile pyproject.toml .forgejo/workflows/ci.yml android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;; *) echo "version.sh: unknown artifact '$1'" >&2; exit 2 ;; esac } # Sets TS to the newest commit timestamp touching this artifact's files, or exits. # # EMPTY IS FATAL, deliberately. A shallow clone sees one commit and derives a # too-low value with every lane green — the failure landmine §6.1 exists for, and # the unrecoverable direction. Every job that calls this needs `fetch-depth: 0`; # this is what turns forgetting it into a red lane instead of a stranded channel. # # SETS A GLOBAL RATHER THAN ECHOING, and that is not a style preference. Written as # `$(commit_ts desktop)` the function runs in a SUBSHELL, so its `exit` ends only # that subshell and the caller continues with an empty string. Measured before this # was fixed: `key desktop` on a repo with no matching history printed the error to # stderr and then emitted `1.0.-26297280` and exited ZERO. A guard that reports a # problem and does not stop is worse than none — it looks like it is working. resolve_ts() { # Unquoted on purpose: the path list is several words. # shellcheck disable=SC2046 TS="$(git log --format=%ct -1 HEAD -- $(paths_for "$1"))" if [ -z "$TS" ]; then echo "version.sh: no commit touches $1's file set — is this a shallow clone?" >&2 echo " (needs fetch-depth: 0; see note 3127 §6.1)" >&2 exit 1 fi } minutes_since_epoch() { echo $(( ($1 - EPOCH) / 60 )); } what="${1:?usage: version.sh }" artifact="${2:?usage: version.sh }" # VALIDATED HERE, in the parent shell, and not left to `paths_for`'s default arm. # # Third instance of one trap in this script, so it is worth stating plainly: `exit` # inside a function called as `$(...)` ends the SUBSHELL, not the script. `paths_for` # is reached through `$(paths_for "$1")`, so its `exit 2` printed the error and # returned an EMPTY pathspec — and an empty pathspec matches everything, so # `version.sh display nope` answered `2026.08.28.0900` and exited 0. A confident # version for an artifact that does not exist. # # The other two were the shallow-clone guard on the `key` path (emitted # `1.0.-26297280`, exit 0) and the same guard on `display` (which failed only because # `date` then choked on an empty string — luck, not design). Each was found by a # different mechanism; none by reading the code. If you add a guard to this file, # make sure it runs where the script does. case "$artifact" in desktop|android|server) : ;; *) echo "version.sh: unknown artifact '$artifact' (want desktop, android or server)" >&2 exit 2 ;; esac case "$what" in paths) paths_for "$artifact" ;; display) # One shape for every human-readable version in this repo, and for the release # tag: YYYY.MM.DD.HHMM, zero-padded, UTC (note 3127 §1). Padded so it sorts as # text as well as numerically, and so two lanes cannot emit forms one character # apart. resolve_ts "$artifact" date -u -d "@$TS" +%Y.%m.%d.%H%M ;; key) case "$artifact" in desktop) # COMMIT time. The desktop is a one-value system to Tauri — its comparator # reads the version name — so this key is also what lands in bundle # filenames and .deb metadata. Commit time buys the property in §2 reason # (4): the last dev build before a PR and the main build from it are the # same bytes and derive the same key, so the artifact is reused rather than # rebuilt and re-signed under a new name. # # Commit time CAN go backwards (rebuild an older commit). The backwards # guard in step 5 is the whole mitigation, and the desktop's failure there # is soft: an update is not offered. Contrast Android below. # # `1.0.` and not `0.0.`: the minor must clear the installed `0.2.` line # or every dev user is stranded on "up to date" permanently. Checked against # the live feed (0.2.466), not against what we thought we had published. resolve_ts desktop echo "1.0.$(minutes_since_epoch "$TS")" ;; android) # BUILD time, and the asymmetry with the desktop is deliberate. Android # HARD-FAILS an install on a downgrade (INSTALL_FAILED_VERSION_DOWNGRADE) # and leaves a channel you cannot get out of, so its key must be monotonic # BY CONSTRUCTION rather than by a guard that runs in CI. Build time cannot # go backwards; commit time can. # # An Int, which is what Android compares. ~3.5M today against a 2.1e9 # ceiling — roughly four thousand years of headroom. minutes_since_epoch "$(date -u +%s)" ;; server) # NO ORDERING KEY. Nothing compares the server image: no updater, no install # gate, and `:latest` is moved by the registry rather than chosen by a # client. §2 is explicit that an artifact with nothing to compare needs only # a name — do not add one because the other two have one. echo "version.sh: the server has no ordering key; use 'display'" >&2 exit 2 ;; *) echo "version.sh: unknown artifact '$artifact'" >&2; exit 2 ;; esac ;; *) echo "version.sh: unknown request '$what' (want display, key or paths)" >&2 exit 2 ;; esac