Files
thoughtsync/tests/test_versioning.py
T
bvandeusenandClaude Opus 5 85ead4d66b
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
versioning: anchor at the repo root — a pathspec is relative to the caller's cwd
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>
2026-08-28 21:03:04 -04:00

252 lines
12 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"
# 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}"