Files
thoughtsync/tests/test_versioning.py
T
bvandeusenandClaude Opus 5 0ab7d94294
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 17s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m19s
versioning: refuse to publish a version below what the channel already serves
Step 5 of M314, note 3127 §6.3. Everything else in this milestone derives a
number and trusts it; this compares the derived value against what the channel
is actually serving and fails the lane if it went down.

Too-low is the unrecoverable direction: every installed client reports "up to
date" forever, and no later build fixes it until one climbs back above the bad
number. #2183 and #2993 are both that symptom.

## Two hazards, two mechanisms

A shallow clone is now tested DIRECTLY, in `version.sh`, via
`--is-shallow-repository`. The empty-result guard only caught the case where
nothing matched — and run 4796 showed the worse one, where a partial match
returned a real six-days-stale answer. Asking the question outright costs no
network and covers artifacts with nothing published to compare against.

`guard-forward.sh` handles the rest: a squash or rebase merge rewriting the
committer date, a rebuild of an older commit, and clock skew between runners.

## The comparison is per artifact, and the operator differs

  desktop  derived >= published   commit time, so equality is the ORDINARY
                                  no-change case and `<=` would fail every
                                  build that changed nothing
  android  derived >  published   build time, so equality means two builds in
                                  one minute — and Android refuses to install
                                  an APK whose versionCode does not RISE

The server is deliberately unguarded: nothing compares its version, `:latest`
moves regardless, and rule 145 removed the version tags that would be the
published list. A too-low value there is a wrong date in a footer, not a
stranded client. It still gets the shallow-clone check.

## Proved to fire, not assumed

Cloned the repo, checked out a commit eight back, ran the guard against the
LIVE dev feed:

  at the tip     derived 1.0.3502151, published 1.0.3502151  -> pass
  eight back     derived 1.0.3501535, published 1.0.3502151  -> FAILS
  android tip    derived 3502171,     published 3502152      -> pass
  stable         derived 1.0.3502151, published 0.2.0        -> pass

That last row is worth keeping: stable still advertises the bare `0.2.0` from
the old Cargo.toml scheme, so the transition orders upward on BOTH channels,
not just the one being exercised.

A channel with nothing published passes rather than failing — otherwise the
first publish to a new channel could never happen.

The guard runs BEFORE the build in all three lanes, so a bad derivation costs
seconds rather than a five-minute compile and a publish to undo.

`compare` is exposed as an explicit mode so the ordering is testable without a
network and inspectable without a push — 16 cases including `1.0.9 < 1.0.10`,
which a string compare gets exactly backwards.

#3145

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:33:00 -04:00

307 lines
15 KiB
Python

"""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"
GUARD = Path(__file__).resolve().parent.parent / "packaging" / "guard-forward.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, *, subdir: str = "") -> str:
return subprocess.run(
["sh", str(SCRIPT), what, artifact],
cwd=repo / subdir if subdir else 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"
# --- where it is called from -------------------------------------------------
@pytest.mark.parametrize("subdir", ["", "desktop/src-tauri", "android", "core"])
def test_the_answer_does_not_depend_on_the_caller_s_directory(repo: Path, subdir: str) -> None:
"""`git log -- <paths>` resolves pathspecs relative to the CURRENT DIRECTORY.
Every caller runs from somewhere different — the desktop build from
`desktop/src-tauri`, the Android build from `android`, the manifest from the root
— so without an anchor the same request answers differently per caller.
This is not hypothetical and it is not a loud failure. Run 4796 produced THREE
versions from one push: the desktop build said 1.0.3494522 while the manifest and
the pacman packager said 1.0.3502131, because the build's pathspec matched
`desktop/src-tauri/Cargo.toml` — a real file, six days stale. Non-empty, so the
shallow-clone guard could not fire. The Android job failed loudly in the same run
only because ITS pathspec happened to match nothing; same bug, luckier symptom."""
(repo / "desktop/src-tauri").mkdir(parents=True, exist_ok=True)
(repo / "core").mkdir(parents=True, exist_ok=True)
assert version(repo, "display", "desktop", subdir=subdir) == "2026.08.28.0900"
assert version(repo, "key", "desktop", subdir=subdir) == version(repo, "key", "desktop")
def test_every_artifact_agrees_across_directories(repo: Path) -> None:
"""The property the manifest job actually depends on: the value the bundle was
built with and the value the manifest looks for must be the same string, and they
are computed by different jobs in different directories."""
for artifact in ("desktop", "android", "server"):
root = version(repo, "display", artifact)
assert version(repo, "display", artifact, subdir="desktop/src-tauri") == root
assert version(repo, "display", artifact, subdir="android") == root
# --- 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 ----------------------------------------------------------
@pytest.mark.parametrize("what", ["display", "key", "paths"])
def test_an_unknown_artifact_is_rejected(repo: Path, what: str) -> None:
"""It was not. `paths_for`'s `exit 2` ran inside `$(paths_for ...)`, so it printed
the error, returned an EMPTY pathspec — which matches everything — and answered
`2026.08.28.0900` with exit 0. A confident version for an artifact that does not
exist, which is the worst kind of wrong for a value nothing else can contradict.
Asserting on stdout as well as the exit code, because the exit code alone passed
for `display` in the first version of this file while stdout carried a lie."""
r = subprocess.run(["sh", str(SCRIPT), what, "nope"],
cwd=repo, capture_output=True, text=True)
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
assert r.stdout.strip() == "", f"{what} emitted a value for a bogus artifact: {r.stdout!r}"
@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}"
# --- the backwards guard's comparison ----------------------------------------
#
# Exercised through the guard's own `compare` mode rather than a reimplementation
# here: a test of a copy proves nothing about the code that runs. No network — the
# comparison is pure, and the fetch/compare halves are separable for exactly this.
def compare(a: str, b: str) -> bool:
"""True when the guard considers `a` to sort strictly below `b`."""
return subprocess.run(["sh", str(GUARD), "compare", a, b],
capture_output=True, text=True).returncode == 0
@pytest.mark.parametrize("a,b,expect_lt", [
# THE trap: as text, "1.0.9" > "1.0.10". The comparison must be numeric
# per dot-segment, which is what note 3127 §1 spells out and what a naive
# `[ "$a" \< "$b" ]` would get exactly backwards.
("1.0.9", "1.0.10", True),
("1.0.10", "1.0.9", False),
# Equal is NOT less. Under commit time an unchanged source derives what it
# derived last time, so this is the ordinary no-change build.
("1.0.5", "1.0.5", False),
# A missing segment reads as zero.
("1.0", "1.0.0", False),
("1.0.0", "1.0", False),
("1.0", "1.0.1", True),
# The real transition this milestone performs, on both channels: dev was
# publishing 0.2.<run>, stable was on the bare 0.2.0 from Cargo.toml.
("0.2.466", "1.0.3502151", True),
("0.2.0", "1.0.3502151", True),
("1.0.3502151", "0.2.466", False),
# Android codes are bare integers.
("3502151", "3502152", True),
("3502152", "3502151", False),
# Zero-padded display versions compare correctly despite the leading zeros
# (which is why they are stripped on parse rather than compared as text).
("2026.08.29.0110", "2026.08.29.0111", True),
("2026.08.29.0111", "2026.08.29.0110", False),
("2026.08.09.0111", "2026.08.10.0111", True),
("2026.12.31.2359", "2027.01.01.0000", True),
])
def test_the_guard_orders_versions_numerically(a: str, b: str, expect_lt: bool) -> None:
assert compare(a, b) is expect_lt
@pytest.mark.parametrize("args", [["compare"], ["compare", "1.0.0"], ["nope", "dev"],
["desktop", "nope"]])
def test_the_guard_rejects_bad_invocations(args: list[str]) -> None:
"""Including the two-place validation the version script needed twice — a guard
that answers confidently for input it does not understand is worse than none."""
r = subprocess.run(["sh", str(GUARD), *args], capture_output=True, text=True)
assert r.returncode != 0