Skip to content

Commit 51d758e

Browse files
committed
fix(install): upgrading from a beta to its own release was refused
Hit it updating the test box from v1.6.0-beta.3 to v1.6.0: the installer called it a downgrade and aborted. `sort -V` is not semver aware. It reads `1.6.0-beta.3` as `1.6.0` plus extra characters and sorts it AFTER the finished release, so the guard that exists to stop an accidental rollback fired on the single most common upgrade there is — every beta tester moving to the version they were testing. The prerelease separator is swapped for `~` before comparing. That is the one character version sort orders before end-of-string, which is exactly the semver rule that a prerelease precedes the version it leads to. Tested against the comparison lifted out of install.sh itself rather than a retyped copy — a test agreeing with a paraphrase would have passed while the shipped code was wrong.
1 parent 0eb2d6e commit 51d758e

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Unit tests for `install.sh`.
2+
3+
The installer is not sourceable — it runs top to bottom — so the version
4+
comparison is lifted out of the file itself and evaluated, rather than a copy
5+
of it being retyped here. A test that agrees with a paraphrase of the code
6+
would have passed while the shipped comparison was wrong.
7+
"""
8+
import os
9+
import re
10+
import shutil
11+
import subprocess
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
INSTALL_SH = Path(__file__).resolve().parents[2] / "install.sh"
17+
BASH = shutil.which("bash")
18+
19+
pytestmark = pytest.mark.skipif(
20+
BASH is None or not INSTALL_SH.exists(), reason="bash or install.sh unavailable",
21+
)
22+
23+
24+
def _comparison_lines() -> str:
25+
"""The lines install.sh uses to decide 'is the target older?'."""
26+
text = INSTALL_SH.read_text(encoding="utf-8", errors="ignore")
27+
wanted = [
28+
ln.strip() for ln in text.splitlines()
29+
if re.match(r'^\s*_(running|target)_cmp=', ln)
30+
]
31+
assert wanted, "install.sh no longer prepares comparable version strings"
32+
return "\n".join(wanted)
33+
34+
35+
def is_downgrade(running: str, target: str) -> bool:
36+
"""Run the installer's own comparison for one pair of versions."""
37+
body = f"""
38+
_running_num="{running}"
39+
_target_num="{target}"
40+
{_comparison_lines()}
41+
if [[ "$_running_num" != "$_target_num" \\
42+
&& "$(printf '%s\\n%s\\n' "$_running_cmp" "$_target_cmp" | sort -V | tail -1)" == "$_running_cmp" ]]; then
43+
echo DOWNGRADE
44+
else
45+
echo OK
46+
fi
47+
"""
48+
r = subprocess.run([BASH, "-c", body], capture_output=True, text=True,
49+
env=dict(os.environ), timeout=30)
50+
assert r.returncode == 0, r.stderr
51+
return r.stdout.strip() == "DOWNGRADE"
52+
53+
54+
class TestDowngradeDetection:
55+
def test_beta_to_its_own_release_is_an_upgrade(self):
56+
"""The single most common upgrade there is, and it was refused:
57+
`sort -V` is not semver aware — it reads `1.6.0-beta.3` as `1.6.0`
58+
plus extra characters and sorts it AFTER the finished release."""
59+
assert not is_downgrade("1.6.0-beta.3", "1.6.0")
60+
assert not is_downgrade("1.6.0-beta.1", "1.6.0")
61+
62+
def test_release_to_its_own_beta_is_a_downgrade(self):
63+
assert is_downgrade("1.6.0", "1.6.0-beta.3")
64+
65+
def test_ordinary_upgrades_are_allowed(self):
66+
assert not is_downgrade("1.5.3", "1.6.0")
67+
assert not is_downgrade("1.6.0", "1.6.1")
68+
assert not is_downgrade("1.6.0", "2.0.0")
69+
70+
def test_ordinary_downgrades_are_caught(self):
71+
assert is_downgrade("1.6.0", "1.5.3")
72+
assert is_downgrade("1.6.1", "1.6.0")
73+
74+
def test_betas_are_ordered_among_themselves(self):
75+
assert not is_downgrade("1.6.0-beta.2", "1.6.0-beta.3")
76+
assert is_downgrade("1.6.0-beta.3", "1.6.0-beta.2")
77+
78+
def test_the_same_version_is_neither(self):
79+
assert not is_downgrade("1.6.0", "1.6.0")
80+
assert not is_downgrade("1.6.0-beta.3", "1.6.0-beta.3")

install.sh

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,12 +573,22 @@ fi
573573
# Use `sort -V` (version sort) as a cheap semver comparator. Strips a
574574
# leading `v`, then asks whether running > resolved. If yes, abort.
575575
# Same-version (running == resolved) handled separately below.
576+
#
577+
# The prerelease separator is swapped for `~` first. `sort -V` is not semver
578+
# aware: it reads `1.6.0-beta.3` as `1.6.0` plus extra characters and sorts it
579+
# AFTER the finished release, so upgrading from any beta to the release it was
580+
# a beta of looked like a downgrade and was refused — which is the single most
581+
# common upgrade there is. `~` is the one character version sort orders before
582+
# end-of-string, which is exactly the semver rule that a prerelease precedes
583+
# the version it leads to.
576584
if [[ "$IS_UPDATE" == "1" \
577585
&& -n "$RUNNING_VERSION" && "$RUNNING_VERSION" != "?" ]]; then
578586
_running_num="${RUNNING_VERSION#v}"
579587
_target_num="${DISPLAY_VERSION#v}"
588+
_running_cmp="${_running_num/-/\~}"
589+
_target_cmp="${_target_num/-/\~}"
580590
if [[ "$_running_num" != "$_target_num" \
581-
&& "$(printf '%s\n%s\n' "$_running_num" "$_target_num" | sort -V | tail -1)" == "$_running_num" ]]; then
591+
&& "$(printf '%s\n%s\n' "$_running_cmp" "$_target_cmp" | sort -V | tail -1)" == "$_running_cmp" ]]; then
582592
echo ""
583593
warn "════════════════════════════════════════════════════════════════════"
584594
warn " ⛔ DOWNGRADE DETECTED — aborting."

0 commit comments

Comments
 (0)