Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/1-orchestration-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -209,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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>-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 <staged-apk> `
--expected-package com.hermeswebui.android.github `
--expected-version-name <version>-github `
--expected-version-code <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
Expand Down
15 changes: 13 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -137,12 +148,12 @@ extensions.configure<ApplicationExtension>("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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
128 changes: 128 additions & 0 deletions tools/tests/test_verify_release_apk.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading