#!/usr/bin/env sh # # What version an artifact carries, derived from its OWN shipped files. # # One definition of HOW to derive, applied per artifact. One VALUE for unrelated # artifacts would re-version the desktop on a Kotlin-only commit and the phone on a # Rust-only one. # # Lives at the repo root, not under desktop/, because it serves three artifacts and # a shared thing filed under one consumer 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. # "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. # # WHY NOT A `-dev.N` PRERELEASE for the dev channel: 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. 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. From `desktop/src-tauri`, the pathspec `Cargo.toml` # matches `desktop/src-tauri/Cargo.toml`, a real file, so git returns the newest # commit touching THAT: a stale but non-empty answer the guard below cannot catch, # and a version that disagrees with what every other job derives for the same push. cd "$(git rev-parse --show-toplevel)" # A SHALLOW CLONE IS FATAL, and asked directly rather than inferred. # # Depth-1 sees one commit, so `git log -- ` answers about whatever happens to # be in that commit and the result is a too-LOW version — the unrecoverable # direction, arrived at silently with every lane green. # # The empty-result guard below catches only the case where NOTHING matches, not a # partial match returning a real, stale answer. `--is-shallow-repository` tests the # actual hazard instead of a symptom of it, costs no network, and covers every # artifact including the ones with no published value to compare against. if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then echo "version.sh: this is a SHALLOW clone — any version derived here would be" >&2 echo " too low, silently. Add 'fetch-depth: 0' to the checkout." >&2 exit 1 fi # 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. EPOCH=1577836800 # --- the shipped file sets --------------------------------------------------- # # ONE definition, read by every consumer: `should-build.sh` decides from it whether # a client lane builds, and the workflows carry no `paths:` filters of their own. # # 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. `should-build.sh` # skips a build whose version the channel already serves, so that combination would # serve 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. # # The base images are NOT listed. `Dockerfile` is in the set, so a `FROM` pinned # by digest would bring the base in for free. Resolving a digest at derive time # is WRONG: an external lookup 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 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 would run in a SUBSHELL, so its `exit` would end # only that subshell and the caller would continue with an empty string: `key # desktop` on a repo with no matching history would print the error and then emit # `1.0.-26297280` and exit 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)" >&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. # # The trap this whole script works around: `exit` inside a function called as # `$(...)` ends the SUBSHELL, not the script. `paths_for` is reached through # `$(paths_for "$1")`, so its `exit 2` alone would print the error and return an # EMPTY pathspec — and an empty pathspec matches everything, so `version.sh display # nope` would answer a confident version for an artifact that does not exist. 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. 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. With commit time, 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). guard-forward.sh # 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 `0.2.` line older # installs carry, or they are stranded on "up to date" permanently. 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. 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