Files
thoughtsync/tests/test_versioning.py
T
Bryan Van Deusen 6e524ec616
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m46s
guard: an empty channel killed the lane instead of passing it
The first merge to `main` took the Android lane down (run 4857): the decide
job exited 1 in 0.16 seconds with no output at all, and the image build
skipped behind it because a failing lane must not publish.

`stable` had never published an APK, which the guard treats as a pass — there
is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch
of its own. That branch was unreachable. `published="$(published_for ...)"`
under `set -e` dies on the substitution before it, and everything the pipeline
would have printed goes into the capture rather than the log.

What decided which lookups had the bug is the last command in the pipeline.
`sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`.
Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one —
and only on a channel with nothing on it, which is why a week of dev pushes
never saw it.

The tests now reach the half of the guard that talks to a feed, with `curl`
shadowed on PATH so they stay hermetic: an empty channel passes and builds, a
lower published version passes, a higher one fails the lane, and an equal
Android code is refused because Android will not install it.
2026-08-29 13:45:26 -04:00

418 lines
20 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"
SHOULD_BUILD = Path(__file__).resolve().parent.parent / "packaging" / "should-build.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
# --- the backwards guard against a channel ------------------------------------
#
# The half of the guard that talks to a release feed, which the `compare` tests above
# deliberately do not reach. Hermetic anyway: `curl` is shadowed on PATH by a stub, so
# these assert what the guard does with an answer rather than what the forge returns.
#
# WHY THIS SECTION EXISTS AT ALL. The empty-channel case — a channel that has never
# published this artifact — is one the guard is written to PASS, and it says so in a
# branch of its own. It did not: `published="$(published_for ...)"` under `set -e`
# died on the substitution before that branch could run, because Android's lookup
# ended in a `grep` that exits 1 on no match while its three siblings ended in a `sed`
# that exits 0. Silent, because everything the pipeline printed went into the capture.
# It took the Android lane down on the first merge to `main` (run 4857) and nothing
# here would have caught it.
@pytest.fixture
def curl_stub(tmp_path: Path):
"""A `curl` on PATH that serves whatever the test says, or 404s.
Returns a callable: `serve(None)` for a channel with nothing published (the stub
exits 22, as real curl does under `-f` on an HTTP error), `serve(body)` to hand
back a feed.
"""
bindir = tmp_path / "stubbin"
bindir.mkdir()
body = bindir / "body"
stub = bindir / "curl"
stub.write_text(
"#!/bin/sh\n"
f'[ -f "{body}" ] || exit 22\n'
f'cat "{body}"\n'
)
stub.chmod(0o755)
def serve(content: str | None) -> dict[str, str]:
if content is None:
body.unlink(missing_ok=True)
else:
body.write_text(content)
return {**os.environ, "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}
return serve
def guard(repo: Path, env: dict[str, str], *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["sh", str(GUARD), *args],
cwd=repo, env=env, capture_output=True, text=True)
@pytest.mark.parametrize("artifact", ["desktop", "android"])
def test_an_empty_channel_passes_the_guard(repo: Path, curl_stub, artifact: str) -> None:
"""Nothing published yet is not a backwards move — it is the first publish.
Failing here would block the very first build to reach a channel, which is exactly
what happened to Android on `stable`.
"""
r = guard(repo, curl_stub(None), artifact, "stable")
assert r.returncode == 0, f"exit={r.returncode} stdout={r.stdout!r} stderr={r.stderr!r}"
assert "nothing to compare" in r.stdout
@pytest.mark.parametrize("artifact", ["desktop", "android"])
def test_an_empty_channel_reports_no_published_version(
repo: Path, curl_stub, artifact: str
) -> None:
"""`published` answers empty rather than failing — `should-build.sh` captures it
the same way the guard does, so a non-zero exit strands that caller too."""
r = guard(repo, curl_stub(None), "published", artifact, "stable")
assert r.returncode == 0, f"exit={r.returncode} stderr={r.stderr!r}"
assert r.stdout.strip() == ""
def test_an_empty_channel_builds(repo: Path, curl_stub) -> None:
r = subprocess.run(["sh", str(SHOULD_BUILD), "android", "stable"],
cwd=repo, env=curl_stub(None), capture_output=True, text=True)
assert r.returncode == 0, f"exit={r.returncode} stderr={r.stderr!r}"
assert r.stdout.strip() == "true"
def test_a_lower_published_version_passes(repo: Path, curl_stub) -> None:
"""The transition this milestone performs: `stable` served the bare 0.2.0 from
Cargo.toml, and the new key has to clear it."""
env = curl_stub('{"version": "0.2.0", "platforms": {}}')
r = guard(repo, env, "desktop", "stable")
assert r.returncode == 0, f"stderr={r.stderr!r}"
assert "may be published" in r.stdout
def test_a_higher_published_version_fails_the_lane(repo: Path, curl_stub) -> None:
"""The unrecoverable direction. A version below what is published leaves every
installed client reporting 'up to date' forever, so this fails rather than warns."""
env = curl_stub('{"version": "9.9.9", "platforms": {}}')
r = guard(repo, env, "desktop", "stable")
assert r.returncode != 0
assert "GUARD FAILED" in r.stderr
def test_an_equal_android_code_fails_because_android_will_not_install_it(
repo: Path, curl_stub
) -> None:
"""Android hard-fails a non-rising versionCode, so equality is refused there —
unlike the desktop, where an unchanged source deriving its own value is ordinary."""
code = version(repo, "key", "android")
env = curl_stub(f'{{"version_code": {code}, "version_name": "x"}}')
r = guard(repo, env, "android", "stable")
assert r.returncode != 0
assert "EQUALS" in r.stderr