diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index c83d67f..6382b38 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 1a9817e..81cf617 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 : above is the rollback unit. Version label = short sha. + # the : 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 diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index a7a13a4..0add310 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -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 -- ` 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 -- ` 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 -- ` 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 diff --git a/desktop/packaging/arch/package-prebuilt.sh b/desktop/packaging/arch/package-prebuilt.sh index e9e449a..0d1fa60 100755 --- a/desktop/packaging/arch/package-prebuilt.sh +++ b/desktop/packaging/arch/package-prebuilt.sh @@ -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 diff --git a/desktop/packaging/build-version.sh b/desktop/packaging/build-version.sh deleted file mode 100755 index 9f3dd5b..0000000 --- a/desktop/packaging/build-version.sh +++ /dev/null @@ -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 diff --git a/packaging/version.sh b/packaging/version.sh new file mode 100755 index 0000000..d8afe9f --- /dev/null +++ b/packaging/version.sh @@ -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 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 + +# 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 }" + +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 diff --git a/tests/test_versioning.py b/tests/test_versioning.py new file mode 100644 index 0000000..ead2e2a --- /dev/null +++ b/tests/test_versioning.py @@ -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.` 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 -- ` 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}"