guard: an empty channel killed the lane instead of passing it
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m46s

The first merge to `main` took the Android lane down (run 4857): the decide
job exited 1 in 0.16 seconds with no output at all, and the image build
skipped behind it because a failing lane must not publish.

`stable` had never published an APK, which the guard treats as a pass — there
is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch
of its own. That branch was unreachable. `published="$(published_for ...)"`
under `set -e` dies on the substitution before it, and everything the pipeline
would have printed goes into the capture rather than the log.

What decided which lookups had the bug is the last command in the pipeline.
`sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`.
Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one —
and only on a channel with nothing on it, which is why a week of dev pushes
never saw it.

The tests now reach the half of the guard that talks to a feed, with `curl`
shadowed on PATH so they stay hermetic: an empty channel passes and builds, a
lower published version passes, a higher one fails the lane, and an equal
Android code is refused because Android will not install it.
This commit is contained in:
Bryan Van Deusen
2026-08-29 13:45:26 -04:00
parent c2fdc05e5c
commit 6e524ec616
2 changed files with 133 additions and 6 deletions
+111
View File
@@ -24,6 +24,7 @@ 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 deliberately:
# 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."""
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, 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