Enforce monotonic household releases and staged APK integrity
This commit is contained in:
parent
c78a8dfa8d
commit
383802611c
10 changed files with 351 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue