android: add play release upload lane

This commit is contained in:
joshavant
2026-06-16 17:39:05 +02:00
parent 91220cbd31
commit 91fb5d3823
16 changed files with 484 additions and 0 deletions

6
.gitignore vendored
View File

@@ -83,6 +83,12 @@ apps/ios/fastlane/screenshots/
apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/
apps/ios/fastlane/.env
apps/android/fastlane/report.xml
apps/android/fastlane/Preview.html
apps/android/fastlane/test_output/
apps/android/fastlane/logs/
apps/android/fastlane/.env
apps/android/fastlane/metadata/android/**/images/
# fastlane build artifacts (local)
apps/ios/*.ipa

View File

@@ -60,6 +60,8 @@ pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
`pnpm android:bundle:release` is an alias for the same archive helper.
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
Flavor-specific direct Gradle tasks:
```bash

View File

@@ -0,0 +1,37 @@
# OpenClaw Android Versioning
Android release builds use pinned app metadata instead of auto-bumping `build.gradle.kts`.
## Version model
- `apps/android/version.json` is the source of truth.
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
Examples:
- `version = 2026.6.2`
- `versionCode = 2026060201`
- another upload on the same release train: `versionCode = 2026060202`
## Commands
```bash
pnpm android:version
pnpm android:version:check
pnpm android:version:sync
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
```
## Release Workflow
1. Pin Android to the intended release version.
2. Run `pnpm android:version:sync`.
3. Update `apps/android/fastlane/metadata/android/en-US/release_notes.txt`.
4. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
5. Run `pnpm android:release:upload` to upload metadata and the Play AAB to Google Play internal testing.
6. Promote to production manually in Google Play Console.
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.

View File

@@ -0,0 +1,20 @@
# Google Play API key (pick one approach)
#
# Recommended local path:
# GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
#
# Or raw JSON content for CI:
# GOOGLE_PLAY_JSON_KEY_DATA={"type":"service_account",...}
# Optional app targeting
# GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
# Release target
# GOOGLE_PLAY_TRACK=internal
# GOOGLE_PLAY_RELEASE_STATUS=completed
# GOOGLE_PLAY_VALIDATE_ONLY=1
# Metadata toggles
# SUPPLY_UPLOAD_METADATA=1
# SUPPLY_UPLOAD_IMAGES=1
# SUPPLY_UPLOAD_SCREENSHOTS=1

View File

@@ -0,0 +1,3 @@
package_name(ENV["GOOGLE_PLAY_PACKAGE_NAME"] || "ai.openclaw.app")
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY"]) if ENV["GOOGLE_PLAY_JSON_KEY"]

View File

@@ -0,0 +1,255 @@
require "fileutils"
require "json"
require "open3"
require "shellwords"
require "tempfile"
default_platform(:android)
DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app"
DEFAULT_PLAY_TRACK = "internal"
DEFAULT_PLAY_RELEASE_STATUS = "completed"
def load_env_file(path)
return unless File.exist?(path)
File.foreach(path) do |line|
stripped = line.strip
next if stripped.empty? || stripped.start_with?("#")
key, value = stripped.split("=", 2)
next if key.nil? || key.empty? || value.nil?
ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
end
end
def env_present?(value)
!value.nil? && !value.strip.empty?
end
def android_root
File.expand_path("..", __dir__)
end
def repo_root
File.expand_path("../..", android_root)
end
def shell_join(args)
args.shelljoin
end
def play_package_name
raw = ENV["GOOGLE_PLAY_PACKAGE_NAME"].to_s.strip
raw.empty? ? DEFAULT_PLAY_PACKAGE_NAME : raw
end
def play_track
raw = ENV["GOOGLE_PLAY_TRACK"].to_s.strip
raw.empty? ? DEFAULT_PLAY_TRACK : raw
end
def play_release_status
raw = ENV["GOOGLE_PLAY_RELEASE_STATUS"].to_s.strip
raw.empty? ? DEFAULT_PLAY_RELEASE_STATUS : raw
end
def play_validate_only?
ENV["GOOGLE_PLAY_VALIDATE_ONLY"] == "1"
end
def play_metadata_upload_requested?
ENV["SUPPLY_UPLOAD_METADATA"] == "1"
end
def play_screenshot_upload_requested?
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] == "1"
end
def play_image_upload_requested?
ENV["SUPPLY_UPLOAD_IMAGES"] == "1"
end
def play_auth_options
json_key = ENV["GOOGLE_PLAY_JSON_KEY"].to_s.strip
json_key = ENV["SUPPLY_JSON_KEY"].to_s.strip if json_key.empty?
json_key = ENV["GOOGLE_PLAY_JSON_KEY_PATH"].to_s.strip if json_key.empty?
return { json_key: json_key } unless json_key.empty?
json_key_data = ENV["GOOGLE_PLAY_JSON_KEY_DATA"].to_s.strip
json_key_data = ENV["SUPPLY_JSON_KEY_DATA"].to_s.strip if json_key_data.empty?
return { json_key_data: json_key_data } unless json_key_data.empty?
UI.user_error!("Missing Google Play API credentials. Set GOOGLE_PLAY_JSON_KEY or GOOGLE_PLAY_JSON_KEY_DATA.")
end
def with_play_json_key_file
auth = play_auth_options
if auth[:json_key]
yield auth[:json_key]
return
end
Tempfile.create(["openclaw-google-play", ".json"]) do |file|
file.write(auth.fetch(:json_key_data))
file.flush
yield file.path
end
end
def read_android_version_metadata
stdout, stderr, status = Open3.capture3(
"node",
"--import",
"tsx",
File.join(repo_root, "scripts", "android-version.ts"),
"--json"
)
unless status.success?
detail = stderr.to_s.strip
detail = stdout.to_s.strip if detail.empty?
UI.user_error!("Failed to read Android version metadata: #{detail}")
end
parsed = JSON.parse(stdout)
version = parsed.fetch("canonicalVersion").to_s
version_code = parsed.fetch("versionCode").to_i
UI.user_error!("Android version helper returned incomplete metadata.") if version.empty? || version_code <= 0
{ version: version, version_code: version_code }
rescue JSON::ParserError => e
UI.user_error!("Invalid JSON from Android version helper: #{e.message}")
end
def sync_android_versioning!
sh(shell_join(["node", "--import", "tsx", File.join(repo_root, "scripts", "android-sync-versioning.ts"), "--check"]))
end
def android_release_notes_path
File.join(__dir__, "metadata", "android", "en-US", "release_notes.txt")
end
def android_changelog_path(version_code)
File.join(__dir__, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
end
def sync_android_changelog!(version_code)
release_notes_path = android_release_notes_path
UI.user_error!("Missing Android release notes at #{release_notes_path}.") unless File.exist?(release_notes_path)
changelog_path = android_changelog_path(version_code)
FileUtils.mkdir_p(File.dirname(changelog_path))
File.write(changelog_path, File.read(release_notes_path))
changelog_path
end
def play_metadata_path
File.join(__dir__, "metadata", "android")
end
def play_screenshot_paths
Dir[File.join(play_metadata_path, "**", "images", "**", "*.png")]
end
def validate_android_screenshots!
return unless play_screenshot_upload_requested?
if play_screenshot_paths.empty?
UI.user_error!("SUPPLY_UPLOAD_SCREENSHOTS=1 but no PNG screenshots were found under apps/android/fastlane/metadata/android/*/images.")
end
end
def release_artifact_path(version)
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-play-release.aab")
end
def build_release_artifacts!
sh(shell_join(["bun", File.join(android_root, "scripts", "build-release-artifacts.ts")]))
end
def upload_play_store_metadata!(version_metadata)
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: !play_metadata_upload_requested?,
skip_upload_changelogs: false,
skip_upload_images: !play_image_upload_requested?,
skip_upload_screenshots: !play_screenshot_upload_requested?,
validate_only: play_validate_only?
)
end
def upload_play_store_build!(version_metadata)
artifact_path = release_artifact_path(version_metadata.fetch(:version))
UI.user_error!("Missing Play release artifact at #{artifact_path}. Run pnpm android:release:archive first.") unless File.exist?(artifact_path)
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
aab: artifact_path,
track: play_track,
release_status: play_release_status,
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_metadata: true,
skip_upload_changelogs: false,
skip_upload_images: true,
skip_upload_screenshots: true,
validate_only: play_validate_only?
)
end
load_env_file(File.join(__dir__, ".env"))
platform :android do
desc "Validate Google Play API credentials"
lane :auth_check do
with_play_json_key_file do |json_key_path|
validate_play_store_json_key(json_key: json_key_path)
end
UI.success("Google Play API credentials are valid.")
end
desc "Upload Google Play metadata, changelog, and optional screenshots"
lane :metadata do
sync_android_versioning!
version_metadata = read_android_version_metadata
ENV["SUPPLY_UPLOAD_METADATA"] = "1" unless ENV.key?("SUPPLY_UPLOAD_METADATA")
upload_play_store_metadata!(version_metadata)
UI.success("Uploaded Android Play metadata for #{version_metadata[:version]} (#{version_metadata[:version_code]}).")
end
desc "Build signed Android release artifacts locally without uploading"
lane :play_store_archive do
sync_android_versioning!
build_release_artifacts!
end
desc "Upload the signed Play AAB to Google Play"
lane :play_store do
sync_android_versioning!
version_metadata = read_android_version_metadata
upload_play_store_build!(version_metadata)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
end
desc "Upload Android metadata, archive release artifacts, then upload the Play AAB"
lane :release_upload do
auth_check
sync_android_versioning!
version_metadata = read_android_version_metadata
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
upload_play_store_metadata!(version_metadata)
build_release_artifacts!
upload_play_store_build!(version_metadata)
UI.success("Uploaded Android Play build to #{play_track}: version=#{version_metadata[:version]} code=#{version_metadata[:version_code]}")
UI.important("Production promotion remains manual in Google Play Console.")
end
end

View File

@@ -0,0 +1,67 @@
# fastlane setup (OpenClaw Android)
Install:
```bash
brew install fastlane
```
Create a Google Play service account JSON key with Google Play Developer API access, then grant that service account access to the OpenClaw app in Play Console.
Recommended local auth:
```bash
GOOGLE_PLAY_JSON_KEY=/absolute/path/to/google-play-service-account.json
```
Optional app targeting:
```bash
GOOGLE_PLAY_PACKAGE_NAME=ai.openclaw.app
```
Validate auth:
```bash
cd apps/android
fastlane android auth_check
```
Archive locally without upload:
```bash
pnpm android:release:archive
```
Upload metadata, release notes, and the Play AAB to the internal testing track:
```bash
pnpm android:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/android
fastlane android release_upload
```
Release rules:
- `apps/android/version.json` is the pinned Android release version source.
- `apps/android/Config/Version.properties` is generated from that source and read by Gradle.
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
- `pnpm android:version:sync` updates generated version artifacts.
- `pnpm android:version:check` validates checked-in Android version artifacts.
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
- Production promotion remains manual in Google Play Console.
Screenshots:
- Android metadata can upload raw Play screenshots when PNGs exist under `apps/android/fastlane/metadata/android/<locale>/images/`.
- Set `SUPPLY_UPLOAD_SCREENSHOTS=1` to include those screenshots in `fastlane android metadata`.
- Do not commit generated screenshot captures unless they become intentional store metadata assets.

View File

@@ -0,0 +1,3 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -0,0 +1,18 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this Android app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from Android
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Enable device capabilities such as camera, screen, location, and notifications when you choose
- Receive push wakes and node status updates for connected workflows
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by Android permissions and can be enabled only for the capabilities you want to use.
Getting started:
1) Set up your OpenClaw Gateway
2) Open the Android app and pair with your gateway
3) Start using chat, Talk mode, approvals, and automations from your phone

View File

@@ -0,0 +1,3 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

View File

@@ -0,0 +1 @@
Personal AI on your Android devices

View File

@@ -0,0 +1 @@
OpenClaw

View File

@@ -1440,7 +1440,11 @@
"android:lint:android": "node scripts/run-android-gradle.mjs :app:lintDebug",
"android:run": "node scripts/run-android-gradle.mjs :app:installPlayDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
"android:run:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
"android:release": "bash scripts/android-release.sh",
"android:release:archive": "bun apps/android/scripts/build-release-artifacts.ts",
"android:release:auth:check": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android auth_check'",
"android:release:metadata": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android metadata'",
"android:release:upload": "bash scripts/android-release-upload.sh",
"android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest",
"android:test:integration": "node scripts/run-with-env.mjs OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"android:test:third-party": "node scripts/run-android-gradle.mjs :app:testThirdPartyDebugUnitTest",

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/android-release-upload.sh
Uploads Android Play metadata, builds signed release artifacts, and uploads the
Play AAB to Google Play internal testing by default. This does not promote the
build to production.
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/android-fastlane.sh"
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
(
cd "${ROOT_DIR}/apps/android"
run_android_fastlane android release_upload
)

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec bash "${ROOT_DIR}/scripts/android-release-upload.sh" "$@"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
run_android_fastlane() {
if command -v fastlane >/dev/null 2>&1 && fastlane --version >/dev/null 2>&1; then
fastlane "$@"
return
fi
if command -v rbenv >/dev/null 2>&1; then
local version=""
while IFS= read -r version; do
if RBENV_VERSION="${version}" rbenv which fastlane >/dev/null 2>&1; then
RBENV_VERSION="${version}" rbenv exec fastlane "$@"
return
fi
done < <(rbenv versions --bare)
fi
echo "fastlane not found. Install fastlane or select a Ruby version that has the fastlane gem." >&2
return 127
}