require "fileutils" require "json" require "open3" require "shellwords" require "supply/client" default_platform(:android) ANDROID_FASTLANE_ROOT = File.expand_path(__dir__, Dir.pwd) DEFAULT_PLAY_PACKAGE_NAME = "ai.openclaw.app" DEFAULT_PLAY_TRACK = "internal" DEFAULT_PLAY_RELEASE_STATUS = "completed" ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES = [ "OPENCLAW_ANDROID_STORE_FILE", "OPENCLAW_ANDROID_STORE_PASSWORD", "OPENCLAW_ANDROID_KEY_ALIAS", "OPENCLAW_ANDROID_KEY_PASSWORD" ].freeze 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("..", ANDROID_FASTLANE_ROOT) end def repo_root File.expand_path("../..", android_root) end def android_release_signing_script File.join(repo_root, "scripts", "android-release-signing.mjs") end def android_release_signing_materialized_properties_path File.join(android_root, "build", "release-signing", "gradle.properties") 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 validate_play_auth! client = nil begin client = Supply::Client.make_from_config(params: play_auth_options) client.begin_edit(package_name: play_package_name) rescue => e UI.user_error!("Google Play API credentials are invalid for #{play_package_name}: #{e.message}") ensure if client&.current_edit begin client.abort_current_edit rescue => e UI.user_error!("Google Play API credentials opened a validation edit but could not close it: #{e.message}") end end end end def read_android_version_metadata stdout, stderr, status = Open3.capture3( "node", "--import", "tsx", File.join(repo_root, "scripts", "android-version.ts"), "--json", "--root", repo_root ) 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", "--root", repo_root])) end def android_release_notes_path File.join(ANDROID_FASTLANE_ROOT, "metadata", "android", "en-US", "release_notes.txt") end def validate_android_release_notes! release_notes_path = android_release_notes_path UI.user_error!("Missing Android release notes at #{release_notes_path}. Run `pnpm android:version:sync`.") unless File.exist?(release_notes_path) UI.user_error!("Android release notes at #{release_notes_path} are empty.") unless env_present?(File.read(release_notes_path)) end def android_changelog_path(version_code) File.join(ANDROID_FASTLANE_ROOT, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt") end def sync_android_changelog!(version_code) validate_android_release_notes! changelog_path = android_changelog_path(version_code) FileUtils.mkdir_p(File.dirname(changelog_path)) File.write(changelog_path, File.read(android_release_notes_path)) changelog_path end def play_metadata_path File.join(ANDROID_FASTLANE_ROOT, "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 capture_android_screenshots! sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")])) end def read_android_release_signing_properties!(path) UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path) properties = {} 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? properties[key] = value.strip end missing = ANDROID_RELEASE_SIGNING_GRADLE_PROPERTIES.reject { |key| env_present?(properties[key]) } UI.user_error!("Materialized Android release signing properties are missing: #{missing.join(', ')}.") unless missing.empty? properties end def export_android_release_signing_properties!(path) read_android_release_signing_properties!(path).each do |key, value| ENV["ORG_GRADLE_PROJECT_#{key}"] = value end end def sync_android_release_signing! sh(shell_join(["node", android_release_signing_script, "--mode", "sync-pull"])) export_android_release_signing_properties!(android_release_signing_materialized_properties_path) end def prepare_android_release_signing! if env_present?(ENV["MATCH_PASSWORD"]) sync_android_release_signing! elsif File.exist?(android_release_signing_materialized_properties_path) export_android_release_signing_properties!(android_release_signing_materialized_properties_path) end end def validate_android_release_signing! Dir.chdir(android_root) do sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"])) end end def print_android_release_plan!(version_metadata) UI.message("Android Play release plan:") UI.message(" package: #{play_package_name}") UI.message(" track: #{play_track}") UI.message(" release_status: #{play_release_status}") UI.message(" validate_only: #{play_validate_only?}") UI.message(" versionName: #{version_metadata.fetch(:version)}") UI.message(" versionCode: #{version_metadata.fetch(:version_code)}") end def validate_android_release_preflight!(version_metadata) validate_play_auth! prepare_android_release_signing! validate_android_release_signing! validate_android_release_notes! print_android_release_plan!(version_metadata) 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, track: play_track, version_code: version_metadata.fetch(:version_code), 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, upload_metadata: false, upload_images: false, upload_screenshots: false) ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots validate_android_screenshots! sync_android_changelog!(version_metadata.fetch(:version_code)) 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: !upload_metadata, skip_upload_changelogs: false, skip_upload_images: !upload_images, skip_upload_screenshots: !upload_screenshots, validate_only: play_validate_only? ) end load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env")) platform :android do desc "Validate Google Play API credentials" lane :auth_check do validate_play_auth! UI.success("Google Play API credentials are valid.") end desc "Print the Android release signing plan" lane :signing_plan do sh(shell_join(["node", android_release_signing_script, "--mode", "plan"])) end desc "Pull encrypted Android release signing assets and validate Gradle release signing" lane :signing_check do sync_android_release_signing! validate_android_release_signing! UI.success("Android release signing assets are available locally.") end desc "Pull encrypted Android release signing assets from the shared signing repo" lane :signing_sync_pull do sync_android_release_signing! UI.success("Pulled Android release signing assets.") end desc "Create or refresh encrypted Android release signing assets in the shared signing repo" lane :signing_sync_push do sh(shell_join(["node", android_release_signing_script, "--mode", "sync-push"])) UI.success("Pushed Android release signing assets.") end desc "Validate Android Play release auth, signing, versioning, and release notes" lane :release_preflight do sync_android_versioning! version_metadata = read_android_version_metadata validate_android_release_preflight!(version_metadata) UI.success("Android Play release preflight passed for #{version_metadata[:version]} (#{version_metadata[:version_code]}).") 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! prepare_android_release_signing! build_release_artifacts! end desc "Generate deterministic Android screenshots for Google Play metadata" lane :screenshots do capture_android_screenshots! 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 sync_android_versioning! version_metadata = read_android_version_metadata validate_android_release_preflight!(version_metadata) screenshots ENV["SUPPLY_UPLOAD_METADATA"] = "1" ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" build_release_artifacts! upload_play_store_build!(version_metadata, upload_metadata: true, upload_screenshots: true) 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