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
93 changes: 93 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: Release APK

# Build a signed release APK and attach it to a GitHub Release.
#
# Triggers:
# - pushing a tag like v0.1.0 -> builds and publishes a GitHub Release
# - manual "Run workflow" -> builds and uploads the APK as an artifact
#
# Signing:
# If the repo defines the KEYSTORE_BASE64 / KEYSTORE_PASSWORD / KEY_ALIAS /
# KEY_PASSWORD secrets, the APK is signed with that keystore (stable signature
# across releases — required for in-place app updates). Otherwise an ephemeral
# keystore is generated so the build still yields an installable, signed APK
# (note: an ephemeral signature changes every run, so such builds can't update
# a previously installed copy — reinstall instead).

on:
push:
tags: [ "v*" ]
workflow_dispatch:

permissions:
contents: write # needed to create the GitHub Release

jobs:
release-apk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- name: Set up Android SDK
uses: android-actions/setup-android@v3

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: "8.7"

- name: Resolve signing keystore
id: keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/ks"
KS="$RUNNER_TEMP/ks/release.jks"
if [ -n "${KEYSTORE_BASE64:-}" ]; then
echo "Using keystore from repository secrets."
echo "$KEYSTORE_BASE64" | base64 -d > "$KS"
echo "store_pass=${KEYSTORE_PASSWORD}" >> "$GITHUB_OUTPUT"
echo "key_alias=${KEY_ALIAS}" >> "$GITHUB_OUTPUT"
echo "key_pass=${KEY_PASSWORD}" >> "$GITHUB_OUTPUT"
else
echo "No KEYSTORE_BASE64 secret; generating an ephemeral keystore."
keytool -genkeypair -dname "CN=IO Tracer, O=cacheMon" \
-alias iotracer -keyalg RSA -keysize 2048 -validity 10000 \
-keystore "$KS" -storepass android -keypass android
echo "store_pass=android" >> "$GITHUB_OUTPUT"
echo "key_alias=iotracer" >> "$GITHUB_OUTPUT"
echo "key_pass=android" >> "$GITHUB_OUTPUT"
fi
echo "path=$KS" >> "$GITHUB_OUTPUT"

- name: Build signed release APK
env:
KEYSTORE_FILE: ${{ steps.keystore.outputs.path }}
KEYSTORE_PASSWORD: ${{ steps.keystore.outputs.store_pass }}
KEY_ALIAS: ${{ steps.keystore.outputs.key_alias }}
KEY_PASSWORD: ${{ steps.keystore.outputs.key_pass }}
run: gradle :app:assembleRelease --no-daemon --stacktrace

- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: io-tracer-release-apk
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error

- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: app/build/outputs/apk/release/*.apk
generate_release_notes: true
32 changes: 32 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,45 @@ android {
versionName = "0.1.0"
}

// Release signing is driven by environment variables (set by the release
// workflow). When they are absent — e.g. a plain local `assembleDebug` — no
// signing config is attached and the release variant is left unsigned.
val envKeystoreFile = System.getenv("KEYSTORE_FILE")
val envKeystorePassword = System.getenv("KEYSTORE_PASSWORD")
val envKeyAlias = System.getenv("KEY_ALIAS")
val envKeyPassword = System.getenv("KEY_PASSWORD")
val hasSigningEnv = !envKeystoreFile.isNullOrEmpty() &&
!envKeystorePassword.isNullOrEmpty() &&
!envKeyAlias.isNullOrEmpty() &&
!envKeyPassword.isNullOrEmpty()
// Fail loudly on a half-configured keystore rather than silently publishing an
// unsigned release: if a keystore is named, the rest must be present too.
require(envKeystoreFile.isNullOrEmpty() || hasSigningEnv) {
"KEYSTORE_FILE is set, but one or more of KEYSTORE_PASSWORD, KEY_ALIAS, " +
"KEY_PASSWORD is missing or empty."
}
signingConfigs {
create("release") {
if (hasSigningEnv) {
// Resolve a relative path against the repo root, not the app module.
storeFile = rootProject.file(envKeystoreFile!!)
storePassword = envKeystorePassword
keyAlias = envKeyAlias
keyPassword = envKeyPassword
}
}
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
if (hasSigningEnv) {
signingConfig = signingConfigs.getByName("release")
}
}
}

Expand Down
34 changes: 34 additions & 0 deletions docs/ANDROID_APP.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,40 @@ gradle :app:testDebugUnitTest # run the parser/pairer unit tests
CI (`.github/workflows/android.yml`) runs the unit tests and assembles the debug
APK on every push/PR and uploads the APK as an artifact.

## Releases (signed release APK)

A signed **release** APK is produced by `.github/workflows/release.yml`:

- **Push a tag** `vX.Y.Z` (e.g. `git tag v0.1.0 && git push origin v0.1.0`) — the
workflow builds, signs, and publishes a **GitHub Release** with the APK attached.
- Or trigger it manually from **Actions ▸ Release APK ▸ Run workflow**; the signed
APK is uploaded as a workflow artifact.

**Signing.** If the repo defines `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`,
`KEY_ALIAS`, and `KEY_PASSWORD` secrets, the APK is signed with that keystore —
use this so updates install in place over previous versions. Without those
secrets the workflow generates an **ephemeral** keystore so the build still yields
an installable, signed APK; note an ephemeral signature changes every run, so
those builds can't update an already-installed copy (uninstall first).

To create the keystore for the secrets path:

```bash
keytool -genkeypair -v -keystore release.jks -alias iotracer \
-keyalg RSA -keysize 2048 -validity 10000
base64 release.jks | tr -d '\n' # paste into the KEYSTORE_BASE64 repo secret
```

Build a signed release APK locally:

```bash
KEYSTORE_FILE=$PWD/release.jks KEYSTORE_PASSWORD=… KEY_ALIAS=iotracer KEY_PASSWORD=… \
gradle :app:assembleRelease # APK -> app/build/outputs/apk/release/
```

> Release builds use the same code as debug (`isMinifyEnabled = false`); only
> signing differs. The app still requires **root** at runtime.

## Run

1. Install the debug APK on a rooted device (`adb install app-debug.apk`).
Expand Down
Loading