Skip to content

Commit 7ef18df

Browse files
committed
Add environment-based APK signing and release workflow
1 parent 3660c6e commit 7ef18df

11 files changed

Lines changed: 289 additions & 3 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Release Android artifacts
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
- "release-check/*"
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
concurrency:
14+
group: release-${{ github.ref }}
15+
cancel-in-progress: false
16+
17+
jobs:
18+
release:
19+
name: Build and verify signed APK
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 40
22+
steps:
23+
- uses: actions/checkout@v6
24+
with:
25+
fetch-depth: 0
26+
persist-credentials: false
27+
- name: Validate publication tag
28+
if: startsWith(github.ref, 'refs/tags/v')
29+
run: |
30+
version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' app/build.gradle.kts)"
31+
test "$GITHUB_REF_NAME" = "v$version_name"
32+
git merge-base --is-ancestor HEAD origin/main
33+
- uses: actions/setup-java@v5
34+
with:
35+
distribution: temurin
36+
java-version: "21"
37+
- uses: gradle/actions/setup-gradle@v4
38+
- uses: ruby/setup-ruby@v1
39+
with:
40+
ruby-version: "3.4.10"
41+
bundler-cache: true
42+
- name: Install Android SDK components
43+
run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "build-tools;36.0.0"'
44+
- name: Restore Android signing key
45+
env:
46+
SIGNING_KEY_BASE64: ${{ secrets.ANDROID_SIGNING_KEY_BASE64 }}
47+
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
48+
run: |
49+
test -n "$SIGNING_KEY_BASE64" && test -n "$KEYSTORE_PASSWORD"
50+
umask 077
51+
keystore="$RUNNER_TEMP/message487-release.p12"
52+
password_file="$RUNNER_TEMP/message487-keystore-password"
53+
printf '%s' "$SIGNING_KEY_BASE64" | base64 --decode > "$keystore"
54+
printf '%s' "$KEYSTORE_PASSWORD" > "$password_file"
55+
echo "MESSAGE487_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV"
56+
echo "MESSAGE487_KEY_PASSWORD_FILE=$password_file" >> "$GITHUB_ENV"
57+
- name: Build and verify signed release
58+
env:
59+
MESSAGE487_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
60+
MESSAGE487_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
61+
run: bundle exec fastlane android release_artifacts
62+
- name: Remove signing files
63+
if: always()
64+
run: rm -f "$RUNNER_TEMP/message487-release.p12" "$RUNNER_TEMP/message487-keystore-password"
65+
- uses: actions/upload-artifact@v7
66+
with:
67+
name: message487-signed-release
68+
path: dist/release/*
69+
if-no-files-found: error
70+
retention-days: 14
71+
- name: Publish test and lint reports
72+
if: always()
73+
uses: actions/upload-artifact@v7
74+
with:
75+
name: release-test-reports
76+
path: |
77+
app/build/reports/tests/
78+
app/build/test-results/
79+
app/build/reports/lint-results-*
80+
retention-days: 14
81+
82+
publish:
83+
name: Publish GitHub Release
84+
needs: release
85+
if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push'
86+
runs-on: ubuntu-latest
87+
permissions:
88+
contents: write
89+
steps:
90+
- uses: actions/download-artifact@v8
91+
with:
92+
name: message487-signed-release
93+
path: dist/release
94+
- name: Publish verified artifacts
95+
env:
96+
GH_TOKEN: ${{ github.token }}
97+
run: |
98+
cd dist/release
99+
sha256sum -c SHA256SUMS
100+
gh release create "$GITHUB_REF_NAME" ./*.apk mapping.txt SHA256SUMS \
101+
--repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes \
102+
--title "Message487 $GITHUB_REF_NAME"

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ DevServer/.env
4040

4141
.venv/
4242
__pycache__/
43+
dist/

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ A custom webhook is also supported. Telegram forwarding is one possible workflow
55
app does not depend on Telegram.
66

77
**Status:** development preview with notification/SMS capture, a persistent encrypted outbox,
8-
background delivery, automatic retries and a delivery journal. Webhook authentication and
9-
production release signing are not implemented yet.
8+
background delivery, automatic retries and a delivery journal. Webhook authentication is not
9+
implemented yet. Signed APK release automation is configured; see [Releases](docs/releases.md).
1010

1111
## Getting started
1212

‎app/build.gradle.kts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ plugins {
44
id("org.jetbrains.kotlin.plugin.compose")
55
}
66

7+
val releaseKeystorePath = providers.environmentVariable("MESSAGE487_KEYSTORE_PATH").orNull
8+
val releaseKeystorePassword = providers.environmentVariable("MESSAGE487_KEYSTORE_PASSWORD").orNull
9+
val releaseKeyAlias = providers.environmentVariable("MESSAGE487_KEY_ALIAS").orNull
10+
val releaseKeyPassword = providers.environmentVariable("MESSAGE487_KEY_PASSWORD").orNull
11+
val signingInputs = listOf(releaseKeystorePath, releaseKeystorePassword, releaseKeyAlias, releaseKeyPassword)
12+
if (signingInputs.any { it != null } && signingInputs.any { it.isNullOrBlank() }) {
13+
throw GradleException("Release signing environment is incomplete")
14+
}
15+
716
android {
817
namespace = "life.andre.message487"
918
compileSdk = 36
@@ -13,13 +22,24 @@ android {
1322
minSdk = 26
1423
targetSdk = 36
1524
versionCode = 1
16-
versionName = "0.1.0-dev"
25+
versionName = "0.0.1"
26+
}
27+
signingConfigs {
28+
if (signingInputs.all { !it.isNullOrBlank() }) {
29+
create("release") {
30+
storeFile = file(releaseKeystorePath!!)
31+
storePassword = releaseKeystorePassword
32+
keyAlias = releaseKeyAlias
33+
keyPassword = releaseKeyPassword
34+
}
35+
}
1736
}
1837
buildTypes {
1938
getByName("debug") {
2039
buildConfigField("String", "DEFAULT_WEBHOOK_URL", "\"http://10.0.2.2:5678/webhook/message487/receive\"")
2140
}
2241
getByName("release") {
42+
signingConfig = signingConfigs.findByName("release")
2343
buildConfigField("String", "DEFAULT_WEBHOOK_URL", "\"\"")
2444
isMinifyEnabled = true
2545
isShrinkResources = true

‎docs/project-context.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,5 @@ VPN, Go/JNI, DNS-диагностика и детали публикации Meg
114114
Local rotating diagnostics, a next-launch crash prompt and manual ZIP email reports are implemented; see [diagnostics](diagnostics.md). Recipient: der-morgenstern@yandex.ru.
115115

116116
CI test categories and commands are documented in [testing](testing.md); Compose UI tests run on Robolectric without an emulator.
117+
118+
Signed APK releases use environment-based signing as in MegaProxy; see [releases](releases.md).

‎docs/releases.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Signed APK releases
2+
3+
Run `bundle exec fastlane android release_artifacts` with JDK 21 and Android SDK 36. The lane runs
4+
Android JVM/Compose tests, debug/release lint and a signed release build, then checks the certificate,
5+
package ID, version and non-debuggable flag. Outputs are `dist/release/message487-<version>.apk`,
6+
R8 `mapping.txt` and `SHA256SUMS`. Keep the mapping with its exact APK when diagnosing crashes.
7+
8+
Signing follows MegaProxy's environment contract with the `MESSAGE487_` prefix:
9+
10+
| Variable | Source/default |
11+
| --- | --- |
12+
| `MESSAGE487_KEYSTORE_PATH` | `~/AndroidApkKey` locally; restored temporary file in CI |
13+
| `MESSAGE487_KEY_PASSWORD_FILE` | `~/.my-tokens/android-key-password` locally; temporary file in CI |
14+
| `MESSAGE487_KEYSTORE_PASSWORD` | Exported by the release script from the password file |
15+
| `MESSAGE487_KEY_ALIAS` | `key0` locally; `ANDROID_KEY_ALIAS` secret in CI |
16+
| `MESSAGE487_KEY_PASSWORD` | Password-file contents unless explicitly supplied |
17+
| `MESSAGE487_EXPECTED_CERT_SHA256` | Expected public certificate fingerprint, pinned in the script |
18+
| `MESSAGE487_RELEASE_DIR` | `dist/release` |
19+
20+
Gradle reads only the four signing environment variables (path, store password, alias and key
21+
password). Partial signing configuration fails closed. The PR `checks` lane rejects signing inputs
22+
and verifies an unsigned release APK. Passwords are not command-line arguments and must never be
23+
printed, committed or passed through Gradle `-P` properties.
24+
25+
GitHub Secrets use the same names as MegaProxy: `ANDROID_SIGNING_KEY_BASE64`,
26+
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. The release workflow
27+
restores the key/password with private permissions and deletes those temporary files even on failure.
28+
Its build job has read-only repository permissions; only the separate publication job can write a
29+
GitHub Release. PR workflows do not consume signing secrets.
30+
31+
## Verification and publication
32+
33+
- Push a `release-check/*` tag to build and verify a signed APK in GitHub Actions without publishing
34+
a Release. The signed APK, checksums, mapping and test reports are available as Actions artifacts.
35+
- After the workflow is merged into the default branch, manual dispatch also builds artifacts only.
36+
- For publication, increment `versionCode`, set the intended `versionName` in `app/build.gradle.kts`,
37+
and merge the reviewed change after all required PR checks pass. Push the matching `v<versionName>`
38+
tag. The workflow requires the tag commit to be contained in `main` and rejects a version mismatch.
39+
It publishes the verified APK, mapping and checksums to GitHub Releases. An existing Release is
40+
not overwritten by a rerun.
41+
42+
The first configured version is `0.0.1` with `versionCode = 1`. Later releases must increase
43+
`versionCode` to support Android upgrades. Keep using the same signing key for installed users.

‎docs/testing.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,6 @@ not prove notification/SMS permission delivery, WorkManager/OS scheduling, real
3333
process death handling or email-client behavior. Those remain device/emulator checks; see
3434
[diagnostics](diagnostics.md) for the crash/report scenario. Required hosted CI does not depend on
3535
an Android emulator, following MegaProxy's approach to unreliable KVM availability.
36+
37+
Signed release verification runs the Android test/lint suite separately with signing inputs. PR
38+
checks reject those inputs and remain unsigned. See [release automation](releases.md).

‎fastlane/Fastfile‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ project_root = File.expand_path("..", __dir__)
77
platform :android do
88
desc "Run JVM tests, Android lint, and build debug and unsigned release APKs"
99
lane :checks do
10+
signing_variables = %w[MESSAGE487_KEYSTORE_PATH MESSAGE487_KEYSTORE_PASSWORD MESSAGE487_KEY_ALIAS MESSAGE487_KEY_PASSWORD MESSAGE487_KEY_PASSWORD_FILE]
11+
if signing_variables.any? { |name| !ENV.fetch(name, "").empty? }
12+
UI.user_error!("Signing configuration must not be available to PR checks")
13+
end
1014
gradle(
1115
tasks: %w[testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease],
1216
flags: "--no-daemon",
@@ -41,6 +45,11 @@ platform :android do
4145
sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "DevServer/tests/smoke.py"))
4246
end
4347

48+
desc "Build, sign and verify the release APK and checksums"
49+
lane :release_artifacts do
50+
sh(File.join(project_root, "scripts/build-release-apk.sh"))
51+
end
52+
4453
desc "Build a debug APK"
4554
lane :debug_artifact do
4655
gradle(task: "assembleDebug", project_dir: project_root)

‎fastlane/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ Run Python tests and formatting checks for the development server
3939

4040
Exercise the running development n8n server
4141

42+
### android release_artifacts
43+
44+
```sh
45+
[bundle exec] fastlane android release_artifacts
46+
```
47+
48+
Build, sign and verify the release APK and checksums
49+
4250
### android debug_artifact
4351

4452
```sh

‎scripts/build-release-apk.sh‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
source "$project_dir/scripts/java-toolchain.sh"
6+
: "${ANDROID_HOME:=${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
7+
: "${MESSAGE487_KEYSTORE_PATH:=$HOME/AndroidApkKey}"
8+
: "${MESSAGE487_KEY_PASSWORD_FILE:=$HOME/.my-tokens/android-key-password}"
9+
: "${MESSAGE487_KEY_ALIAS:=key0}"
10+
: "${MESSAGE487_RELEASE_DIR:=$project_dir/dist/release}"
11+
: "${MESSAGE487_EXPECTED_CERT_SHA256:=8a014a2a558a75b5f900ee0c33cd50f24b7432734912406699fc08866747f822}"
12+
export ANDROID_HOME
13+
14+
if [[ ! -f "$MESSAGE487_KEYSTORE_PATH" || ! -f "$MESSAGE487_KEY_PASSWORD_FILE" ]]; then
15+
echo "Release keystore or password file is missing" >&2
16+
exit 1
17+
fi
18+
keystore_password="$(<"$MESSAGE487_KEY_PASSWORD_FILE")"
19+
if [[ -z "$keystore_password" ]]; then
20+
echo "Keystore password file is empty" >&2
21+
exit 1
22+
fi
23+
: "${MESSAGE487_KEY_PASSWORD:=$keystore_password}"
24+
keytool -list -keystore "$MESSAGE487_KEYSTORE_PATH" \
25+
-storepass:file "$MESSAGE487_KEY_PASSWORD_FILE" -alias "$MESSAGE487_KEY_ALIAS" >/dev/null
26+
apksigner="$ANDROID_HOME/build-tools/36.0.0/apksigner"
27+
aapt="$ANDROID_HOME/build-tools/36.0.0/aapt"
28+
if [[ ! -x "$apksigner" || ! -x "$aapt" ]]; then
29+
echo "Android build tools 36.0.0 are required" >&2
30+
exit 1
31+
fi
32+
version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' "$project_dir/app/build.gradle.kts")"
33+
version_code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)/\1/p' "$project_dir/app/build.gradle.kts")"
34+
if [[ ! "$version_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || ! "$version_code" =~ ^[1-9][0-9]*$ ]]; then
35+
echo "A stable versionName and positive versionCode are required" >&2
36+
exit 1
37+
fi
38+
39+
export MESSAGE487_KEYSTORE_PATH MESSAGE487_KEY_ALIAS MESSAGE487_KEY_PASSWORD
40+
export MESSAGE487_KEYSTORE_PASSWORD="$keystore_password"
41+
cd "$project_dir"
42+
# Separate invocations ensure clean finishes before generated-resource tasks start.
43+
./gradlew clean --no-daemon
44+
./gradlew testDebugUnitTest lintDebug lintRelease assembleRelease --no-daemon
45+
apk="$project_dir/app/build/outputs/apk/release/app-release.apk"
46+
actual_fingerprint="$("$apksigner" verify --verbose --print-certs "$apk" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')"
47+
if [[ "$actual_fingerprint" != "$MESSAGE487_EXPECTED_CERT_SHA256" ]]; then
48+
echo "Unexpected release signing certificate" >&2
49+
exit 1
50+
fi
51+
badging="$("$aapt" dump badging "$apk")"
52+
if ! grep -Fq "package: name='life.andre.message487' versionCode='$version_code' versionName='$version_name'" <<< "$badging"; then
53+
echo "Release APK package or version does not match the project" >&2
54+
exit 1
55+
fi
56+
if grep -q '^application-debuggable' <<< "$badging"; then
57+
echo "Release APK must not be debuggable" >&2
58+
exit 1
59+
fi
60+
mkdir -p "$MESSAGE487_RELEASE_DIR"
61+
# Only remove artifacts owned by this script so old APKs cannot enter a new release.
62+
find "$MESSAGE487_RELEASE_DIR" -maxdepth 1 -type f \
63+
\( -name 'message487-*.apk' -o -name mapping.txt -o -name SHA256SUMS \) -delete
64+
cp "$apk" "$MESSAGE487_RELEASE_DIR/message487-$version_name.apk"
65+
cp "$project_dir/app/build/outputs/mapping/release/mapping.txt" "$MESSAGE487_RELEASE_DIR/mapping.txt"
66+
(
67+
cd "$MESSAGE487_RELEASE_DIR"
68+
shasum -a 256 message487-*.apk mapping.txt > SHA256SUMS
69+
)
70+
echo "Verified signed release APK and checksums: $MESSAGE487_RELEASE_DIR"

0 commit comments

Comments
 (0)