versioning: refuse to publish a version below what the channel already serves
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
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>
This commit is contained in:
@@ -23,6 +23,7 @@ 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
|
||||
@@ -249,3 +250,57 @@ def test_no_matching_history_fails_rather_than_guessing(tmp_path: Path, what: st
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user