From a0ee10e4dc8d9906848ae6c68799ddaec44d5306 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 3 Sep 2026 23:36:26 +0000 Subject: [PATCH 1/3] Make GitHub update channel fork-safe via GITHUB_RELEASES_REPO override Default stays hermes-webui/hermes-android; release workflow pins the override to github.repository so a signed github build from a fork checks that fork's releases. Release=play and debug=none channels unchanged. --- .github/workflows/1-orchestration-release.yml | 2 ++ app/build.gradle.kts | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/1-orchestration-release.yml b/.github/workflows/1-orchestration-release.yml index 0b4b4e0..932cb2d 100644 --- a/.github/workflows/1-orchestration-release.yml +++ b/.github/workflows/1-orchestration-release.yml @@ -161,6 +161,8 @@ jobs: script: ./gradlew --no-daemon connectedDebugAndroidTest - name: Build signed release artifacts + env: + GITHUB_RELEASES_REPO: ${{ github.repository }} shell: bash run: | ./gradlew test lintDebug :app:stageGithubReleaseApk :app:bundleRelease --no-daemon diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0082907..e07dd08 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,17 @@ val appVersionCode = run { val distributionArtifactName = "hermes-webui-v$appVersionName" val githubReleaseArtifactName = "$distributionArtifactName-github" +// Repository (owner/name) whose GitHub Releases the "github" build type checks. +// Defaults to upstream so local/upstream builds are unchanged; release workflows +// override it with the repository running the workflow so fork builds check fork releases. +val defaultGithubReleaseRepo = "hermes-webui/hermes-android" +val githubReleaseRepo: String = run { + val candidate = providers.gradleProperty("githubReleaseRepo").orNull + ?: providers.environmentVariable("GITHUB_RELEASES_REPO").orNull + candidate?.trim()?.takeIf { it.isNotEmpty() && it.contains('/') } + ?: defaultGithubReleaseRepo +} + val keystoreProperties = Properties().apply { val propertiesFile = rootProject.file("keystore.properties") if (propertiesFile.exists()) { @@ -137,12 +148,12 @@ extensions.configure("android") { buildConfigField( "String", "GITHUB_RELEASES_API_URL", - "\"https://api.github.com/repos/hermes-webui/hermes-android/releases/latest\"" + "\"https://api.github.com/repos/$githubReleaseRepo/releases/latest\"" ) buildConfigField( "String", "GITHUB_RELEASES_PAGE_URL", - "\"https://github.com/hermes-webui/hermes-android/releases/latest\"" + "\"https://github.com/$githubReleaseRepo/releases/latest\"" ) } debug { From 96388f17b5e6a03b0c4ad069e061bda54ede676c Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 4 Sep 2026 00:57:11 +0000 Subject: [PATCH 2/3] docs: add phone/tablet GitHub APK acceptance checklist and human approval gate --- README.md | 5 ++++- RELEASE.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6064cb7..6521f9b 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,10 @@ Git and never commits or pushes source changes. `versionCode` is derived from semantic version as `major*10000 + minor*100 + patch`; update the Gradle and README metadata together before starting a release. -See [RELEASE.md](./RELEASE.md) for the operator workflow. +See [RELEASE.md](./RELEASE.md) for the operator workflow, including the phone +and tablet GitHub APK acceptance checklist and the human approval gate before +any push, tag, release, or secret change. Android updates always hand off to +the system installer; no silent install is promised. ## Architecture diff --git a/RELEASE.md b/RELEASE.md index 33935b7..e8aa283 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -74,6 +74,56 @@ manually with the same Play AAB artifact metadata. Do not rerun `1 - Orchestration Release` just to retry one failed publish target unless the build artifacts are missing or expired. +## Device Acceptance: GitHub APK (Phone + Tablet) + +Run this checklist on one phone and one tablet after every GitHub APK release. +It documents exact commands and artifacts; it never installs anything by +itself — Android updates always hand off to the system installer, so no silent +install is promised or expected. + +### Human Approval Gate (stop point) + +Before pushing, tagging, creating a release, or touching any secret: + +1. Stop and get explicit human approval naming the exact version, tag, and + publish target(s). +2. Never read, print, paste, or request secret values. Names only: + `ANDROID_KEYSTORE_BASE`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, + `ANDROID_KEY_PASSWORD`, `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE`. + +### Release Artifacts to Collect (before device testing) + +- APK artifact name: `hermes-webui-v-github.apk` +- Version name (must end in `-github`) and version code from the workflow summary +- APK file SHA-256 and signing-cert SHA-256 digests printed by the release + verifier (`tools/verify_release_apk.py`); only digests are ever printed + +```powershell +python tools/verify_release_apk.py --apk ` + --expected-package com.hermeswebui.android.github ` + --expected-version-name -github ` + --expected-version-code +./gradlew.bat -q :app:printReleaseVersionName --no-daemon +``` + +### Per-Device Checklist (repeat on phone and tablet) + +1. Package check: installed app id is `com.hermeswebui.android.github` + (`adb shell pm list packages | findstr hermes`). +2. Version check: Settings shows the released version name; + `adb shell dumpsys package com.hermeswebui.android.github | findstr versionName`. +3. Cert/SHA-256 check: downloaded APK file hash and signing-cert digest match + the release verifier output from the step above. +4. Download/install prompt: in-app update flow shows `Check` -> `Download` -> + `Install`; tapping Install opens the Android system installer prompt (not a + silent install); an install-ready notification appears when Hermes is + backgrounded mid-download. +5. No-update behavior: with the latest version already installed, `Check` + reports no update available and shows no download/install action. +6. Rollback/manual fallback: if the new build misbehaves, uninstall it and + reinstall the previous release APK from the GitHub Releases page by hand; + confirm Settings shows the previous version name afterward. + ## Safety Checks - Release workflows use concurrency groups to avoid duplicate publishing for From fa9f7d94c8c9aa1623ae0cdaa9543b97aa1fabe6 Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 4 Sep 2026 01:34:35 +0000 Subject: [PATCH 3/3] android: add GitHub release APK verifier + server/tailscale tests; fix workflow version interpolation --- .github/workflows/1-orchestration-release.yml | 23 ++++ .../android/ServerUrlValidatorTest.kt | 17 +++ .../android/TailscaleEndpointDetectorTest.kt | 19 +++ tools/tests/test_verify_release_apk.py | 128 ++++++++++++++++++ tools/verify_release_apk.py | 113 ++++++++++++++++ 5 files changed, 300 insertions(+) create mode 100644 tools/tests/test_verify_release_apk.py create mode 100644 tools/verify_release_apk.py diff --git a/.github/workflows/1-orchestration-release.yml b/.github/workflows/1-orchestration-release.yml index 932cb2d..e007fcd 100644 --- a/.github/workflows/1-orchestration-release.yml +++ b/.github/workflows/1-orchestration-release.yml @@ -211,6 +211,29 @@ jobs: exit 1 } + - name: Verify GitHub APK identity (package, version, SHA-256, signing cert) + shell: bash + run: | + apk_path="$(find build/release -maxdepth 1 -type f -name 'hermes-webui-v${{ steps.release_version.outputs.version_name }}-github.apk')" + test -n "$apk_path" || { echo "Staged GitHub APK not found for verification"; exit 1; } + + # versionCode follows the semver formula in app/build.gradle.kts: major*10000 + minor*100 + patch + expected_version_code="$(python3 -c 'import re, sys; m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", sys.argv[1]); sys.exit(2) if not m else print(int(m.group(1)) * 10000 + int(m.group(2)) * 100 + int(m.group(3)))' "${{ steps.release_version.outputs.version_name }}")" + + summary_tmp="$(mktemp)" + if ! python3 tools/verify_release_apk.py \ + --apk "$apk_path" \ + --expected-package com.hermeswebui.android.github \ + --expected-version-name "${{ steps.release_version.outputs.version_name }}-github" \ + --expected-version-code "$expected_version_code" > "$summary_tmp"; then + cat "$summary_tmp" >> "$GITHUB_STEP_SUMMARY" + rm -f "$summary_tmp" + echo "GitHub APK identity verification failed." + exit 1 + fi + cat "$summary_tmp" >> "$GITHUB_STEP_SUMMARY" + rm -f "$summary_tmp" + - name: Set artifact names id: artifact_names shell: bash diff --git a/app/src/test/java/com/hermeswebui/android/ServerUrlValidatorTest.kt b/app/src/test/java/com/hermeswebui/android/ServerUrlValidatorTest.kt index fb0f757..4f9df95 100644 --- a/app/src/test/java/com/hermeswebui/android/ServerUrlValidatorTest.kt +++ b/app/src/test/java/com/hermeswebui/android/ServerUrlValidatorTest.kt @@ -26,4 +26,21 @@ class ServerUrlValidatorTest { fun `rejects unsupported scheme`() { assertThat(validator.isValid("ftp://hermes.example.com")).isFalse() } + + @Test + fun `accepts host with port and path`() { + assertThat(validator.isValid("https://hermes.example.com:8443/dashboard?tab=chat")).isTrue() + assertThat(validator.isValid("http://192.168.1.50:8080/")).isTrue() + } + + @Test + fun `rejects blank input`() { + assertThat(validator.isValid("")).isFalse() + assertThat(validator.isValid(" ")).isFalse() + } + + @Test + fun `rejects scheme without host`() { + assertThat(validator.isValid("https://")).isFalse() + } } diff --git a/app/src/test/java/com/hermeswebui/android/TailscaleEndpointDetectorTest.kt b/app/src/test/java/com/hermeswebui/android/TailscaleEndpointDetectorTest.kt index 1f1825a..08909cf 100644 --- a/app/src/test/java/com/hermeswebui/android/TailscaleEndpointDetectorTest.kt +++ b/app/src/test/java/com/hermeswebui/android/TailscaleEndpointDetectorTest.kt @@ -25,4 +25,23 @@ class TailscaleEndpointDetectorTest { assertThat(TailscaleEndpointDetector.isTailscaleUrl("https://hermes.example.com")).isFalse() assertThat(TailscaleEndpointDetector.isTailscaleUrl("https://192.168.1.12")).isFalse() } + + @Test + fun `rejects cgnat addresses just outside the tailscale range`() { + assertThat(TailscaleEndpointDetector.isTailscaleUrl("http://100.63.0.1")).isFalse() + assertThat(TailscaleEndpointDetector.isTailscaleUrl("http://100.128.0.1")).isFalse() + assertThat(TailscaleEndpointDetector.isTailscaleUrl("http://101.64.0.1")).isFalse() + } + + @Test + fun `detects cgnat range boundaries`() { + assertThat(TailscaleEndpointDetector.isTailscaleUrl("http://100.64.0.1")).isTrue() + assertThat(TailscaleEndpointDetector.isTailscaleUrl("http://100.127.255.255")).isTrue() + } + + @Test + fun `rejects invalid ipv4 octets and malformed hosts`() { + assertThat(TailscaleEndpointDetector.isTailscaleUrl("http://100.999.1.1")).isFalse() + assertThat(TailscaleEndpointDetector.isTailscaleUrl("https://not-a-url")).isFalse() + } } diff --git a/tools/tests/test_verify_release_apk.py b/tools/tests/test_verify_release_apk.py new file mode 100644 index 0000000..76d2783 --- /dev/null +++ b/tools/tests/test_verify_release_apk.py @@ -0,0 +1,128 @@ +import hashlib +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from tools import verify_release_apk + + +VALID_BADGING = ( + "package: name='com.hermeswebui.android' versionCode='1029' versionName='1.0.29'\n" + "launchable-activity: name='com.hermeswebui.android.MainActivity' icon-URI='res://56'\n" +) + +VALID_CERTS = ( + "Verified using v1 scheme (JAR signing): false\n" + "Verified using v2 scheme (APK Signature Scheme v2): true\n" + "Signer #1 certificate SHA-256 digest: " + "d098ad9b834d2aec2782e51dbcaadf718e70197ca5af3c0571c41132d61b7ad2\n" +) + +FIXTURE_BYTES = b"hermes-release-apk-fixture-v1\n" +FIXTURE_SHA256 = hashlib.sha256(FIXTURE_BYTES).hexdigest() + + +def run_main(argv: list[str], badging: str, certs: str) -> int: + with mock.patch.object(verify_release_apk.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}"): + with mock.patch.object( + verify_release_apk, + "run_tool", + side_effect=lambda cmd: badging if "badging" in cmd else certs, + ): + with mock.patch.object(verify_release_apk.sys, "argv", ["verify_release_apk.py"] + argv): + return verify_release_apk.main() + + +class ParseBadgingTests(unittest.TestCase): + def test_parse_badging_extracts_package_version_code_and_name(self) -> None: + package, version_code, version_name = verify_release_apk.parse_badging(VALID_BADGING) + self.assertEqual(package, "com.hermeswebui.android") + self.assertEqual(version_code, 1029) + self.assertEqual(version_name, "1.0.29") + + def test_parse_badging_rejects_output_without_package_line(self) -> None: + with self.assertRaises(RuntimeError): + verify_release_apk.parse_badging("launchable-activity: name='x'\n") + + def test_parse_badging_rejects_non_numeric_version_code(self) -> None: + output = "package: name='com.example.app' versionCode='abc' versionName='1.0.0'\n" + with self.assertRaises((RuntimeError, ValueError)): + verify_release_apk.parse_badging(output) + + +class CertSha256RegexTests(unittest.TestCase): + def test_cert_sha256_re_matches_valid_digest(self) -> None: + match = verify_release_apk.CERT_SHA256_RE.search(VALID_CERTS) + self.assertIsNotNone(match) + self.assertEqual( + match.group(1), + "d098ad9b834d2aec2782e51dbcaadf718e70197ca5af3c0571c41132d61b7ad2", + ) + + def test_cert_sha256_re_matches_uppercase_digest(self) -> None: + output = "certificate SHA-256 digest: " + "A" * 64 + match = verify_release_apk.CERT_SHA256_RE.search(output) + self.assertIsNotNone(match) + self.assertEqual(match.group(1), "A" * 64) + + def test_cert_sha256_re_rejects_short_digest(self) -> None: + self.assertIsNone(verify_release_apk.CERT_SHA256_RE.search("certificate SHA-256 digest: abc123\n")) + + def test_cert_sha256_re_rejects_non_hex_characters(self) -> None: + self.assertIsNone(verify_release_apk.CERT_SHA256_RE.search("certificate SHA-256 digest: " + "g" * 64)) + + +class Sha256OfTests(unittest.TestCase): + def test_sha256_of_matches_known_fixture(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + apk = Path(tmp_dir) / "app.apk" + apk.write_bytes(FIXTURE_BYTES) + self.assertEqual(verify_release_apk.sha256_of(str(apk)), FIXTURE_SHA256) + + +class MainExitCodeTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.apk = Path(self._tmp.name) / "app-release.apk" + self.apk.write_bytes(FIXTURE_BYTES) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def base_args(self, **overrides: object) -> list[str]: + args: dict[str, object] = { + "package": "com.hermeswebui.android", + "version_name": "1.0.29", + "version_code": 1029, + } + args.update(overrides) + return [ + "--apk", + str(self.apk), + "--expected-package", + str(args["package"]), + "--expected-version-name", + str(args["version_name"]), + "--expected-version-code", + str(args["version_code"]), + ] + + def test_main_exits_zero_when_all_identity_checks_pass(self) -> None: + self.assertEqual(run_main(self.base_args(), VALID_BADGING, VALID_CERTS), 0) + + def test_main_exits_one_on_package_mismatch(self) -> None: + args = self.base_args(package="com.other.app") + self.assertEqual(run_main(args, VALID_BADGING, VALID_CERTS), 1) + + def test_main_exits_one_on_version_name_mismatch(self) -> None: + args = self.base_args(version_name="9.9.9") + self.assertEqual(run_main(args, VALID_BADGING, VALID_CERTS), 1) + + def test_main_exits_one_on_version_code_mismatch(self) -> None: + args = self.base_args(version_code=999) + self.assertEqual(run_main(args, VALID_BADGING, VALID_CERTS), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_release_apk.py b/tools/verify_release_apk.py new file mode 100644 index 0000000..64fe687 --- /dev/null +++ b/tools/verify_release_apk.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Verify a staged Android APK's identity before release publication. + +Checks the APK file SHA-256, package name, versionName, and versionCode +parsed with `aapt dump badging`, plus the signing certificate SHA-256 digest +from `apksigner verify --print-certs`. Only digests are printed — never +keystore paths, aliases, or passwords. + +Exit code 0 when every check passes; non-zero with a clear error otherwise. +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import shutil +import subprocess +import sys + +PACKAGE_RE = re.compile(r"^package: name='([^']+)' versionCode='(\d+)' versionName='([^']*)'", re.MULTILINE) +CERT_SHA256_RE = re.compile(r"certificate SHA-256 digest:\s*([0-9a-fA-F]{64})") + + +def sha256_of(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run_tool(cmd: list[str]) -> str: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + except FileNotFoundError as exc: + raise RuntimeError(f"Required tool not found: {cmd[0]} ({exc})") from exc + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise RuntimeError(f"`{' '.join(cmd[:2])} ...` failed (exit {result.returncode}): {detail[:500]}") + return result.stdout + + +def parse_badging(output: str) -> tuple[str, int, str]: + match = PACKAGE_RE.search(output) + if not match: + raise RuntimeError("Could not parse package/version from aapt badging output") + package, version_code, version_name = match.groups() + return package, int(version_code), version_name + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify staged release APK identity metadata.") + parser.add_argument("--apk", required=True, help="Path to the staged APK file") + parser.add_argument("--expected-package", required=True, help="Expected applicationId (package name)") + parser.add_argument("--expected-version-name", required=True, help="Expected versionName including suffixes") + parser.add_argument( + "--expected-version-code", + type=int, + default=None, + help="Expected versionCode; when omitted only the APK's own code is reported", + ) + args = parser.parse_args() + + aapt = shutil.which("aapt") or shutil.which("aapt2") + apksigner = shutil.which("apksigner") + if not aapt: + print("ERROR: neither aapt nor aapt2 found on PATH", file=sys.stderr) + return 1 + if not apksigner: + print("ERROR: apksigner not found on PATH", file=sys.stderr) + return 1 + + try: + apk_sha256 = sha256_of(args.apk) + package, version_code, version_name = parse_badging(run_tool([aapt, "dump", "badging", args.apk])) + cert_digest = CERT_SHA256_RE.search(run_tool(["apksigner", "verify", "--print-certs", args.apk])) + except (OSError, RuntimeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + if not cert_digest: + print("ERROR: apksigner did not report a signing certificate SHA-256 digest", file=sys.stderr) + return 1 + cert_sha256 = cert_digest.group(1).lower() + + failures = [] + if package != args.expected_package: + failures.append(f"package: expected {args.expected_package!r}, got {package!r}") + if version_name != args.expected_version_name: + failures.append(f"versionName: expected {args.expected_version_name!r}, got {version_name!r}") + if args.expected_version_code is not None and version_code != args.expected_version_code: + failures.append(f"versionCode: expected {args.expected_version_code}, got {version_code}") + + print("## Staged APK verification") + print() + print(f"- APK file: `{args.apk}`") + print(f"- Package: `{package}`") + print(f"- Version name: `{version_name}`") + print(f"- Version code: `{version_code}`") + print(f"- APK SHA-256: `{apk_sha256}`") + print(f"- Signing cert SHA-256: `{cert_sha256}`") + + if failures: + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + print("All identity checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())