versioning: each artifact derives from its own files, with the clock picked per value
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Canceled after 13s
CI & Build / integration (push) Canceled after 13s
CI & Build / Build & push image (push) Canceled after 0s
Android / Kotlin + Rust (APK) (push) Failing after 14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Failing after 4s

Step 4 of M314. `desktop/packaging/build-version.sh` was one generator feeding
the desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`, so a
Kotlin-only commit re-versioned the desktop and a Rust-only commit
re-versioned the phone. Note 3127 §3 cites this repo as its example of that
failure. It is replaced by `packaging/version.sh` — one definition of HOW to
derive, three file sets, and the sets in one place.

Lives at the repo root rather than under desktop/, because it serves three
artifacts now and a shared thing filed under one consumer ends up owned by it.

## Two values, and the clock chosen per value (§2)

  desktop  key      1.0.<minutes since 2020-01-01>   commit time
  desktop  display  2026.08.28.0900                  commit time  (#3181 shows it)
  android  versionName                               commit time
  android  versionCode  <minutes since 2020>         BUILD time
  server   version  2026.08.28.0900                  commit time, no ordering key

Every human-readable version in the repo is now one shape. The two exceptions
are not version names at all — they are bare monotonic integers a comparator
reads and nobody quotes.

The desktop needs a separate key because Tauri parses `latest.json` with the
semver crate and `2026.08.28.0900` fails it twice (four segments, and `08` is a
leading zero). `1.0.` and not `0.0.`: the minor has to clear the installed
`0.2.466` line or every dev user is stranded on "up to date" permanently.

Android's code comes from BUILD time while the desktop's key comes from COMMIT
time, deliberately. Android hard-fails a downgrade with
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
its key must be monotonic by construction; the desktop merely declines to offer
an update, which a guard can catch.

## The bug this found in itself

The shallow-clone guard `exit 1`-ed inside a function called as `$(...)` —
which ends the SUBSHELL, not the script. `display` still failed, but only
because `date` then choked on the empty string. `key` printed the error to
stderr, emitted `1.0.-26297280`, and exited ZERO.

That is precisely the failure the guard exists to prevent: a too-low version on
a green lane, and too-low is the direction you cannot recover from. It resolves
into a global in the parent shell now. The test is parametrized over both
requests, because one path was covered and the other was broken in exactly the
way the covered one was meant to rule out.

## Also

`fetch-depth: 0` on every job that derives — four of them, and only ci.yml's
gate had it. Depth-1 is silently wrong rather than loudly broken (§6.1).

The file sets include each artifact's BUILD RECIPE (its workflow, and
`packaging/`). A workflow file is not shipped, but change a Gradle flag and the
bytes change while the source does not — and once step 6 skips a build whose
version already exists, that serves the OLD artifact on a green run.

The base images are deliberately NOT resolved at derive time: that is an
external lookup, which §7's corollary forbids. `Dockerfile` is already in the
server's set, so pinning `FROM` by digest in step 6 puts the base inside the set
for free.

`build-version.sh` is deleted, its last consumer (the pacman packager) moved
over, and the one finding worth keeping out of its header — why not a `-dev.N`
prerelease — is preserved in the successor.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 20:51:39 -04:00
co-authored by Claude Opus 5
parent c268ae4f23
commit c5044339a1
7 changed files with 462 additions and 52 deletions
+41 -8
View File
@@ -64,6 +64,14 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# tauri's generate_context! embeds the built frontend at compile time, so the
# frontend must exist before any cargo compile (clippy/test/build), not just
@@ -123,8 +131,12 @@ jobs:
else
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
fi
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
@@ -273,6 +285,14 @@ jobs:
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# Same reason as the Linux job: generate_context! embeds the built frontend
# at compile time, so it must exist before cargo runs.
@@ -306,8 +326,12 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -393,6 +417,14 @@ jobs:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
- name: Write and publish latest.json
env:
@@ -404,10 +436,11 @@ jobs:
echo "manifest to write. Add the secret to enable in-app updates."
exit 0
fi
# The SAME helper the bundles were built with — a second derivation here
# could drift, and a manifest whose version doesn't match the binary it
# points at is an updater that never settles.
version="$(sh desktop/packaging/build-version.sh)"
# The SAME helper AND the same request the bundles were built with — a
# second derivation here could drift, and a manifest whose version doesn't
# match the binary it points at is an updater that never settles. It must
# be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)"
# POSIX `case` (rule 81). For a tag, GITHUB_REF_NAME is the tag name, so
# the default arm is the tag path.
case "${GITHUB_REF_NAME}" in