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>
211 lines
9.8 KiB
Python
211 lines
9.8 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) -> 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}"
|