CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 13s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 32s
Rule 32 "Comments describe the product to a stranger" sweep over src/, alembic/ and tests/. Comments and docstrings now say what the code does and the reason as a present-tense constraint, briefly. Removed: - issue, task, note, rule, milestone and family-idea references - "used to" / before-and-after narration - quotes of decisions and incident stories from the deploying instance - paragraph-length arguments, where a line or two carries the fact Kept every hazard as a present-tense fact, e.g. timing oracles, the proxy hop direction, and migrations that must stay frozen. Also: - Three test names lose "still"/"used to"/"pre_m304" phrasing. - pyproject's description drops "FabledSword family". The history these comments carried stays in Scribe and in git log. A comment-stripping diff (Scribe snippet #6226 "codeonly.py — prove a diff changed only comments") shows no code changed apart from the renamed tests and the description string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
382 lines
17 KiB
Python
382 lines
17 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, so they need no `fetch-depth: 0`, and can commit to one artifact's file set
|
|
at a time: a Kotlin-only commit must leave the desktop's version alone.
|
|
"""
|
|
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 so a change to
|
|
# it fails here: moving it renumbers every artifact downwards.
|
|
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; the CI container may need
|
|
# more than git does here.
|
|
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."""
|
|
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 a build flag changes the bytes while the
|
|
source does not. A build skipped because its version exists would then serve 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.
|
|
|
|
It fails quietly: a relative pathspec can match a real, stale file from the
|
|
desktop's directory and give one push several versions."""
|
|
(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:
|
|
"""What 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:
|
|
"""Not `0.0.<minutes>`: minor 0 sorts below installed `0.2.x` builds, which would
|
|
then read "up to date" forever."""
|
|
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), so it has no
|
|
ordering key just because the other artifacts do."""
|
|
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:
|
|
"""An `exit` inside `$(...)` ends only the subshell, so an empty pathspec (which
|
|
matches everything) would answer with a confident version and exit 0. Asserts on
|
|
stdout as well as the exit code."""
|
|
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:
|
|
"""A shallow clone sees one commit, so `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, because an `exit` inside `$(...)` can fail one and not the other."""
|
|
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", [
|
|
# As text, "1.0.9" > "1.0.10". The comparison must be numeric per dot-segment;
|
|
# `[ "$a" \< "$b" ]` gets it 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. Hermetic: `curl` is shadowed on
|
|
# PATH by a stub.
|
|
#
|
|
# A channel that has never published an artifact must pass. Under `set -e`, a lookup
|
|
# ending in a `grep` with no match kills the substitution before that branch runs.
|
|
|
|
|
|
@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 first build to reach a channel.
|
|
"""
|
|
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
|