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
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>
307 lines
15 KiB
Python
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
|