Merge pull request 'Add verified household TV updates and release tooling' (#60) from fm/tv-updater-core into main
This commit is contained in:
commit
57cf17aaad
33 changed files with 3033 additions and 112 deletions
225
docker/README.md
225
docker/README.md
|
|
@ -11,7 +11,11 @@ make test # app default/debug suite plus wolfskin-design JVM/capture suite
|
|||
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
|
||||
```
|
||||
|
||||
APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds
|
||||
Offline JVM tests use instrumented Android 9, 11 and 14 SDK jars. Cache preparation
|
||||
resolves each SDK independently; putting all three versions in one Gradle
|
||||
configuration selects only the newest and prevents older-SDK tests from starting.
|
||||
|
||||
APKs remain under `app/build/outputs/apk/default/{release,debug,optimized}/`. Debug builds
|
||||
are not playback-complete and must not be distributed as releases. Upstream's
|
||||
`.github/workflows` and dependency version catalog are unchanged; release builds
|
||||
use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not
|
||||
|
|
@ -27,6 +31,159 @@ Upstream enables JVM unit tests only for debug variants; there is no
|
|||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||||
task without changing upstream's test configuration.
|
||||
|
||||
## Household update channel
|
||||
|
||||
The household assembly remains `defaultOptimized`: R8/resource shrinking,
|
||||
non-debuggable, baseline profiles, pinned playback extensions and no debug
|
||||
fixtures. Its installation identity is `org.justinvisser.wolfskin.tv.opt`.
|
||||
The persistent external Android debug keystore is now the **household signing
|
||||
authority**, not a disposable development key. Certificate SHA-256:
|
||||
`182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d`.
|
||||
Never print/copy that keystore into artifacts or regenerate it for a release.
|
||||
The household wrapper fails if it is missing; protect and escrow the original.
|
||||
|
||||
Same package + same signer + strictly higher Android versionCode allows an
|
||||
in-place upgrade retaining Jellyfin logins, databases and preferences. Never
|
||||
uninstall, clear data, change identity or replace the key as an update recovery
|
||||
step. An older ephemeral signer is a separate, owner-approved migration gate;
|
||||
this change does not prove the TV's installed signer or retained-login behavior.
|
||||
|
||||
The dedicated public distribution repository is
|
||||
`justinzeus/wolfskin-tv-updates`; its default API is
|
||||
`https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin-tv-updates/releases/latest`.
|
||||
Repository creation and each actual publication are separate owner gates.
|
||||
Neither command creates a repository. Source remains in `justinzeus/wolfskin`.
|
||||
Initialize the distribution repository's default branch (for example with a
|
||||
README) before its first draft; Forgejo needs a commit on which to create a
|
||||
distribution tag. That tag is not the source tag in the separate source repo.
|
||||
The old production package keeps its old release API and parser. Only the Opt
|
||||
package migrates the exact old default saved URL; intentional custom URLs remain.
|
||||
Installed-release notes use the household channel too.
|
||||
|
||||
### Build, inspect and reserve a code
|
||||
|
||||
```sh
|
||||
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
|
||||
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
|
||||
# Prerequisites only: no build, tag, ledger mutation or publication.
|
||||
make tv-release VERSION=1.1.1 VERSION_CODE=100001 \
|
||||
TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-dry TV_RELEASE_FLAGS=--dry-run
|
||||
```
|
||||
|
||||
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 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,
|
||||
fixture exclusion and both dexopt profiles. It emits four standalone APKs:
|
||||
`Wolfskin-armeabi-v7a.apk`, `Wolfskin-arm64-v8a.apk`, `Wolfskin-x86_64.apk`,
|
||||
and universal `Wolfskin.apk`; plus `wolfskin-update.json`, `RELEASE-NOTES.md`
|
||||
and `SHA256SUMS`. Hashes and sizes come from final signed bytes. Universal
|
||||
32-bit x86 is not MPV-complete and is not a supported playback ABI.
|
||||
|
||||
Manifest schema 1 contains `packageId`, `versionName`, `versionCode`,
|
||||
`publishedAt` (UTC ISO-8601), immutable `sourceCommit`, `sourceTag`,
|
||||
`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;
|
||||
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,
|
||||
APK metadata/certificate inspection and native/ZIP inventory. Publication uploads
|
||||
only the seven named public files, never that directory, credentials or keys.
|
||||
Review release notes and corresponding-source/native links before promotion.
|
||||
|
||||
### Explicit draft publication
|
||||
|
||||
```sh
|
||||
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
|
||||
TV_PUBLISH_FLAGS=--dry-run
|
||||
# Only after owner approval to upload:
|
||||
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1
|
||||
# Only after owner approval to publish the verified draft (use its printed id):
|
||||
make tv-publish TV_RELEASE_DIR=/var/tmp/wolfskin-tv-release-1.1.1 \
|
||||
TV_PUBLISH_FLAGS='--draft-id <id> --promote'
|
||||
make tv-tooling-test
|
||||
```
|
||||
|
||||
The publisher reads git's credential helper into process memory, not arguments,
|
||||
logs or files. It creates a draft, uploads all public files, then downloads and
|
||||
hashes their bytes while still draft. Existing matching drafts can be resumed
|
||||
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.
|
||||
|
||||
### Client trust and installation
|
||||
|
||||
`UpdateController` is the process-scoped UI contract with `StateFlow` state,
|
||||
shared auto-check preference, refreshed PackageManager installed version and
|
||||
explicit check/download/cancel/install/retry/resume actions. Its capture fake
|
||||
does not perform real operations. Classic Opt update controls use that same
|
||||
controller; the old package retains its original updater.
|
||||
|
||||
`release: StateFlow<UpdateUiState.Available?>` retains version, size, localized
|
||||
date and release notes through download, Ready and installation cancellation.
|
||||
Verified records persist notes as an optional `releaseNotes` field alongside
|
||||
the manifest; existing manifest-only records remain readable. `Failed.operation`
|
||||
is `UpdateOperation.Check`, `Download` or `Install`, never inferred from message text.
|
||||
Permission and handoff dismissals restore verified Ready without discarding its
|
||||
APK. Installer ownership is tracked separately from the visible state, so a
|
||||
dismissed but active system session still blocks a duplicate handoff. Calling
|
||||
`install()` from PermissionNeeded resolves and opens the permission settings again.
|
||||
|
||||
The client uses a dedicated anonymous OkHttp client, validating every redirect:
|
||||
HTTPS, no URL user-info, no cookies/Jellyfin token/repository credential.
|
||||
Loopback HTTP is available only through the test transport constructor flag.
|
||||
It selects armeabi-v7a first when supported, then remaining device ABIs and
|
||||
a universal fallback, subject to minSdk. Universal fallback is limited to
|
||||
the channel's supported playback ABIs: armeabi-v7a, arm64-v8a and x86_64.
|
||||
It cannot make an unsupported processor compatible. Empty-release-list success
|
||||
is distinct from a latest-endpoint 404, authorization failure, invalid metadata
|
||||
or up-to-date code.
|
||||
|
||||
Downloads are app-private. Retry starts a new truncated partial rather than
|
||||
concatenating unvalidated ranges; cancel and process recreation discard partials.
|
||||
Length, SHA-256, APK PackageManager package/version/current signer and manifest
|
||||
signer must agree before Ready. Restoring Ready re-hashes/re-inspects the APK.
|
||||
Checksum files on the same server are not an independent trust authority:
|
||||
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
|
||||
uninstall is involved. PackageManager is re-read on return; a successful callback
|
||||
is not proof that the running process is the new version. Real OEM confirmation,
|
||||
process replacement and retained-login acceptance remain device-walk gates.
|
||||
|
||||
|
||||
### Full-suite test diagnostics
|
||||
|
||||
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
|
||||
|
|
@ -89,20 +246,22 @@ wait for either response. The minified SDK log tag is not stable across builds.
|
|||
|
||||
The `optimized` build type inherits `release`: R8 optimization/minification,
|
||||
resource shrinking, non-debuggable code, dependency consumer keep rules and
|
||||
dependency baseline profiles. It changes only signing (the Android debug key),
|
||||
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
|
||||
The launcher icon/banner and release-compatible version name are unchanged:
|
||||
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
|
||||
`make apk` remains the clean, release-key-signed production build. Debug and
|
||||
optimized containers share the persistent debug key described below; release
|
||||
signing is unchanged.
|
||||
dependency baseline profiles. Its package (`org.justinvisser.wolfskin.tv.opt`),
|
||||
launcher label (`Wolfskin (Opt)`) and persistent signer remain unchanged. It is
|
||||
the household release assembly, with plain versionName `1.1.1` and an explicit
|
||||
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=1 \
|
||||
-PWholphinExtensionsUsername=JustinZeus \
|
||||
-PWholphinExtensionsPassword=unused \
|
||||
:app:assembleDefaultOptimized
|
||||
|
|
@ -115,7 +274,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
|
||||
|
|
@ -958,14 +1118,12 @@ Jellyfin read by the selected person's saved Jellyfin user ID, so a lost
|
|||
Conductarr operator or a renamed household profile does not hide that user's
|
||||
own library. A different saved user ID still fails closed.
|
||||
|
||||
`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite.
|
||||
It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and
|
||||
`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color
|
||||
constructors outside generated tokens, token font sizes below 12 sp, raw
|
||||
household library labels, and page-counter/navigation wording. Its negative
|
||||
cases plant each violation independently. Existing native capture checks also
|
||||
verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures
|
||||
the usable first chunk while the final read is still suspended.
|
||||
`make test` keeps the native capture checks for rendered font sizes and visible
|
||||
surface behavior, plus generated-token drift checks. Styling uses generated
|
||||
tokens rather than raw dimensions. The source-regex wording/style audit was
|
||||
removed: source spelling is not a consumer-visible behavior contract.
|
||||
`LibraryProgressiveSurfaceTest` captures the usable first chunk while the final
|
||||
read is still suspended.
|
||||
|
||||
## Toolchain and disk use
|
||||
|
||||
|
|
@ -1012,21 +1170,25 @@ the checkout when building on another host, for example:
|
|||
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
|
||||
```
|
||||
|
||||
The wrapper creates a missing key once using the build image's `keytool`: RSA
|
||||
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
|
||||
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
|
||||
file mode `0600`. Existing keys are reused, never regenerated; generation errors
|
||||
abort the build rather than falling back to an ephemeral container key.
|
||||
For ordinary debug builds only, the wrapper creates a missing key once using
|
||||
the build image's `keytool`: RSA 2048, 10,000-day validity, alias
|
||||
`androiddebugkey`, store/key passwords `android`, subject `CN=Android Debug`.
|
||||
Household and optimized modes require the existing key and refuse generation.
|
||||
The directory is mode `0700`, the file mode `0600`; existing keys are reused.
|
||||
Generation errors abort rather than falling back to an ephemeral container key.
|
||||
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
|
||||
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
|
||||
Keep and back up this file across clean builds, disposable worktrees, container
|
||||
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
|
||||
are public; this key is for local debug/optimized builds, not release signing.
|
||||
are public; the private key is nevertheless the permanent household signing
|
||||
authority. It is distinct from the old production package's release signer.
|
||||
|
||||
### Preserve household TV data
|
||||
|
||||
Install and update the household TV app only in place with `adb install -r`,
|
||||
using the same package and persistent debug signer. Its certificate SHA-256 is:
|
||||
Normal household updates use the in-app verified download and explicit Android
|
||||
confirmation, preserving the same package and persistent signer. A separately
|
||||
authorized bootstrap may use operator `adb install -r`; that is not a substitute
|
||||
for the in-app update acceptance walk. The certificate SHA-256 is:
|
||||
|
||||
```text
|
||||
182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d
|
||||
|
|
@ -1044,9 +1206,10 @@ a **keep app data** choice during interactive uninstall. Choose it when
|
|||
available; older Android versions ignore the attribute. This is a retention
|
||||
prompt, not an automatic backup or a guarantee against data clearing.
|
||||
|
||||
For an in-place update, compare `dumpsys package <pkg>` before and after:
|
||||
`firstInstallTime` must remain unchanged. The guarded installer that checks
|
||||
the signer and this timestamp lives in the operator's tooling, not this repo.
|
||||
An operator can compare `dumpsys package <pkg>` before and after an in-place
|
||||
update: `firstInstallTime` must remain unchanged. The in-app service checks
|
||||
package, current signer, checksum and higher code; real-device timestamp and
|
||||
retained-login proof remain a separate operator walk, not a JVM-test claim.
|
||||
|
||||
|
||||
## Release credentials
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ else
|
|||
signing_directory=$(dirname "$debug_keystore")
|
||||
mkdir -p "$signing_directory"
|
||||
chmod 700 "$signing_directory"
|
||||
if [[ ( $mode == household || $mode == optimized ) && ! -s $debug_keystore ]]; then
|
||||
printf 'Household signing key is required; refusing to generate an identity\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -e $debug_keystore ]]; then
|
||||
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
|
||||
trap 'rm -rf -- "$temporary_directory"' EXIT
|
||||
|
|
@ -66,5 +70,15 @@ else
|
|||
chmod 600 "$debug_keystore"
|
||||
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
|
||||
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
|
||||
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
||||
if [[ $mode == household ]]; then
|
||||
[[ -s $secrets/github-packages-token ]] || { printf 'Missing native package credential\n' >&2; exit 1; }
|
||||
arguments+=(--mount "type=bind,src=$secrets/github-packages-token,dst=/run/native-token,readonly")
|
||||
docker run "${arguments[@]}" "$image" bash -euc '
|
||||
export ORG_GRADLE_PROJECT_WholphinExtensionsUsername=JustinZeus
|
||||
export ORG_GRADLE_PROJECT_WholphinExtensionsPassword=$(cat /run/native-token)
|
||||
exec ./gradlew "$@"
|
||||
' bash "${gradle_arguments[@]}" "$@"
|
||||
else
|
||||
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
371
docker/test_tv_publish.py
Normal file
371
docker/test_tv_publish.py
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"""Exercise the release CLI against a local Forgejo-shaped HTTP server."""
|
||||
|
||||
import email.parser
|
||||
import email.policy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from tv_publish import REQUIRED, Forge
|
||||
from tv_release import PACKAGE, SIGNER
|
||||
|
||||
|
||||
class FakeForge:
|
||||
"""Draft assets require authentication; anonymous reads work only after promotion."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.release: dict[str, Any] = {}
|
||||
self.assets: dict[str, bytes] = {}
|
||||
self.events: list[tuple[str, bool]] = []
|
||||
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):
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
def respond(self, value: Any, code: int = 200) -> None:
|
||||
data = value if isinstance(value, bytes) else json.dumps(value).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
authenticated = bool(self.headers.get("Authorization"))
|
||||
fixture.events.append(("POST " + self.path, authenticated))
|
||||
if not authenticated:
|
||||
self.respond({}, 401)
|
||||
return
|
||||
data = self.rfile.read(int(self.headers["Content-Length"]))
|
||||
if self.path.endswith("/releases"):
|
||||
fixture.release = json.loads(data) | {"id": 17, "assets": []}
|
||||
self.respond(fixture.release)
|
||||
else:
|
||||
name = urllib.parse.parse_qs(
|
||||
urllib.parse.urlsplit(self.path).query
|
||||
)["name"][0]
|
||||
message = email.parser.BytesParser(
|
||||
policy=email.policy.default
|
||||
).parsebytes(
|
||||
b"Content-Type: "
|
||||
+ self.headers["Content-Type"].encode()
|
||||
+ b"\r\n\r\n"
|
||||
+ data
|
||||
)
|
||||
fixture.assets[name] = next(message.iter_parts()).get_payload(
|
||||
decode=True
|
||||
)
|
||||
asset = {
|
||||
"name": name,
|
||||
"browser_download_url": fixture.base + "/assets/" + name,
|
||||
"size": len(fixture.assets[name]),
|
||||
}
|
||||
fixture.release["assets"].append(asset)
|
||||
self.respond(asset)
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
fixture.events.append(
|
||||
("PATCH", bool(self.headers.get("Authorization")))
|
||||
)
|
||||
fixture.release.update(
|
||||
json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
)
|
||||
self.respond(fixture.release)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
authenticated = bool(self.headers.get("Authorization"))
|
||||
fixture.events.append(("GET " + self.path, authenticated))
|
||||
if fixture.redirect_url:
|
||||
self.send_response(302)
|
||||
self.send_header("Location", fixture.redirect_url)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path.startswith("/assets/"):
|
||||
if fixture.release["draft"] and not authenticated:
|
||||
self.respond({}, 404)
|
||||
return
|
||||
data = fixture.assets[self.path.removeprefix("/assets/")]
|
||||
corrupt = (
|
||||
fixture.corrupt_draft
|
||||
if fixture.release["draft"]
|
||||
else fixture.corrupt_public
|
||||
)
|
||||
self.respond(data + b"corrupt" if corrupt else data)
|
||||
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)
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.base = f"http://127.0.0.1:{self.server.server_port}"
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release fixture resources after each independent publication scenario."""
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join()
|
||||
|
||||
|
||||
class PublishTest(unittest.TestCase):
|
||||
"""Permanent tests assert safety transitions and real uploaded bytes, not script wording."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.forge = FakeForge()
|
||||
self.addCleanup(self.forge.close)
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
assets = []
|
||||
for abi in ("armeabi-v7a", "arm64-v8a", "x86_64", "universal"):
|
||||
name = "Wolfskin.apk" if abi == "universal" else f"Wolfskin-{abi}.apk"
|
||||
data = f"fixture APK bytes for {abi}".encode()
|
||||
(self.directory / name).write_bytes(data)
|
||||
assets.append(
|
||||
{
|
||||
"name": name,
|
||||
"abi": abi,
|
||||
"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"minSdk": 23,
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"packageId": PACKAGE,
|
||||
"versionName": "1.1.1",
|
||||
"versionCode": 100001,
|
||||
"publishedAt": "2026-11-07T12:00:00Z",
|
||||
"sourceCommit": "a" * 40,
|
||||
"sourceTag": "pwolfskin-opt-v1.1.1",
|
||||
"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()
|
||||
|
||||
def checksums(self) -> None:
|
||||
"""Hash fixture files after deliberately constructing or mutating the bundle."""
|
||||
(self.directory / "SHA256SUMS").write_text(
|
||||
"".join(
|
||||
f"{hashlib.sha256((self.directory / name).read_bytes()).hexdigest()} {name}\n"
|
||||
for name in sorted(REQUIRED - {"SHA256SUMS"})
|
||||
)
|
||||
)
|
||||
|
||||
def cli(self, *flags: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Drive the real CLI and real git credential-helper protocol with a fake credential."""
|
||||
environment = os.environ | {
|
||||
"GIT_CONFIG_COUNT": "2",
|
||||
"GIT_CONFIG_KEY_0": "credential.helper",
|
||||
"GIT_CONFIG_VALUE_0": "",
|
||||
"GIT_CONFIG_KEY_1": "credential.helper",
|
||||
"GIT_CONFIG_VALUE_1": "!printf 'username=fixture\npassword=fixture-secret\n'",
|
||||
}
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name("tv_publish.py")),
|
||||
"--directory",
|
||||
str(self.directory),
|
||||
"--test-server",
|
||||
self.forge.base,
|
||||
*flags,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotIn("fixture-secret", result.stdout + result.stderr)
|
||||
return result
|
||||
|
||||
def test_dry_run_never_requests_or_fetches_credentials(self) -> None:
|
||||
result = self.cli("--dry-run", "--promote")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual([], self.forge.events)
|
||||
|
||||
def test_draft_then_explicit_promotion_reverifies_all_bytes(self) -> None:
|
||||
result = self.cli()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(self.forge.release["draft"])
|
||||
self.assertEqual(REQUIRED, set(self.forge.assets))
|
||||
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)
|
||||
|
||||
def test_corrupt_uploaded_bytes_remain_draft(self) -> None:
|
||||
self.forge.corrupt_draft = True
|
||||
result = self.cli("--promote")
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertTrue(self.forge.release["draft"])
|
||||
self.assertFalse(any(event == "PATCH" for event, _ in self.forge.events))
|
||||
|
||||
def test_failed_anonymous_verification_withdraws_promoted_release(self) -> None:
|
||||
self.forge.corrupt_public = True
|
||||
result = self.cli("--promote")
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertTrue(self.forge.release["draft"])
|
||||
self.assertEqual(2, sum(event == "PATCH" for event, _ in self.forge.events))
|
||||
|
||||
def test_invalid_local_checksum_never_creates_draft(self) -> None:
|
||||
(self.directory / "Wolfskin.apk").write_bytes(b"tampered")
|
||||
result = self.cli()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual([], self.forge.events)
|
||||
|
||||
def test_manifest_size_mismatch_never_creates_draft(self) -> None:
|
||||
path = self.directory / "wolfskin-update.json"
|
||||
manifest = json.loads(path.read_text())
|
||||
manifest["assets"][0]["bytes"] += 1
|
||||
path.write_text(json.dumps(manifest))
|
||||
self.checksums()
|
||||
result = self.cli()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual([], self.forge.events)
|
||||
|
||||
def test_empty_manifest_asset_set_never_creates_draft(self) -> None:
|
||||
path = self.directory / "wolfskin-update.json"
|
||||
manifest = json.loads(path.read_text())
|
||||
manifest["assets"] = []
|
||||
path.write_text(json.dumps(manifest))
|
||||
self.checksums()
|
||||
result = self.cli()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual([], self.forge.events)
|
||||
|
||||
def test_credential_helper_tracing_is_disabled(self) -> None:
|
||||
trace = self.directory / "credential.trace"
|
||||
with patch.dict(os.environ, {"GIT_TRACE": str(trace)}):
|
||||
result = self.cli()
|
||||
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)
|
||||
self.forge.redirect_url = other.base + "/asset"
|
||||
forge = Forge(self.forge.base, "Basic fixture-authorization", test_server=True)
|
||||
with self.assertRaises(ValueError):
|
||||
forge.verify(self.forge.base + "/redirect", "0" * 64, authenticated=True)
|
||||
self.assertEqual([], other.events)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
159
docker/test_tv_release.py
Normal file
159
docker/test_tv_release.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Exercise source cleanliness, monotonic codes and fail-closed signing prerequisites."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import tv_release
|
||||
|
||||
|
||||
class ReleaseInputTest(unittest.TestCase):
|
||||
"""Use a disposable Git fixture, never a build or a real signing key."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name) / "source"
|
||||
self.root.mkdir()
|
||||
(self.root / "docker").mkdir()
|
||||
(self.root / "docker/tv-version-codes").write_text("65 installed-opt\n")
|
||||
self.git("init", "--quiet")
|
||||
self.git("add", "docker/tv-version-codes")
|
||||
self.git(
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-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)
|
||||
|
||||
def git(self, *args: str) -> str:
|
||||
"""Run the isolated source fixture's actual Git commands."""
|
||||
return subprocess.check_output(["git", *args], cwd=self.root, text=True).strip()
|
||||
|
||||
def test_new_code_identifies_clean_source(self) -> None:
|
||||
self.assertEqual(
|
||||
self.git("rev-parse", "HEAD"), tv_release.validate_inputs("1.1.1", 100001)
|
||||
)
|
||||
|
||||
def test_reused_and_lower_codes_are_rejected(self) -> None:
|
||||
for code in (1, 64, 65):
|
||||
with self.subTest(code=code), self.assertRaises(ValueError):
|
||||
tv_release.validate_inputs("1.1.1", code)
|
||||
|
||||
def test_dirty_source_is_rejected(self) -> None:
|
||||
(self.root / "untracked").write_text("not part of the source commit")
|
||||
with self.assertRaises(ValueError):
|
||||
tv_release.validate_inputs("1.1.1", 100001)
|
||||
|
||||
def test_unparseable_version_and_invalid_codes_are_rejected(self) -> None:
|
||||
for version, code in (
|
||||
("1.1.1-opt", 100001),
|
||||
("1.1.1", 0),
|
||||
("1.1.1", 2100000001),
|
||||
):
|
||||
with (
|
||||
self.subTest(version=version, code=code),
|
||||
self.assertRaises(ValueError),
|
||||
):
|
||||
tv_release.validate_inputs(version, code)
|
||||
|
||||
def test_missing_signing_key_never_generates_identity(self) -> None:
|
||||
key = Path(self.temporary.name) / "absent-key"
|
||||
with (
|
||||
patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}),
|
||||
self.assertRaises(ValueError),
|
||||
):
|
||||
tv_release.release(
|
||||
"1.1.1", 100001, Path(self.temporary.name) / "output", True
|
||||
)
|
||||
self.assertFalse(key.exists())
|
||||
|
||||
def test_dry_run_never_builds_tags_or_mutates_ledger(self) -> None:
|
||||
key = Path(self.temporary.name) / "fixture-key-presence-only"
|
||||
key.write_bytes(b"not an actual keystore; dry run must not open it")
|
||||
output = Path(self.temporary.name) / "output"
|
||||
with (
|
||||
patch.dict(os.environ, {"WOLFSKIN_DEBUG_KEYSTORE": str(key)}),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
):
|
||||
tv_release.release("1.1.1", 100001, output, True)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual("", self.git("tag", "--list"))
|
||||
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__":
|
||||
unittest.main()
|
||||
3
docker/tv-version-codes
Normal file
3
docker/tv-version-codes
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Household org.justinvisser.wolfskin.tv.opt codes already used.
|
||||
# Append successful tv-release codes; commit this ledger before another release.
|
||||
65 installed-opt
|
||||
413
docker/tv_publish.py
Executable file
413
docker/tv_publish.py
Executable file
|
|
@ -0,0 +1,413 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Upload a verified draft; promote only with explicit --promote owner approval."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SERVER = "https://git.justinvisser.org"
|
||||
REPOSITORY = "justinzeus/wolfskin-tv-updates"
|
||||
REQUIRED = {
|
||||
"Wolfskin-armeabi-v7a.apk",
|
||||
"Wolfskin-arm64-v8a.apk",
|
||||
"Wolfskin-x86_64.apk",
|
||||
"Wolfskin.apk",
|
||||
"wolfskin-update.json",
|
||||
"RELEASE-NOTES.md",
|
||||
"SHA256SUMS",
|
||||
}
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def redirect_request(
|
||||
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Forge:
|
||||
"""Credential-scoped release API; credentials exist only in process memory."""
|
||||
|
||||
def __init__(
|
||||
self, server: str, authorization: str, test_server: bool = False
|
||||
) -> None:
|
||||
self.server = server.rstrip("/")
|
||||
self.origin = urllib.parse.urlsplit(self.server)
|
||||
self.authorization = authorization
|
||||
self.test_server = test_server
|
||||
self.opener = urllib.request.build_opener(NoRedirect)
|
||||
self.api = self.server + "/api/v1/repos/" + REPOSITORY
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
data: Any = None,
|
||||
content_type: str | None = None,
|
||||
length: int | None = None,
|
||||
authenticated: bool = True,
|
||||
) -> Any:
|
||||
"""Reject cross-origin credential forwarding and non-HTTPS production URLs."""
|
||||
for _ in range(6):
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
local = self.test_server and parsed.hostname in (
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
)
|
||||
if (
|
||||
parsed.username
|
||||
or parsed.password
|
||||
or (parsed.scheme != "https" and not local)
|
||||
):
|
||||
raise ValueError(
|
||||
"Distribution requests require credential-free HTTPS URLs"
|
||||
)
|
||||
if authenticated and (parsed.scheme, parsed.netloc) != (
|
||||
self.origin.scheme,
|
||||
self.origin.netloc,
|
||||
):
|
||||
raise ValueError(
|
||||
"Refusing to send release credentials to another origin"
|
||||
)
|
||||
headers = {"Accept": "application/json"}
|
||||
if authenticated:
|
||||
headers["Authorization"] = self.authorization
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
if length is not None:
|
||||
headers["Content-Length"] = str(length)
|
||||
request = urllib.request.Request(
|
||||
url, data=data, method=method, headers=headers
|
||||
)
|
||||
try:
|
||||
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):
|
||||
status = error.code
|
||||
error.close()
|
||||
raise ForgeHttpError(method, status) from None
|
||||
location = error.headers.get("Location")
|
||||
error.close()
|
||||
if not location:
|
||||
raise ValueError("Forgejo redirect has no location")
|
||||
url = urllib.parse.urljoin(url, location)
|
||||
raise ValueError("Too many Forgejo redirects")
|
||||
|
||||
def json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
value: dict[str, Any] | None = None,
|
||||
authenticated: bool = True,
|
||||
) -> Any:
|
||||
"""Read or mutate release metadata without exposing HTTP authorization."""
|
||||
data = None if value is None else json.dumps(value).encode()
|
||||
with self.request(
|
||||
method,
|
||||
self.api + path,
|
||||
data,
|
||||
"application/json",
|
||||
authenticated=authenticated,
|
||||
) as response:
|
||||
return json.load(response)
|
||||
|
||||
def upload(self, release_id: int, file: Path) -> dict[str, Any]:
|
||||
"""Stream multipart APK bytes instead of copying a universal APK into memory."""
|
||||
boundary = uuid.uuid4().hex
|
||||
prefix = (
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{file.name}"\r\n'
|
||||
"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode()
|
||||
suffix = f"\r\n--{boundary}--\r\n".encode()
|
||||
|
||||
def chunks() -> Iterable[bytes]:
|
||||
yield prefix
|
||||
with file.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
yield chunk
|
||||
yield suffix
|
||||
|
||||
url = f"{self.api}/releases/{release_id}/assets?name={urllib.parse.quote(file.name)}"
|
||||
with self.request(
|
||||
"POST",
|
||||
url,
|
||||
chunks(),
|
||||
f"multipart/form-data; boundary={boundary}",
|
||||
len(prefix) + file.stat().st_size + len(suffix),
|
||||
) as response:
|
||||
return json.load(response)
|
||||
|
||||
def verify(self, url: str, expected: str, authenticated: bool) -> None:
|
||||
"""Verify bytes fetched from Forgejo, not only its declared asset size."""
|
||||
with self.request("GET", url, authenticated=authenticated) as response:
|
||||
actual = hashlib.file_digest(response, "sha256").hexdigest()
|
||||
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."""
|
||||
parsed = urllib.parse.urlsplit(server)
|
||||
environment = {
|
||||
name: value
|
||||
for name, value in os.environ.items()
|
||||
if not name.startswith("GIT_TRACE")
|
||||
and name not in {"GIT_CURL_VERBOSE", "SHELLOPTS", "BASHOPTS", "BASH_ENV", "ENV"}
|
||||
}
|
||||
environment["GIT_TERMINAL_PROMPT"] = "0"
|
||||
result = subprocess.run(
|
||||
["git", "credential", "fill"],
|
||||
input=f"protocol={parsed.scheme}\nhost={parsed.netloc}\n\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Git credential helper failed; no release created")
|
||||
fields = dict(
|
||||
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
||||
)
|
||||
if not fields.get("password"):
|
||||
raise ValueError("Git credential helper returned no release credential")
|
||||
encoded = base64.b64encode(
|
||||
f"{fields.get('username', '')}:{fields['password']}".encode()
|
||||
).decode()
|
||||
return "Basic " + encoded
|
||||
|
||||
|
||||
def validate_bundle(directory: Path) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"""Require every public asset and verify the local final-byte checksum set."""
|
||||
for name in REQUIRED:
|
||||
if not (directory / name).is_file():
|
||||
raise ValueError(f"Required release asset missing: {name}")
|
||||
hashes: dict[str, str] = {}
|
||||
for line in (directory / "SHA256SUMS").read_text().splitlines():
|
||||
match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9_.-]+)", line)
|
||||
if match is None or match[2] in hashes:
|
||||
raise ValueError("Invalid or duplicate SHA256SUMS entry")
|
||||
hashes[match[2]] = match[1]
|
||||
if set(hashes) != REQUIRED - {"SHA256SUMS"}:
|
||||
raise ValueError("Checksum set must cover exactly the public artifacts")
|
||||
for name, expected in hashes.items():
|
||||
with (directory / name).open("rb") as stream:
|
||||
if hashlib.file_digest(stream, "sha256").hexdigest() != expected:
|
||||
raise ValueError(f"Local checksum mismatch: {name}")
|
||||
hashes["SHA256SUMS"] = hashlib.sha256(
|
||||
(directory / "SHA256SUMS").read_bytes()
|
||||
).hexdigest()
|
||||
manifest = json.loads((directory / "wolfskin-update.json").read_text())
|
||||
if (
|
||||
manifest["schemaVersion"] != 1
|
||||
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
|
||||
):
|
||||
raise ValueError("Wrong household manifest identity")
|
||||
if (
|
||||
manifest["signerSha256"]
|
||||
!= "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
|
||||
):
|
||||
raise ValueError("Wrong household signing authority")
|
||||
version = manifest["versionName"]
|
||||
code = manifest["versionCode"]
|
||||
if (
|
||||
not re.fullmatch(r"\d+\.\d+\.\d+", version)
|
||||
or type(code) is not int
|
||||
or not 1 <= code <= 2100000000
|
||||
):
|
||||
raise ValueError("Invalid household version identity")
|
||||
if (
|
||||
not re.fullmatch(r"[0-9a-f]{40}", manifest["sourceCommit"])
|
||||
or manifest["sourceTag"] != f"pwolfskin-opt-v{version}"
|
||||
):
|
||||
raise ValueError("Invalid immutable source identity")
|
||||
published = datetime.datetime.fromisoformat(
|
||||
manifest["publishedAt"].replace("Z", "+00:00")
|
||||
)
|
||||
if published.utcoffset() is None:
|
||||
raise ValueError("Publication time must include a timezone")
|
||||
expected_abis = {"armeabi-v7a", "arm64-v8a", "x86_64", "universal"}
|
||||
if (
|
||||
len(manifest["assets"]) != 4
|
||||
or {asset["abi"] for asset in manifest["assets"]} != expected_abis
|
||||
):
|
||||
raise ValueError("Manifest must describe all four standalone APKs")
|
||||
for asset in manifest["assets"]:
|
||||
expected_name = (
|
||||
"Wolfskin.apk"
|
||||
if asset["abi"] == "universal"
|
||||
else f"Wolfskin-{asset['abi']}.apk"
|
||||
)
|
||||
if asset["name"] != expected_name or asset["minSdk"] < 23:
|
||||
raise ValueError("Invalid APK asset identity or minimum SDK")
|
||||
path = directory / asset["name"]
|
||||
if (
|
||||
hashes.get(path.name) != asset["sha256"]
|
||||
or path.stat().st_size != asset["bytes"]
|
||||
):
|
||||
raise ValueError("Manifest does not match final APK bytes")
|
||||
return manifest, hashes
|
||||
|
||||
|
||||
def publish(
|
||||
directory: Path,
|
||||
server: str,
|
||||
promote: bool,
|
||||
dry_run: bool,
|
||||
test_server: bool = False,
|
||||
draft_id: int | None = None,
|
||||
) -> None:
|
||||
"""Keep incomplete uploads draft; withdraw a failed promoted release from latest."""
|
||||
manifest, hashes = validate_bundle(directory)
|
||||
version = manifest["versionName"]
|
||||
tag = f"wolfskin-opt-v{version}"
|
||||
if dry_run:
|
||||
print(
|
||||
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(
|
||||
"POST",
|
||||
"/releases",
|
||||
{
|
||||
"name": f"v{version}",
|
||||
"tag_name": tag,
|
||||
"body": (directory / "RELEASE-NOTES.md").read_text(),
|
||||
"draft": True,
|
||||
"prerelease": False,
|
||||
},
|
||||
)
|
||||
else:
|
||||
release = forge.json("GET", f"/releases/{draft_id}")
|
||||
if not release["draft"] or release["tag_name"] != tag:
|
||||
raise ValueError("Resume target must be the matching draft release")
|
||||
release_id = release["id"]
|
||||
existing = {asset["name"]: asset for asset in release.get("assets", [])}
|
||||
print(
|
||||
f"Verifying draft release {release_id}; it stays draft on any verification failure"
|
||||
)
|
||||
uploaded: dict[str, str] = {}
|
||||
for name, expected in hashes.items():
|
||||
asset = existing.get(name) or forge.upload(release_id, directory / name)
|
||||
url = asset["browser_download_url"]
|
||||
forge.verify(url, expected, authenticated=True)
|
||||
uploaded[name] = url
|
||||
if not promote:
|
||||
print(
|
||||
"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)
|
||||
if latest["id"] != release_id or latest["tag_name"] != tag:
|
||||
raise ValueError("Anonymous latest does not identify the promoted release")
|
||||
for name, url in uploaded.items():
|
||||
forge.verify(url, hashes[name], authenticated=False)
|
||||
except (ValueError, RuntimeError, OSError, KeyError, TypeError):
|
||||
try:
|
||||
forge.json("PATCH", f"/releases/{release_id}", {"draft": True})
|
||||
except (ValueError, RuntimeError, OSError):
|
||||
raise RuntimeError(
|
||||
"Post-publication verification AND withdrawal failed; release may remain public"
|
||||
) from None
|
||||
raise RuntimeError(
|
||||
"Post-publication verification failed; release withdrawn to draft"
|
||||
) from None
|
||||
print(f"Published and anonymously verified {tag}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Expose a deliberately separate publication command."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--directory", required=True, type=Path)
|
||||
parser.add_argument("--promote", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument(
|
||||
"--draft-id", type=int, help="Resume and reverify an existing matching draft"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-server", help="Local fake Forgejo only; HTTP loopback permitted"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.test_server:
|
||||
parsed = urllib.parse.urlsplit(args.test_server)
|
||||
if parsed.scheme != "http" or parsed.hostname not in (
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
):
|
||||
parser.error("--test-server is restricted to an HTTP loopback fixture")
|
||||
publish(
|
||||
args.directory.resolve(),
|
||||
args.test_server or SERVER,
|
||||
args.promote,
|
||||
args.dry_run,
|
||||
bool(args.test_server),
|
||||
args.draft_id,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
254
docker/tv_release.py
Executable file
254
docker/tv_release.py
Executable file
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build and inspect the household assembly without creating a signing identity."""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE = "org.justinvisser.wolfskin.tv.opt"
|
||||
SIGNER = "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
|
||||
ABIS = ("armeabi-v7a", "arm64-v8a", "x86_64", "universal")
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run(*arguments: str) -> str:
|
||||
"""Run a checked command whose arguments contain no credentials."""
|
||||
return subprocess.check_output(arguments, cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
"""Hash final artifact bytes without loading an APK into memory."""
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def validate_inputs(version: str, code: int) -> str:
|
||||
"""Require a clean immutable source and a code newer than the tracked history."""
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+", version) or not 1 <= code <= 2100000000:
|
||||
raise ValueError("Supply a plain VERSION and a positive Android VERSION_CODE")
|
||||
if run("git", "status", "--porcelain"):
|
||||
raise ValueError(
|
||||
"Release source must be clean, including the version-code ledger"
|
||||
)
|
||||
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 reserved maximum {max(used)}")
|
||||
if list((ROOT / "app/libs").glob("*.aar")):
|
||||
raise ValueError("Local AAR overrides are forbidden for household releases")
|
||||
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:
|
||||
"""Verify identity, final signature, optimization and playback-complete native inventory."""
|
||||
image = os.environ.get("WOLFSKIN_BUILD_IMAGE", "wolfskin-build:sdk37-jdk21")
|
||||
mount = f"type=bind,src={path.parent},dst=/artifacts,readonly"
|
||||
base = (
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--platform",
|
||||
"linux/amd64",
|
||||
"--mount",
|
||||
mount,
|
||||
image,
|
||||
)
|
||||
badging = run(*base, "aapt2", "dump", "badging", f"/artifacts/{path.name}")
|
||||
certificate = run(
|
||||
*base,
|
||||
"apksigner",
|
||||
"verify",
|
||||
"--verbose",
|
||||
"--print-certs",
|
||||
f"/artifacts/{path.name}",
|
||||
)
|
||||
proof.write_text(badging + "\n" + certificate + "\n")
|
||||
package = re.search(
|
||||
r"package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'", badging
|
||||
)
|
||||
if package is None or package.groups() != (PACKAGE, str(code), version):
|
||||
raise ValueError(f"Wrong APK identity: {path.name}")
|
||||
fingerprints = re.findall(
|
||||
r"Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)", certificate
|
||||
)
|
||||
if fingerprints != [SIGNER]:
|
||||
raise ValueError(f"Wrong household signer: {path.name}")
|
||||
if "application-debuggable" in badging:
|
||||
raise ValueError("Household APK must not be debuggable")
|
||||
minimum = re.search(r"sdkVersion:'(\d+)'", badging)
|
||||
if minimum is None:
|
||||
raise ValueError("APK minimum SDK is missing")
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = archive.namelist()
|
||||
for profile in ("assets/dexopt/baseline.prof", "assets/dexopt/baseline.profm"):
|
||||
if profile not in names:
|
||||
raise ValueError(f"Missing optimized profile: {profile}")
|
||||
if any(
|
||||
name.startswith(("assets/wolfskin-", "assets/library/")) for name in names
|
||||
):
|
||||
raise ValueError("Debug fixtures leaked into the household assembly")
|
||||
for target in ABIS[:-1] if abi == "universal" else (abi,):
|
||||
libraries = [name for name in names if name.startswith(f"lib/{target}/")]
|
||||
for component in ("mpv", "ffmpeg", "av1"):
|
||||
if not any(component in name.lower() for name in libraries):
|
||||
raise ValueError(f"Missing native {component} for {target}")
|
||||
proof.with_suffix(".inventory").write_text("\n".join(names) + "\n")
|
||||
return int(minimum.group(1))
|
||||
|
||||
|
||||
def release(version: str, code: int, output: Path, dry_run: bool) -> None:
|
||||
"""Produce named APKs, inspected metadata, checksums and source-linked release notes."""
|
||||
commit = validate_inputs(version, code)
|
||||
tag = f"pwolfskin-opt-v{version}"
|
||||
key = Path(
|
||||
os.environ.get(
|
||||
"WOLFSKIN_DEBUG_KEYSTORE",
|
||||
"/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore",
|
||||
)
|
||||
)
|
||||
if not key.is_file() or key.stat().st_size == 0:
|
||||
raise ValueError(
|
||||
"Existing household signing key required; never generate a replacement"
|
||||
)
|
||||
if output.exists():
|
||||
raise ValueError("Release output must not exist; preserve previous proof")
|
||||
if dry_run:
|
||||
print(
|
||||
f"Dry run: source {commit}; defaultOptimized {version} code {code}; four APKs, manifest, notes and SHA256SUMS; no build, tag or publication"
|
||||
)
|
||||
return
|
||||
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)
|
||||
with (private / "build.log").open("w") as log:
|
||||
subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"docker/gradle.sh",
|
||||
"household",
|
||||
f"-PtvVersion={version}",
|
||||
f"-PtvVersionCode={code}",
|
||||
"assembleDefaultOptimized",
|
||||
":app:dependencies",
|
||||
"--configuration",
|
||||
"defaultOptimizedRuntimeClasspath",
|
||||
],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
assets: list[dict[str, object]] = []
|
||||
for abi in ABIS:
|
||||
suffix = "" if abi == "universal" else f"-{abi}"
|
||||
source = (
|
||||
ROOT
|
||||
/ f"app/build/outputs/apk/default/optimized/Wholphin-default-optimized-{version}-{code}{suffix}.apk"
|
||||
)
|
||||
target = output / f"Wolfskin{suffix}.apk"
|
||||
shutil.copyfile(source, target)
|
||||
minimum = inspect_apk(target, version, code, abi, private / f"{abi}.inspection")
|
||||
assets.append(
|
||||
{
|
||||
"abi": abi,
|
||||
"name": target.name,
|
||||
"bytes": target.stat().st_size,
|
||||
"sha256": digest(target),
|
||||
"minSdk": minimum,
|
||||
}
|
||||
)
|
||||
mapping = ROOT / "app/build/outputs/mapping/defaultOptimized"
|
||||
shutil.copytree(mapping, private / "mapping")
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"packageId": PACKAGE,
|
||||
"versionName": version,
|
||||
"versionCode": code,
|
||||
"publishedAt": datetime.datetime.now(datetime.UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
"sourceCommit": commit,
|
||||
"sourceTag": tag,
|
||||
"signerSha256": SIGNER,
|
||||
"assets": assets,
|
||||
}
|
||||
(output / "wolfskin-update.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
source = f"https://git.justinvisser.org/justinzeus/wolfskin/src/tag/{tag}"
|
||||
(output / "RELEASE-NOTES.md").write_text(
|
||||
f"# Wolfskin TV {version}\n\nIn-app verified household updates with explicit Android confirmation.\n\n"
|
||||
f"Package `{PACKAGE}`, code {code}, signer SHA-256 `{SIGNER}`.\n"
|
||||
"Same-package, same-signer upgrades retain app data; never uninstall or clear data to update. "
|
||||
"An earlier ephemeral signer needs a separately authorized migration, not an automatic reinstall.\n\n"
|
||||
"Android 6 (API 23) or later. Standalone armeabi-v7a, arm64-v8a and x86_64 APKs; "
|
||||
"universal fallback. 32-bit x86 is not MPV-complete and is unsupported for playback. "
|
||||
"MPV, ffmpeg and AV1 extensions: pinned Wholphin extensions 0.2.4.\n\n"
|
||||
f"GPL-2.0-only; Wolfskin derives from Wholphin by damontecres and contributors. "
|
||||
f"Corresponding source: [{commit}]({source}); "
|
||||
f"[source archive](https://git.justinvisser.org/justinzeus/wolfskin/archive/{tag}.tar.gz).\n"
|
||||
"[Upstream Wholphin](https://github.com/damontecres/wholphin), "
|
||||
"[native extension source 0.2.4](https://github.com/damontecres/wholphin-extensions/tree/v0.2.4).\n"
|
||||
)
|
||||
files = sorted(path for path in output.iterdir() if path.is_file())
|
||||
(output / "SHA256SUMS").write_text(
|
||||
"".join(f"{digest(path)} {path.name}\n" for path in files)
|
||||
)
|
||||
if not existing:
|
||||
run("git", "tag", tag, commit)
|
||||
with (ROOT / "docker/tv-version-codes").open("a") as ledger:
|
||||
ledger.write(f"{code} {version} {commit}\n")
|
||||
print(
|
||||
f"Release packaged at {output}; commit the code ledger and push source tag {tag} before distribution. Nothing published."
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Expose the make tv-release build contract."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--version-code", required=True, type=int)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
release(args.version, args.version_code, args.output.resolve(), args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue