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
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:
@@ -63,6 +63,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Derives a version, so it needs the whole history — see the note in
|
||||
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Gradle and Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -85,13 +89,17 @@ jobs:
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
version="$(sh ../desktop/packaging/build-version.sh)"
|
||||
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
||||
# same code?", so it comes from the COMMIT and a dev build and the main
|
||||
# build of one commit read identically. The CODE answers "may this be
|
||||
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
||||
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
|
||||
# leaves a channel you cannot get out of — so it comes from BUILD time,
|
||||
# which cannot go backwards. Commit time can.
|
||||
version="$(sh ../packaging/version.sh display android)"
|
||||
code="$(sh ../packaging/version.sh key android)"
|
||||
echo "name=$version" >> $GITHUB_OUTPUT
|
||||
# versionCode must RISE for Android to accept an update, and the run
|
||||
# number is the same monotonic counter the desktop's version scheme
|
||||
# already uses — no state carried between runs, and immune to the
|
||||
# shallow checkout that makes a commit count useless here.
|
||||
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "code=$code" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
|
||||
@@ -105,7 +113,7 @@ jobs:
|
||||
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
|
||||
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
|
||||
echo "Signed release build — $version (versionCode $code)"
|
||||
else
|
||||
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
||||
echo "variant=Debug" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -279,6 +279,10 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
|
||||
# and produces a too-low value silently, with the lane green (§6.1).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
@@ -286,20 +290,26 @@ jobs:
|
||||
# NOT bash `[[ ]]`.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
# The image's version is DERIVED from its own shipped files — including the
|
||||
# Android client it bakes in, which is why an APK-only change re-versions
|
||||
# it. One value and no ordering key: nothing compares a server image, so
|
||||
# §2 says do not invent one just because the other artifacts have one.
|
||||
#
|
||||
# This was a short sha on main and the literal "dev" elsewhere, which could
|
||||
# not answer "how old is this instance?" — the question that actually gets
|
||||
# asked of a self-hosted app running in several places.
|
||||
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/heads/main)
|
||||
# Production line: :latest tracks main's tip (rule 46). No :main tag;
|
||||
# the :<sha> above is the rollback unit. Version label = short sha.
|
||||
# the :<sha> above is the rollback unit.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,7 +64,9 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
|
||||
# this?" question unanswerable.
|
||||
# `|| true` so a miss falls through to the explicit error below rather than
|
||||
# aborting on pipefail with no explanation.
|
||||
PKGVER="$(sh "$SCRIPT_DIR/../build-version.sh" || true)"
|
||||
# The ORDERING KEY: pacman compares this, and it must match the filename the
|
||||
# bundle build produced (write-manifest.sh selects on it).
|
||||
PKGVER="$(sh "$SCRIPT_DIR/../../../packaging/version.sh" key desktop || true)"
|
||||
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; }
|
||||
|
||||
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# Echo the version this build should carry. One definition, used in three places
|
||||
# (both bundle jobs and the manifest writer) — if they ever disagreed, the app would
|
||||
# compare its own version against a manifest describing a different build, and the
|
||||
# updater would either offer nothing or loop forever offering the same thing.
|
||||
#
|
||||
# WHY DEV BUILDS NEED THEIR OWN VERSION AT ALL:
|
||||
# an updater decides by comparing semver. Every dev build carries the version in
|
||||
# Cargo.toml, so without this they'd all be `0.1.0` — an installed build would see a
|
||||
# manifest advertising the version it already has, conclude it was current, and never
|
||||
# update. The rolling channel needs a number that actually rises.
|
||||
#
|
||||
# The CI run number is that number: monotonic, already unique per build, and it needs
|
||||
# no state carried between runs. `0.1.0` + run 2932 becomes `0.1.2932`.
|
||||
#
|
||||
# Plain semver on purpose, NOT a `-dev.N` prerelease tag: prerelease versions sort
|
||||
# BELOW the release they qualify (`0.1.0-dev.5` < `0.1.0`), so a tagged build would
|
||||
# never update to a newer dev one, and Windows installer metadata wants a numeric
|
||||
# X.Y.Z anyway. Bumping the minor in Cargo.toml still wins over any dev build on the
|
||||
# old line, which is the ordering you want: 0.2.0 > 0.1.2932.
|
||||
set -eu
|
||||
|
||||
CARGO_TOML="$(dirname "$0")/../src-tauri/Cargo.toml"
|
||||
base="$(grep -m1 '^version' "$CARGO_TOML" | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
|
||||
# Dev builds only. Anything else (a v* tag, main) ships the version as written.
|
||||
if [ "${GITHUB_REF_NAME:-}" = "dev" ] && [ -n "${GITHUB_RUN_NUMBER:-}" ]; then
|
||||
printf '%s.%s\n' "${base%.*}" "$GITHUB_RUN_NUMBER"
|
||||
else
|
||||
printf '%s\n' "$base"
|
||||
fi
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/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
|
||||
|
||||
# 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>}"
|
||||
|
||||
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
|
||||
@@ -0,0 +1,210 @@
|
||||
"""The version derivation — `packaging/version.sh`.
|
||||
|
||||
These tests build their OWN git repo in a tmpdir rather than reading this one's
|
||||
history. Two reasons, and the second is the important one:
|
||||
|
||||
* They then need no `fetch-depth: 0` on the test lane, and cannot start passing or
|
||||
failing because somebody pushed.
|
||||
* They can commit to ONE artifact's file set at a time, which is the only way to
|
||||
assert the property this whole change exists for: that a Kotlin-only commit
|
||||
leaves the desktop's version alone. Against real history you can only observe
|
||||
whatever the last commits happened to touch.
|
||||
|
||||
Note 3127 §3 cites this repo as its example of the failure being fixed here — one
|
||||
generator feeding three artifacts, so a Rust-only commit re-versioned the phone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.sh"
|
||||
|
||||
# 2020-01-01T00:00:00Z, the counter epoch. Duplicated from the script deliberately:
|
||||
# a test that imported the value could not catch the value being changed, and moving
|
||||
# this epoch renumbers every artifact downwards (note 3127 §6.4).
|
||||
EPOCH = 1577836800
|
||||
|
||||
|
||||
def git(repo: Path, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=True, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def commit(repo: Path, path: str, when: int) -> None:
|
||||
"""Write a file and commit it with a FIXED committer date.
|
||||
|
||||
`%ct` is the committer date, so both GIT_AUTHOR_DATE and GIT_COMMITTER_DATE have
|
||||
to be pinned or the test is timing-dependent.
|
||||
"""
|
||||
f = repo / path
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text(f"{when}\n")
|
||||
git(repo, "add", "-A")
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t",
|
||||
"commit", "-q", "-m", f"touch {path}"],
|
||||
check=True, capture_output=True, text=True,
|
||||
# EXTEND the environment rather than replacing it: a minimal env is enough
|
||||
# for git here but not necessarily inside the CI container, and a test that
|
||||
# fails only there is worse than no test.
|
||||
env={**os.environ,
|
||||
"GIT_AUTHOR_DATE": f"@{when} +0000", "GIT_COMMITTER_DATE": f"@{when} +0000"},
|
||||
)
|
||||
|
||||
|
||||
def version(repo: Path, what: str, artifact: str) -> str:
|
||||
return subprocess.run(
|
||||
["sh", str(SCRIPT), what, artifact],
|
||||
cwd=repo, check=True, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
"""A repo with one commit per artifact area, at three known instants."""
|
||||
git(tmp_path, "init", "-q", "-b", "dev")
|
||||
# 2026-08-28 in UTC, an hour apart so each is distinguishable.
|
||||
commit(tmp_path, "core/lib.rs", 1787900400) # 2026-08-28 07:00 — shared
|
||||
commit(tmp_path, "android/app/build.gradle.kts", 1787904000) # 08:00 — android only
|
||||
commit(tmp_path, "desktop/src-tauri/main.rs", 1787907600) # 09:00 — desktop only
|
||||
return tmp_path
|
||||
|
||||
|
||||
# --- shape -------------------------------------------------------------------
|
||||
|
||||
def test_display_is_zero_padded_calver(repo: Path) -> None:
|
||||
"""`YYYY.MM.DD.HHMM`, padded. Padding is what makes it sort as text as well as
|
||||
numerically, and what keeps two lanes from emitting forms one character apart."""
|
||||
for artifact in ("desktop", "android", "server"):
|
||||
assert re.fullmatch(r"\d{4}\.\d{2}\.\d{2}\.\d{4}", version(repo, "display", artifact))
|
||||
|
||||
|
||||
def test_display_is_the_commit_instant_in_utc(repo: Path) -> None:
|
||||
# The desktop's newest commit is 09:00 UTC on 2026-08-28.
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.0900"
|
||||
# Android's is an hour earlier, and it does not see the desktop commit at all.
|
||||
assert version(repo, "display", "android") == "2026.08.28.0800"
|
||||
|
||||
|
||||
# --- the property the whole change exists for --------------------------------
|
||||
|
||||
def test_a_desktop_commit_does_not_move_android(repo: Path) -> None:
|
||||
before = version(repo, "display", "android")
|
||||
commit(repo, "desktop/src-tauri/other.rs", 1787911200) # 10:00
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.1000"
|
||||
assert version(repo, "display", "android") == before
|
||||
|
||||
|
||||
def test_an_android_commit_does_not_move_the_desktop(repo: Path) -> None:
|
||||
before = version(repo, "display", "desktop")
|
||||
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
|
||||
assert version(repo, "display", "android") == "2026.08.28.1000"
|
||||
assert version(repo, "display", "desktop") == before
|
||||
|
||||
|
||||
def test_a_shared_core_commit_moves_both(repo: Path) -> None:
|
||||
"""`core/` is genuinely in both sets — the .so and the desktop binary are built
|
||||
from it — so this is correct rather than a leak between them."""
|
||||
commit(repo, "core/src/sync.rs", 1787911200) # 10:00
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.1000"
|
||||
assert version(repo, "display", "android") == "2026.08.28.1000"
|
||||
|
||||
|
||||
def test_the_server_set_contains_the_android_set(repo: Path) -> None:
|
||||
"""The image BAKES IN the APK, so an APK-only change changes what the image
|
||||
ships. Note 3127 §3's bundled-artifact trap; FC's web image embeds the extension
|
||||
the same way, and Roundtable needed a bespoke workflow for want of modelling it."""
|
||||
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
|
||||
assert version(repo, "display", "server") == "2026.08.28.1000"
|
||||
assert "android" in version(repo, "paths", "server")
|
||||
|
||||
|
||||
def test_the_build_recipe_is_in_the_set(repo: Path) -> None:
|
||||
"""A workflow file is not shipped, but change a build flag 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."""
|
||||
commit(repo, ".forgejo/workflows/desktop.yml", 1787911200) # 10:00
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.1000"
|
||||
|
||||
|
||||
# --- the ordering keys -------------------------------------------------------
|
||||
|
||||
def test_the_desktop_key_is_valid_semver(repo: Path) -> None:
|
||||
"""THE test that keeps the update channel alive. Tauri parses `latest.json`'s
|
||||
version with the semver crate AT DESERIALIZATION — a string it cannot parse does
|
||||
not sort low, it makes the whole feed fail to load and every client report "no
|
||||
update available" forever. Exactly three numeric segments, no leading zeros."""
|
||||
key = version(repo, "key", "desktop")
|
||||
assert re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", key), key
|
||||
|
||||
|
||||
def test_the_desktop_key_and_display_describe_one_build(repo: Path) -> None:
|
||||
"""They are allowed to look unrelated. They are not allowed to disagree about
|
||||
WHICH build — so both come from one timestamp."""
|
||||
key = version(repo, "key", "desktop")
|
||||
minutes = int(key.split(".")[2])
|
||||
assert EPOCH + minutes * 60 == 1787907600 # the desktop's newest commit, 09:00
|
||||
|
||||
|
||||
def test_the_desktop_key_clears_what_is_already_installed(repo: Path) -> None:
|
||||
"""`0.0.<minutes>` reads best as "not a version" and would have stranded every
|
||||
dev user: minor 0 < 2 puts it below the installed `0.2.466` line, and "up to
|
||||
date" forever is the direction you cannot recover from."""
|
||||
key = tuple(int(p) for p in version(repo, "key", "desktop").split("."))
|
||||
assert key > (0, 2, 466)
|
||||
assert key > (0, 2, 999999) # and above any run number that line could reach
|
||||
|
||||
|
||||
def test_the_android_key_is_an_int_android_will_accept(repo: Path) -> None:
|
||||
"""Build time, not commit time: Android HARD-FAILS a downgrade with
|
||||
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
|
||||
the key must be monotonic by construction rather than by a CI guard."""
|
||||
code = int(version(repo, "key", "android"))
|
||||
assert code > 1_000_000 # far above the run numbers it replaces (~470)
|
||||
assert code < 2_100_000_000 # Android's Int ceiling
|
||||
|
||||
|
||||
def test_the_server_has_no_ordering_key(repo: Path) -> None:
|
||||
"""Nothing compares a server image — no updater, no install gate. §2: do not add
|
||||
an ordering key because the other artifacts have one."""
|
||||
r = subprocess.run(["sh", str(SCRIPT), "key", "server"],
|
||||
cwd=repo, capture_output=True, text=True)
|
||||
assert r.returncode != 0
|
||||
assert "no ordering key" in r.stderr
|
||||
|
||||
|
||||
# --- failing loudly ----------------------------------------------------------
|
||||
|
||||
def test_an_unknown_artifact_is_rejected(repo: Path) -> None:
|
||||
r = subprocess.run(["sh", str(SCRIPT), "display", "nope"],
|
||||
cwd=repo, capture_output=True, text=True)
|
||||
assert r.returncode != 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("what", ["display", "key"])
|
||||
def test_no_matching_history_fails_rather_than_guessing(tmp_path: Path, what: str) -> None:
|
||||
"""The shallow-clone failure (note 3127 §6.1), which is the one that matters:
|
||||
depth-1 sees one commit, `git log -- <paths>` finds nothing for most artifacts,
|
||||
and a script that shrugged would emit a too-LOW version with the lane green.
|
||||
Too-low is unrecoverable — every installed client is stranded.
|
||||
|
||||
BOTH REQUESTS, and the parametrize is the point rather than thoroughness. The
|
||||
first version of this guard `exit 1`-ed inside a function called as `$(...)`,
|
||||
which ends the SUBSHELL and not the script. `display` still failed — but only
|
||||
because `date` then choked on an empty string. `key` printed the error, emitted
|
||||
`1.0.-26297280`, and exited ZERO. One path was covered and the other was broken
|
||||
in exactly the way the guard existed to prevent."""
|
||||
git(tmp_path, "init", "-q", "-b", "dev")
|
||||
commit(tmp_path, "README.md", 1787900400) # in no artifact's set
|
||||
r = subprocess.run(["sh", str(SCRIPT), what, "desktop"],
|
||||
cwd=tmp_path, capture_output=True, text=True)
|
||||
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
|
||||
assert "shallow" in r.stderr
|
||||
assert r.stdout.strip() == "", f"{what} emitted a value anyway: {r.stdout!r}"
|
||||
Reference in New Issue
Block a user