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 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
Three failures on c504433, two root causes, and the interesting one is that
`git log -- <paths>` resolves pathspecs against the CURRENT DIRECTORY.
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 one push produced THREE versions:
desktop build 1.0.3494522 <- six days stale
pacman packager 1.0.3502131
manifest job 1.0.3502131
The build's pathspec had matched `desktop/src-tauri/Cargo.toml` — a real file
— so git answered with the newest commit touching THAT. Non-empty, so the
shallow-clone guard could not fire; the manifest then found no bundle matching
its own answer and the lane went red two steps from the cause. The Android job
failed loudly in the same run only because ITS pathspec happened to match
nothing from `android/`. Same bug, luckier symptom.
The script `cd`s to `git rev-parse --show-toplevel` before doing anything now,
and the test asserts every artifact answers identically from four directories.
## And a third instance of the trap that bit yesterday
The unit test caught it: `version.sh display nope` printed "unknown artifact"
to stderr and then answered `2026.08.28.0900` with exit 0. `paths_for` is
reached through `$(paths_for "$1")`, so its `exit 2` ended the subshell,
returned an EMPTY pathspec — and an empty pathspec matches everything.
That is now three occurrences of one mistake in one file: the shallow-clone
guard on `key` (emitted `1.0.-26297280`, exit 0), the same guard on `display`
(which failed only because `date` then choked on the empty string), and this.
Each was found by a different mechanism and none by reading the code. The
artifact is validated in the parent shell now, and the file says so where the
next guard would be written.
Both tests assert on STDOUT as well as the exit code. The exit code alone
passed for `display nope` while stdout carried a lie.
#3144
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
11 KiB
Bash
Executable File
221 lines
11 KiB
Bash
Executable File
#!/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 <artifact> the human-readable version — 2026.08.28.1815
|
|
# version.sh key <artifact> the ordering key a comparator reads
|
|
# version.sh paths <artifact> 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 -- <paths>` 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 <display|key|paths> <desktop|android|server>}"
|
|
artifact="${2:?usage: version.sh <display|key|paths> <desktop|android|server>}"
|
|
|
|
# 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.<run>` 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
|