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
26 changes: 26 additions & 0 deletions .github/workflows/android-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
storeFile=release.keystore
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF

# ---- Release build (signed) ----
- name: Build release APK
run: |
Expand Down
52 changes: 52 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,58 @@ Optional overrides:
- `SMOKE_HEALTH_URL` (if health endpoint is not `/api/health`)
- `SMOKE_TIMEOUT_SECONDS` (default: `20`)



## Android Release Signing

Android release builds (`assembleRelease` / `bundleRelease`) MUST be signed
with a real release keystore. The OTA update manifest advertises these
artifacts under the stable/beta channels and un-signed / debug-signed APKs
are rejected by Android on real devices, so the release build type fails fast
when signing is not configured instead of silently publishing an unsigned APK.

### How it works

`android/app/build.gradle` reads `android/key.properties` at configure time.
The properties file contains the keystore path and three passwords/aliases.
Both `android/key.properties` and `*.keystore` / `*.jks` are gitignored; a
template lives at `android/key.properties.example`.

When `android/key.properties` is missing, the release build type throws a
`GradleException` with a pointer at this section. When it is present, the
`signingConfigs.release` block is wired up and applied to the release build
type so the resulting APK / AAB is signed with the supplied keystore.

### Required GitHub Actions secrets

Configure these repository secrets before triggering a release. They are
consumed by `.github/workflows/android-release.yml`:

| Secret | Description |
|---|---|
| `ANDROID_KEYSTORE_BASE64` | Base64-encoded contents of the release keystore file (e.g. `base64 -w0 release.keystore`). |
| `ANDROID_KEYSTORE_PASSWORD` | Password for the keystore. |
| `ANDROID_KEY_ALIAS` | Alias of the signing key inside the keystore. |
| `ANDROID_KEY_PASSWORD` | Password for the signing key. |

The workflow decodes `ANDROID_KEYSTORE_BASE64` to `android/app/release.keystore`
and writes a populated `android/key.properties` next to the file before
invoking `./gradlew assembleRelease` / `bundleRelease`. Without these
secrets the release job fails at the build step with a clear error pointing
back to this section.

### Local builds

To produce a signed release locally:

1. Generate (or copy) a release keystore, e.g. `keytool -genkey -v -keystore ~/keys/miso-release.jks -alias miso -keyalg RSA -keysize 2048 -validity 10000`.
2. Copy `android/key.properties.example` to `android/key.properties` and fill
in `storeFile` (path relative to `android/app/`), `storePassword`,
`keyAlias`, and `keyPassword`.
3. From `android/`, run `./gradlew assembleRelease`. The resulting APK lives
at `app/build/outputs/apk/release/app-release.apk` and is signed with your
keystore.

## Image Tags
| Event | Tag |
|-------|-----|
Expand Down
7 changes: 7 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ captures/
#*.jks
#*.keystore

# Release signing inputs driven by the Android release pipeline — see
# android/app/build.gradle and RELEASE.md#android-release-signing. These must
# NEVER be committed; CI injects them from GitHub Actions secrets.
**/key.properties
*.keystore
*.jks

# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
Expand Down
40 changes: 40 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
apply plugin: 'com.android.application'

// Release signing configuration is driven by android/key.properties, which is
// gitignored. CI populates the file from GitHub Actions secrets
// (ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS,
// ANDROID_KEY_PASSWORD) and writes the base64-decoded keystore to
// android/app/<storeFile>. 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
Expand All @@ -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."
)
}
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions android/key.properties.example
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions tests/android-release-signing.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading