From b842172b4b869c2e0bcf37a7e0a9de65a923bf39 Mon Sep 17 00:00:00 2001 From: Saffron Date: Tue, 30 Jun 2026 20:09:15 -0600 Subject: [PATCH] fix(android-release): wire release signing with key.properties and required secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release build type had no signingConfig, so assembleRelease / bundleRelease produced unsigned APKs / AABs that Android refuses to install on real devices — and the OTA manifest still advertised them as the stable / beta artifacts. Wire signingConfigs.release to read android/key.properties at configure time, fall back to throwing a clear GradleException when the file is missing so we never silently publish an unsigned APK, and document the ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD secrets the workflow now decodes from CI. Add android/key.properties.example as the committed shape reference, keep android/key.properties and *.jks / *.keystore out of git, and cover the new contract in tests/android-release-signing.test.js. --- .github/workflows/android-release.yml | 26 +++++++ RELEASE.md | 52 +++++++++++++ android/.gitignore | 7 ++ android/app/build.gradle | 40 ++++++++++ android/key.properties.example | 21 ++++++ tests/android-release-signing.test.js | 102 ++++++++++++++++++++++++++ 6 files changed, 248 insertions(+) create mode 100644 android/key.properties.example create mode 100644 tests/android-release-signing.test.js diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index ffd0554..9d6423f 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -52,6 +52,32 @@ jobs: - name: Sync Capacitor run: npx cap sync android + # ---- Inject release signing material ---- + # Decodes the base64-encoded keystore (ANDROID_KEYSTORE_BASE64) and + # writes android/key.properties consumed by app/build.gradle. Without + # these secrets the release build type fails fast with a clear error — + # see RELEASE.md#android-release-signing. + - name: Configure release signing + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + set -euo pipefail + if [ -z "$ANDROID_KEYSTORE_BASE64" ] || [ -z "$ANDROID_KEYSTORE_PASSWORD" ] || [ -z "$ANDROID_KEY_ALIAS" ] || [ -z "$ANDROID_KEY_PASSWORD" ]; then + echo "::error::Missing one of ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD; refusing to build an unsigned release APK." >&2 + exit 1 + fi + mkdir -p android/app + echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/release.keystore + cat > android/key.properties <. Local developers copy android/key.properties from a +// trusted source. The release build type fails fast (with a clear error +// message) when the properties are missing so we never publish unsigned APKs. +def keystorePropertiesFile = rootProject.file("key.properties") +def keystoreProperties = new Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + android { namespace = "chat.openclaw.miso" compileSdk = rootProject.ext.compileSdkVersion @@ -16,10 +29,37 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + release { + if (!keystorePropertiesFile.exists()) { + // No key.properties — leave the signing config empty so the + // release build type below fails with a useful error instead + // of silently publishing an unsigned APK. + storeFile = null + storePassword = "" + keyAlias = "" + keyPassword = "" + } else { + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + } + } + } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + if (keystorePropertiesFile.exists()) { + signingConfig signingConfigs.release + } else { + throw new GradleException( + "android/key.properties is missing. Android release builds cannot be produced " + + "without a configured signing keystore; see RELEASE.md#android-release-signing " + + "for the required environment and secrets." + ) + } } } } diff --git a/android/key.properties.example b/android/key.properties.example new file mode 100644 index 0000000..e7a2fb3 --- /dev/null +++ b/android/key.properties.example @@ -0,0 +1,21 @@ +# Template for android/key.properties — this file is a reference only and is +# committed so that contributors know the exact shape required by +# android/app/build.gradle. Do NOT commit a populated copy. +# +# CI populates android/key.properties at build time from GitHub Actions +# secrets. Locally, copy this file to android/key.properties and fill in the +# values from your keystore. See RELEASE.md#android-release-signing for the +# full setup steps and required secrets. + +# Path to the keystore file, relative to android/app/. CI sets this to +# "release.keystore" (decoded from the ANDROID_KEYSTORE_BASE64 secret). +storeFile=release.keystore + +# Keystore password (ANDROID_KEYSTORE_PASSWORD). +storePassword=replace-with-keystore-password + +# Key alias (ANDROID_KEY_ALIAS). +keyAlias=replace-with-key-alias + +# Key password (ANDROID_KEY_PASSWORD). +keyPassword=replace-with-key-password diff --git a/tests/android-release-signing.test.js b/tests/android-release-signing.test.js new file mode 100644 index 0000000..73b8223 --- /dev/null +++ b/tests/android-release-signing.test.js @@ -0,0 +1,102 @@ +'use strict'; + +// Verifies the Android release signing wiring described in +// docs / RELEASE.md#android-release-signing and issue misospace/miso-chat#628. +// +// The tests are static / structural: they assert the build script reads +// android/key.properties, gitignore keeps the file out of the repo, and the +// release workflow injects the required secrets. We avoid spinning up +// gradle on the host because the Android SDK is not installed in CI. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.join(__dirname, '..'); +const buildGradlePath = path.join(repoRoot, 'android', 'app', 'build.gradle'); +const androidGitignorePath = path.join(repoRoot, 'android', '.gitignore'); +const exampleKeyPath = path.join(repoRoot, 'android', 'key.properties.example'); +const releaseDocPath = path.join(repoRoot, 'RELEASE.md'); +const workflowPath = path.join(repoRoot, '.github', 'workflows', 'android-release.yml'); + +function read(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +test('release build type reads android/key.properties via signingConfig', () => { + const gradle = read(buildGradlePath); + + // top-level: load key.properties at configuration time + assert.match(gradle, /keystorePropertiesFile\s*=\s*rootProject\.file\(\s*["']key\.properties["']\s*\)/); + assert.match(gradle, /keystoreProperties\.load\(\s*new\s+FileInputStream\(\s*keystorePropertiesFile\s*\)\s*\)/); + + // signingConfigs.release block wired to the properties + assert.match(gradle, /signingConfigs\s*\{[\s\S]*?release\s*\{/); + assert.match(gradle, /storeFile\s+file\(\s*keystoreProperties\[['"]storeFile['"]\]/); + assert.match(gradle, /storePassword\s+keystoreProperties\[['"]storePassword['"]\]/); + assert.match(gradle, /keyAlias\s+keystoreProperties\[['"]keyAlias['"]\]/); + assert.match(gradle, /keyPassword\s+keystoreProperties\[['"]keyPassword['"]\]/); + + // release build type assigns the signingConfig and fails fast when missing + assert.match(gradle, /signingConfig\s+signingConfigs\.release/); + assert.match( + gradle, + /throw\s+new\s+GradleException\([^)]*key\.properties\s+is\s+missing[^)]*RELEASE\.md#android-release-signing/ + ); +}); + +test('android/.gitignore keeps key.properties, *.keystore and *.jks out of the repo', () => { + const gitignore = read(androidGitignorePath); + + assert.match(gitignore, /\*\*\/key\.properties/); + assert.match(gitignore, /\*\.keystore/); + assert.match(gitignore, /\*\.jks/); +}); + +test('android/key.properties.example exists and matches the key names read by gradle', () => { + const example = read(exampleKeyPath); + + assert.match(example, /^storeFile=/m); + assert.match(example, /^storePassword=/m); + assert.match(example, /^keyAlias=/m); + assert.match(example, /^keyPassword=/m); +}); + +test('RELEASE.md documents the Android release signing contract', () => { + const releaseMd = read(releaseDocPath); + + assert.match(releaseMd, /## Android Release Signing/); + assert.match(releaseMd, /android\/key\.properties/); + assert.match(releaseMd, /ANDROID_KEYSTORE_BASE64/); + assert.match(releaseMd, /ANDROID_KEYSTORE_PASSWORD/); + assert.match(releaseMd, /ANDROID_KEY_ALIAS/); + assert.match(releaseMd, /ANDROID_KEY_PASSWORD/); + // must not leak any actual keystore secret value + assert.doesNotMatch(releaseMd, /^storePassword=\S+/m); +}); + +test('android-release workflow injects signing material from GitHub Actions secrets', () => { + const workflow = read(workflowPath); + + assert.match(workflow, /Configure release signing/); + assert.match(workflow, /ANDROID_KEYSTORE_BASE64:\s*\$\{\{\s*secrets\.ANDROID_KEYSTORE_BASE64\s*\}\}/); + assert.match(workflow, /ANDROID_KEYSTORE_PASSWORD:\s*\$\{\{\s*secrets\.ANDROID_KEYSTORE_PASSWORD\s*\}\}/); + assert.match(workflow, /ANDROID_KEY_ALIAS:\s*\$\{\{\s*secrets\.ANDROID_KEY_ALIAS\s*\}\}/); + assert.match(workflow, /ANDROID_KEY_PASSWORD:\s*\$\{\{\s*secrets\.ANDROID_KEY_PASSWORD\s*\}\}/); + // step that configures signing must run before the gradle assembleRelease steps + const configureIdx = workflow.indexOf('Configure release signing'); + const assembleIdx = workflow.indexOf('assembleRelease'); + assert.ok(configureIdx !== -1 && assembleIdx !== -1 && configureIdx < assembleIdx, + 'Configure release signing must run before assembleRelease'); + assert.match(workflow, /base64\s+-d\s+>\s+android\/app\/release\.keystore/); + assert.match(workflow, /cat\s+>\s+android\/key\.properties/); +}); + +test('gradle wrapper script still exists and is executable for signed-release flows', () => { + const gradlew = path.join(repoRoot, 'android', 'gradlew'); + assert.ok(fs.existsSync(gradlew), 'android/gradlew must exist for ./gradlew assembleRelease'); + const stat = fs.statSync(gradlew); + // owner-exec bit; CI runners may not have the same uid so we accept any exec bit + assert.ok((stat.mode & 0o111) !== 0, 'android/gradlew must be executable'); +});