The guard fix, and the APK and image #4 never published #5

Merged
bvandeusen merged 1 commits from dev into main 2026-08-29 14:19:50 -04:00
2 changed files with 133 additions and 6 deletions
+22 -6
View File
@@ -79,19 +79,35 @@ fetch() {
# What the channel is serving, per artifact. ONE definition of where to look, shared # What the channel is serving, per artifact. ONE definition of where to look, shared
# with `should-build.sh` — the skip decision and the guard must agree about what is # with `should-build.sh` — the skip decision and the guard must agree about what is
# published, and two readers of one fact is how this repo keeps producing #2181-2183. # published, and two readers of one fact is how this repo keeps producing #2181-2183.
#
# EVERY LOOKUP HERE MUST SUCCEED EVEN WHEN IT FINDS NOTHING. That is what the `|| true`
# on each pipeline is for, and it is load-bearing rather than defensive noise.
#
# An empty channel is a REAL state this guard is written to pass — `[ -z "$published" ]`
# further down says so in as many words. But the value is captured as
# `published="$(published_for ...)"`, and under `set -e` a command substitution that
# exits non-zero kills the script before that branch is ever reached. Silently, too:
# everything the pipeline would have said went into the capture rather than the log.
#
# WHICH COMMAND THE PIPELINE HAPPENS TO END ON decides whether that fires, which is the
# part worth remembering. `sed` on empty input exits 0; `grep` exits 1. Three of these
# four lookups end in `sed` and were fine. The one that ends in `grep -oE '[0-9]+$'` —
# Android's version_code — was not, and it failed the whole Android lane on the first
# merge to `main` (run 4857): exit 1, no output, 0.16 seconds, on the one channel that
# had no APK published yet. Its three neighbours hid it until then.
published_for() { published_for() {
case "$1" in case "$1" in
desktop) desktop)
# What the UPDATER reads. The manifest is the thing that decides whether a # What the UPDATER reads. The manifest is the thing that decides whether a
# client is offered a build, so it is the authority on what is published. # client is offered a build, so it is the authority on what is published.
fetch "$SERVER/$REPO/releases/download/$2/latest.json" \ { fetch "$SERVER/$REPO/releases/download/$2/latest.json" \
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \ | grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/' | sed -E 's/.*"([^"]+)"$/\1/'; } || true
;; ;;
android) android)
fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \ { fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \ | grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
| grep -oE '[0-9]+$' | grep -oE '[0-9]+$'; } || true
;; ;;
esac esac
} }
@@ -104,9 +120,9 @@ published_name() {
case "$1" in case "$1" in
desktop) published_for desktop "$2" ;; desktop) published_for desktop "$2" ;;
android) android)
fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \ { fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \ | grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/' | sed -E 's/.*"([^"]+)"$/\1/'; } || true
;; ;;
esac esac
} }
+111
View File
@@ -24,6 +24,7 @@ import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.sh" SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.sh"
GUARD = Path(__file__).resolve().parent.parent / "packaging" / "guard-forward.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 deliberately: # 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 # a test that imported the value could not catch the value being changed, and moving
@@ -304,3 +305,113 @@ def test_the_guard_rejects_bad_invocations(args: list[str]) -> None:
that answers confidently for input it does not understand is worse than none.""" 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) r = subprocess.run(["sh", str(GUARD), *args], capture_output=True, text=True)
assert r.returncode != 0 assert r.returncode != 0
# --- the backwards guard against a channel ------------------------------------
#
# The half of the guard that talks to a release feed, which the `compare` tests above
# deliberately do not reach. Hermetic anyway: `curl` is shadowed on PATH by a stub, so
# these assert what the guard does with an answer rather than what the forge returns.
#
# WHY THIS SECTION EXISTS AT ALL. The empty-channel case — a channel that has never
# published this artifact — is one the guard is written to PASS, and it says so in a
# branch of its own. It did not: `published="$(published_for ...)"` under `set -e`
# died on the substitution before that branch could run, because Android's lookup
# ended in a `grep` that exits 1 on no match while its three siblings ended in a `sed`
# that exits 0. Silent, because everything the pipeline printed went into the capture.
# It took the Android lane down on the first merge to `main` (run 4857) and nothing
# here would have caught it.
@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 very first build to reach a channel, which is exactly
what happened to Android on `stable`.
"""
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