Enforce monotonic household releases and staged APK integrity

This commit is contained in:
justinzeus 2026-11-07 20:19:02 +01:00
parent c78a8dfa8d
commit 383802611c
10 changed files with 351 additions and 45 deletions

View file

@ -72,11 +72,13 @@ make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
Required: a clean committed source checkout, the existing persistent key, native
package credentials through the existing secret mount, no local AAR overrides,
and an explicit code above every entry in `docker/tv-version-codes`. Known Opt
code 65 seeds that ledger; old production code 28 and Wholphin code 62 belong
to different packages. Proposed first household code 100001 exceeds all three.
No household code derives from tag counts. Direct optimized Gradle tasks also
require `-PtvVersionCode`; the packaging command additionally enforces the ledger.
and an explicit code above the committed ledgers at HEAD and freshly fetched
`origin/main`, plus every local consumed-code reservation. Fetch failure refuses
the build, including a dry-run. Known Opt code 65 seeds the ledger; old production
code 28 and Wholphin code 62 belong to different packages. Proposed first household
code 100001 exceeds all three. No household code derives from tag counts.
Only `make tv-release` produces an installable household release; direct optimized
Gradle builds bypass reservation and must never be installed on the household box.
The builder inspects final signed APKs with aapt2/apksigner and checks package,
version/code, signer, non-debuggable flag, minimum SDK, native ABI inventory,
@ -91,8 +93,13 @@ Manifest schema 1 contains `packageId`, `versionName`, `versionCode`,
`signerSha256`, and `assets[]` with `abi`, `name`, `bytes`, `sha256`, `minSdk`.
All ABI outputs share one code/signer/identity. The local source tag is
`pwolfskin-opt-v<VERSION>`; the distribution tag is `wolfskin-opt-v<VERSION>`.
Before building, the code is consumed in the local Git ref
`refs/wolfskin-tv/version-codes/<code>`. This survives discarding a working-tree
ledger append or checking out an older source commit. A failed real build also
consumes its code: use a higher code rather than deleting the reservation.
After successful packaging the builder appends the code to the tracked ledger.
Commit that reservation and explicitly push the source tag before distribution.
Commit that reservation and explicitly push the source tag before distribution;
new producer checkouts obtain the committed floor by fetching `origin/main`.
An existing output directory is refused to preserve previous evidence.
Keep `private/` private: build/dependency logs, R8 mapping/effective configuration,
@ -120,6 +127,12 @@ and reverified. Upload/verification failures remain draft. Promotion requires
the explicit flag; then anonymous latest/asset hash checks must pass. A failed
post-promotion check attempts withdrawal to draft and reports failure, not
success. Cross-origin credential forwarding and non-HTTPS URLs are refused.
Before creating/resuming a draft and immediately before promotion, the publisher
anonymously reads latest and its manifest. The bundle code must be strictly newer.
Only a latest 404 followed by a successful empty release list permits a first
release; other errors fail closed. A channel advance during upload leaves the
verified upload draft rather than publishing an equal or lower code.
`--test-server` permits only a loopback HTTP fake Forgejo; production defaults
cannot be redirected to a different repository by a CLI repository argument.
Dry-run never fetches a credential or makes publication requests.
@ -151,6 +164,8 @@ the installed Android signer is the installation boundary.
EOF never starts installation. Install requires a separate OK, per-package
unknown-app permission and a confirmation-required PackageInstaller session.
The bytes streamed into that session are checked again against the manifest length
and hash; any mismatch abandons the session before commit and discards invalid Ready.
Returning from permission settings requires another explicit Install press.
Denial, cancellation and missing system handlers retain the verified download.
No broad FileProvider, public Downloads write, silent install or automatic
@ -228,13 +243,15 @@ monotonic versionCode; source commit identity is recorded separately.
`make apk` remains the old `org.justinvisser.wolfskin.tv` production assembly and
release signer. It must never publish to the household channel.
Build all four default-flavor outputs in one invocation through the container
wrapper, without a release keystore. On a host whose Gradle cache already holds
the pinned native extensions and their metadata:
For build-only diagnostics, build all four outputs through the container wrapper
with a deliberately non-release code. **Direct optimized builds must never be
installed on the household box or published.** Use `make tv-release` for every
household installation. On a host whose Gradle cache already holds the pinned
native extensions and their metadata:
```sh
bash docker/gradle.sh optimized --offline \
-PtvVersion=1.1.1 -PtvVersionCode=100001 \
-PtvVersion=1.1.1 -PtvVersionCode=1 \
-PWholphinExtensionsUsername=JustinZeus \
-PWholphinExtensionsPassword=unused \
:app:assembleDefaultOptimized
@ -247,7 +264,8 @@ through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
or securely seeded cached artifacts. No release signing inputs are needed.
Without native extension configuration, the existing dependency selection uses
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
playback-complete comparison. Inspect native libraries before installing.
playback-complete comparison. Inspect native libraries for diagnostic evidence;
do not install these direct-build APKs on the household box.
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
armeabi-v7a and x86_64. Keep

View file

@ -30,6 +30,12 @@ class FakeForge:
self.corrupt_draft = False
self.corrupt_public = False
self.redirect_url: str | None = None
self.published_code: int | None = None
self.channel_manifest: dict[str, Any] = {}
self.latest_status = 200
self.release_list: list[dict[str, Any]] = []
self.latest_reads = 0
self.advance_before_promote: int | None = None
fixture = self
class Handler(BaseHTTPRequestHandler):
@ -104,10 +110,39 @@ class FakeForge:
else fixture.corrupt_public
)
self.respond(data + b"corrupt" if corrupt else data)
elif self.path.endswith("/latest") and fixture.release.get(
"draft", True
):
self.respond({}, 404)
elif self.path == "/channel/wolfskin-update.json":
self.respond(
fixture.channel_manifest
| {"versionCode": fixture.published_code}
)
elif self.path.endswith("/latest"):
fixture.latest_reads += 1
if (
fixture.latest_reads == 2
and fixture.advance_before_promote is not None
):
fixture.published_code = fixture.advance_before_promote
if fixture.latest_status != 200:
self.respond({}, fixture.latest_status)
elif fixture.release and not fixture.release["draft"]:
self.respond(fixture.release)
elif fixture.published_code is not None:
self.respond(
{
"id": 9,
"assets": [
{
"name": "wolfskin-update.json",
"browser_download_url": fixture.base
+ "/channel/wolfskin-update.json",
}
],
}
)
else:
self.respond({}, 404)
elif urllib.parse.urlsplit(self.path).path.endswith("/releases"):
self.respond(fixture.release_list)
else:
self.respond(fixture.release)
@ -157,6 +192,7 @@ class PublishTest(unittest.TestCase):
"signerSha256": SIGNER,
"assets": assets,
}
self.forge.channel_manifest = manifest
(self.directory / "wolfskin-update.json").write_text(json.dumps(manifest))
(self.directory / "RELEASE-NOTES.md").write_text("Fixture release notes")
self.checksums()
@ -208,16 +244,18 @@ class PublishTest(unittest.TestCase):
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(self.forge.release["draft"])
self.assertEqual(REQUIRED, set(self.forge.assets))
self.assertTrue(all(auth for _, auth in self.forge.events))
self.assertTrue(
all(
not auth
for path, auth in self.forge.events
if "/latest" in path or "?limit=" in path
)
)
result = self.cli("--draft-id", "17", "--promote")
self.assertEqual(0, result.returncode, result.stderr)
self.assertFalse(self.forge.release["draft"])
for name, data in self.forge.assets.items():
self.assertEqual((self.directory / name).read_bytes(), data)
anonymous = [
event for event, authenticated in self.forge.events if not authenticated
]
self.assertEqual(8, len(anonymous))
def test_corrupt_uploaded_bytes_remain_draft(self) -> None:
self.forge.corrupt_draft = True
@ -266,6 +304,59 @@ class PublishTest(unittest.TestCase):
self.assertEqual(0, result.returncode, result.stderr)
self.assertFalse(trace.exists())
def test_equal_and_lower_bundle_codes_never_create_a_draft(self) -> None:
for published_code in (100001, 100002):
with self.subTest(published_code=published_code):
self.forge.published_code = published_code
result = self.cli("--promote")
self.assertNotEqual(0, result.returncode)
self.assertEqual({}, self.forge.release)
self.assertTrue(all(not auth for _, auth in self.forge.events))
def test_newer_bundle_reads_live_manifest_anonymously(self) -> None:
self.forge.published_code = 100000
result = self.cli()
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(self.forge.release["draft"])
live_reads = [
auth
for path, auth in self.forge.events
if path.endswith("/latest") or path.startswith("GET /channel/")
]
self.assertTrue(live_reads)
self.assertFalse(any(live_reads))
def test_channel_advance_during_upload_prevents_promotion(self) -> None:
self.forge.advance_before_promote = 100001
result = self.cli("--promote")
self.assertNotEqual(0, result.returncode)
self.assertTrue(self.forge.release["draft"])
self.assertFalse(any(path == "PATCH" for path, _ in self.forge.events))
def test_empty_channel_404_permits_first_draft(self) -> None:
result = self.cli()
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(self.forge.release["draft"])
self.assertIn(
(
"GET /api/v1/repos/justinzeus/wolfskin-tv-updates/releases?limit=1",
False,
),
self.forge.events,
)
def test_missing_latest_with_nonempty_channel_is_not_first_release(self) -> None:
self.forge.release_list = [{"id": 9}]
result = self.cli()
self.assertNotEqual(0, result.returncode)
self.assertEqual({}, self.forge.release)
def test_refused_live_channel_never_creates_draft(self) -> None:
self.forge.latest_status = 403
result = self.cli()
self.assertNotEqual(0, result.returncode)
self.assertEqual({}, self.forge.release)
def test_authenticated_redirect_never_reaches_another_origin(self) -> None:
other = FakeForge()
self.addCleanup(other.close)

View file

@ -34,6 +34,12 @@ class ReleaseInputTest(unittest.TestCase):
"-m",
"Record used code",
)
self.remote = Path(self.temporary.name) / "origin.git"
subprocess.run(
["git", "init", "--bare", "--quiet", str(self.remote)], check=True
)
self.git("remote", "add", "origin", str(self.remote))
self.git("push", "--quiet", "origin", "HEAD:main")
self.previous_root = tv_release.ROOT
tv_release.ROOT = self.root
self.addCleanup(setattr, tv_release, "ROOT", self.previous_root)
@ -94,6 +100,59 @@ class ReleaseInputTest(unittest.TestCase):
self.assertEqual(
"65 installed-opt\n", (self.root / "docker/tv-version-codes").read_text()
)
self.assertEqual(
"", self.git("for-each-ref", "refs/wolfskin-tv/version-codes/")
)
def test_stale_branch_uses_fetched_main_ledger(self) -> None:
stale = self.git("rev-parse", "HEAD")
(self.root / "docker/tv-version-codes").write_text(
"65 installed-opt\n100001 released\n"
)
self.git("add", "docker/tv-version-codes")
self.git(
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.invalid",
"commit",
"--quiet",
"-m",
"Reserve published code",
)
self.git("push", "--quiet", "origin", "HEAD:main")
self.git("checkout", "--quiet", "--detach", stale)
with self.assertRaises(ValueError):
tv_release.validate_inputs("1.1.2", 100001)
self.assertEqual(stale, tv_release.validate_inputs("1.1.2", 100002))
def test_discarding_ledger_append_cannot_reuse_reserved_code(self) -> None:
commit = self.git("rev-parse", "HEAD")
tv_release.reserve_code(100001, commit)
with (self.root / "docker/tv-version-codes").open("a") as ledger:
ledger.write("100001 1.1.1 consumed\n")
self.git("restore", "docker/tv-version-codes")
with self.assertRaises(ValueError):
tv_release.validate_inputs("1.1.2", 100001)
self.assertEqual(commit, tv_release.validate_inputs("1.1.2", 100002))
def test_head_reservation_wins_over_older_main(self) -> None:
(self.root / "docker/tv-version-codes").write_text(
"65 installed-opt\n100002 released\n"
)
self.git("add", "docker/tv-version-codes")
self.git(
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.invalid",
"commit",
"--quiet",
"-m",
"Reserve newer branch code",
)
with self.assertRaises(ValueError):
tv_release.validate_inputs("1.1.3", 100002)
if __name__ == "__main__":

View file

@ -30,6 +30,14 @@ REQUIRED = {
}
class ForgeHttpError(RuntimeError):
"""Retain HTTP status without exposing response bodies or authorization."""
def __init__(self, method: str, status: int) -> None:
super().__init__(f"Forgejo {method} failed: HTTP {status}")
self.status = status
class NoRedirect(urllib.request.HTTPRedirectHandler):
"""Validate every redirect before forwarding distribution-tool credentials."""
@ -98,9 +106,9 @@ class Forge:
return self.opener.open(request, timeout=60)
except urllib.error.HTTPError as error:
if method != "GET" or error.code not in (301, 302, 303, 307, 308):
raise RuntimeError(
f"Forgejo {method} failed: HTTP {error.code}"
) from None
status = error.code
error.close()
raise ForgeHttpError(method, status) from None
location = error.headers.get("Location")
error.close()
if not location:
@ -159,6 +167,41 @@ class Forge:
if actual != expected:
raise ValueError("Uploaded artifact checksum mismatch")
def require_newer_code(self, code: int) -> None:
"""Read the public channel anonymously and fail closed on an uncertain floor."""
try:
release = self.json("GET", "/releases/latest", authenticated=False)
except ForgeHttpError as error:
if error.status != 404:
raise
releases = self.json("GET", "/releases?limit=1", authenticated=False)
if releases == []:
return
raise ValueError("Latest is missing but the channel is not empty") from None
assets = [
asset
for asset in release["assets"]
if asset["name"] == "wolfskin-update.json"
]
if len(assets) != 1:
raise ValueError("Latest release must have exactly one update manifest")
with self.request(
"GET", assets[0]["browser_download_url"], authenticated=False
) as response:
manifest = json.load(response)
latest_code = manifest["versionCode"]
if (
manifest["schemaVersion"] != 1
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
or type(latest_code) is not int
or not 1 <= latest_code <= 2100000000
):
raise ValueError("Invalid live household version identity")
if code <= latest_code:
raise ValueError(
f"Bundle versionCode must exceed live channel code {latest_code}"
)
def credential(server: str) -> str:
"""Read git's existing credential helper without secrets in argv, stdout or disk."""
@ -280,6 +323,7 @@ def publish(
f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials"
)
return
Forge(server, "", test_server).require_newer_code(manifest["versionCode"])
forge = Forge(server, credential(server), test_server)
if draft_id is None:
release = forge.json(
@ -313,6 +357,7 @@ def publish(
"Draft upload verified. Publication requires owner approval and explicit --promote."
)
return
forge.require_newer_code(manifest["versionCode"])
try:
forge.json("PATCH", f"/releases/{release_id}", {"draft": False})
latest = forge.json("GET", "/releases/latest", authenticated=False)

View file

@ -37,16 +37,40 @@ def validate_inputs(version: str, code: int) -> str:
raise ValueError(
"Release source must be clean, including the version-code ledger"
)
used = [
int(line.split()[0])
for line in (ROOT / "docker/tv-version-codes").read_text().splitlines()
if line and not line.startswith("#")
]
commit = run("git", "rev-parse", "HEAD")
run(
"git",
"fetch",
"--no-tags",
"origin",
"refs/heads/main:refs/remotes/origin/main",
)
used = []
for reference in (commit, "refs/remotes/origin/main"):
if run("git", "ls-tree", "--name-only", reference, "docker/tv-version-codes"):
ledger = run("git", "show", f"{reference}:docker/tv-version-codes")
used.extend(
int(line.split()[0])
for line in ledger.splitlines()
if line and not line.startswith("#")
)
reservations = run(
"git",
"for-each-ref",
"--format=%(refname:strip=3)",
"refs/wolfskin-tv/version-codes/",
)
used.extend(int(value) for value in reservations.splitlines())
if code <= max(used):
raise ValueError(f"VERSION_CODE must exceed ledger maximum {max(used)}")
raise ValueError(f"VERSION_CODE must exceed reserved maximum {max(used)}")
if list((ROOT / "app/libs").glob("*.aar")):
raise ValueError("Local AAR overrides are forbidden for household releases")
return run("git", "rev-parse", "HEAD")
return commit
def reserve_code(code: int, commit: str) -> None:
"""Keep a consumed code reserved even if its working-tree ledger append is discarded."""
run("git", "update-ref", f"refs/wolfskin-tv/version-codes/{code}", commit, "")
def inspect_apk(path: Path, version: str, code: int, abi: str, proof: Path) -> int:
@ -130,6 +154,7 @@ def release(version: str, code: int, output: Path, dry_run: bool) -> None:
existing = run("git", "tag", "--list", tag)
if existing and run("git", "rev-parse", f"{tag}^{{commit}}") != commit:
raise ValueError("Source tag already identifies another commit")
reserve_code(code, commit)
output.mkdir(parents=True)
private = output / "private"
private.mkdir(mode=0o700)