feat(android): add daily internal testing builds (#161812)

This commit is contained in:
Josh Avant 2026-09-30 18:01:07 -05:00 • committed by GitHub
parent 5b9e29c20c
commit e09dfc8897
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 501 additions and 149 deletions

View file

@ -1,7 +1,21 @@
name: Android Store Release
run-name: Android ${{ (github.event_name == 'schedule' || inputs.operation == 'internal') && 'Internal testing distribution' || 'Store release' }}
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *"
timezone: America/Los_Angeles
workflow_dispatch:
inputs:
operation:
description: Stage a Google Play release or distribute an Internal testing build
required: true
default: release
type: choice
options:
- release
- internal
permissions: {}
@ -17,12 +31,16 @@ env:
jobs:
release:
name: Prepare and upload Android release
if: github.ref == 'refs/heads/main' && github.repository == 'openclaw/openclaw'
if: >-
github.ref == 'refs/heads/main' && github.repository == 'openclaw/openclaw' &&
((github.event_name == 'schedule' && vars.ANDROID_INTERNAL_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' &&
(inputs.operation == 'release' || inputs.operation == 'internal')))
permissions:
contents: write
runs-on: ubuntu-24.04
timeout-minutes: 120
environment: android-store-release
environment: ${{ (github.event_name == 'schedule' || inputs.operation == 'internal') && 'android-internal' || 'android-store-release' }}
steps:
- name: Checkout release source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -38,6 +56,7 @@ jobs:
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
- name: Prepare trusted Linux Android tooling
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'release'
shell: bash
run: |
set -euo pipefail
@ -81,7 +100,7 @@ jobs:
uses: ./.github/actions/setup-android-toolchain
with:
cache-mode: off
install-screenshot-emulators: "true"
install-screenshot-emulators: ${{ (github.event_name == 'workflow_dispatch' && inputs.operation == 'release') && 'true' || 'false' }}
- name: Setup Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
@ -127,6 +146,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANDROID_RELEASE_DESTINATION: ${{ (github.event_name == 'schedule' || inputs.operation == 'internal') && 'internal' || 'play-store' }}
GOOGLE_PLAY_JSON_KEY_DATA: ${{ secrets.GOOGLE_PLAY_JSON_KEY_DATA }}
GOOGLE_PLAY_PACKAGE_NAME: ai.openclaw.app
GOOGLE_PLAY_RELEASE_STATUS: completed
@ -139,6 +159,7 @@ jobs:
set -euo pipefail
gh auth setup-git
pnpm android:release:upload -- \
--destination "$ANDROID_RELEASE_DESTINATION" \
--recovery-dir "$RUNNER_TEMP/android-release-recovery"
- name: Remove materialized signing files
@ -159,7 +180,7 @@ jobs:
retention-days: 30
- name: Retain emulator startup diagnostics
if: always()
if: always() && github.event_name == 'workflow_dispatch' && inputs.operation == 'release'
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:

View file

@ -4,7 +4,7 @@ Root rules still apply. This file adds the Android release guardrails.
## Google Play Releases
- Agent-driven Google Play uploads must use only `pnpm android:release:upload`. The no-input **Android Store Release** GitHub Action calls this owner to plan, generate notes, and upload the selected clean source commit without preparation commits or finalization PRs.
- Agent-driven Google Play uploads must use only `pnpm android:release:upload`. The **Android Store Release** GitHub Action calls this owner to plan, generate notes, and upload the selected clean source commit without preparation commits or finalization PRs. Its default `release` operation stages the store listing; `internal` and scheduled runs use `--destination internal`, skip screenshots and listing updates, and publish only to `internal` and `wear:internal`.
- Google Play releases use the saved Android plan and generated notes artifact. Notes compare the selected source with the public phone and Wear releases, and upload revalidates those baselines. Do not substitute tracked changelog notes or the latest internal build as the store baseline.
- Android pinned metadata and changelog sync remain the defaults for ordinary archives and Gateway APK publication. Store releases leave them unchanged. Keep preparation independent of iOS and preserve `.github/workflows/android-release.yml`.
- Follow [VERSIONING.md](VERSIONING.md) for public revisions, sequential store codes, immutable source refs, and the legacy cutover. Do not infer a store release identity from a new code's date-like shape or an editable Play release name.

View file

@ -239,7 +239,7 @@ tasks still require explicit `openclawBuildCommit` and
`openclawBuildTimestamp` properties so signed artifacts remain reproducible.
Android release archives use the pinned version in `apps/android/version.json`.
Run **Android Store Release** from `main` without input parameters, or run
Run **Android Store Release** from `main` with the default `release` operation, or run
`pnpm android:release:upload` from a clean local `main` matching `origin/main`.
The pipeline selects unused phone and Wear build numbers from Google Play and
generates OpenAI release notes from changes since each form factor's public
@ -247,6 +247,20 @@ release. It saves the plan and notes as release artifacts and uploads the select
clean source commit. Tracked version defaults and notes stay unchanged; the flow
creates no preparation commits or follow-up PRs.
For daily Google Play Internal testing builds, the same workflow runs at
**7:00 AM Pacific** using the `America/Los_Angeles` time zone, including daylight
saving changes. Scheduled runs require the repository variable
`ANDROID_INTERNAL_ENABLED=true` and use the `android-internal` environment.
They upload the phone and Wear builds and generated notes to `internal` and
`wear:internal`, without capturing screenshots or changing the store listing.
Production promotion remains manual.
To run this distribution manually, choose `operation=internal` from `main`, or
run `pnpm android:release:upload -- --destination internal` from a clean local
`main` matching `origin/main`. Manual runs work while the schedule is disabled.
See [daily Internal testing setup](VERSIONING.md#daily-internal-testing) for the
environment, credentials, and enablement steps.
For local preparation or inspection:
```bash

View file

@ -50,7 +50,7 @@ tracked defaults unchanged.
## Release Workflow
1. Run the manual **Android Store Release** GitHub Action from `main`. No input parameters are required. The upload uses the `android-store-release` environment and freezes the commit selected at dispatch, even if `main` advances while the run is queued.
1. Run the manual **Android Store Release** GitHub Action from `main` with its default `release` operation. The upload uses the `android-store-release` environment and freezes the commit selected at dispatch, even if `main` advances while the run is queued. The `internal` operation and daily schedule use the same release owner with the differences described below.
2. The workflow derives the Gateway version from the root `package.json`, selects the Android revision from source refs and current public releases, and chooses the next sequential phone/Wear codes above the uploaded codes and pinned floor. It refuses a version regression, an exhausted revision or native code range, and uploaded new-format codes whose source refs are missing.
3. Planning identifies the public releases in `production` and `wear:production`. OpenAI generates separate phone and Wear notes from changes since those releases. Internal uploads do not advance the public baseline. Staged, halted, or ambiguous public releases stop preparation.
4. The workflow saves the plan and generated notes for the selected clean source commit. Fastlane and Gradle consume those artifacts at runtime. No tracked release files, preparation commits, or follow-up PRs are needed.
@ -66,6 +66,51 @@ aborts that edit before returning. It never uploads or commits a Play edit.
Each run checks live state; build numbers and store codes do not come from dates
or workflow IDs. Both new codes exceed every previously observed uploaded code.
### Daily Internal testing
The **Android Store Release** workflow also supports `operation=internal` and a
daily **7:00 AM Pacific** schedule. The schedule uses `America/Los_Angeles`, so
the local time stays the same through daylight saving changes. Scheduled runs
select the latest `main` commit when triggered and preserve that source through
the release. Manual and scheduled uploads share the `android-release` concurrency
group with ordinary store releases.
Internal distributions retain the same live version planning, generated phone
and Wear notes, signing, artifact validation, atomic upload, and immutable
source-ref recording. They publish only to `internal` and `wear:internal`, skip
screenshot capture and its emulator/image tooling, and leave the store listing
metadata and images unchanged. The existing Play review-submission settings stay
in effect; production promotion remains manual.
To configure unattended runs:
1. Create the `android-internal` GitHub environment, restrict deployment to
`main`, and allow unattended jobs without required reviewers or wait timers.
2. Make `GH_APP_PRIVATE_KEY`, `MATCH_PASSWORD`, `OPENAI_API_KEY`, and
`GOOGLE_PLAY_JSON_KEY_DATA` available to that environment through environment,
repository, or organization secrets. Use the same signing identity and Google
Play application as the ordinary store release.
3. Configure the Google Play Internal testing audience for the app in Play
Console. The workflow distributes to the existing tracks; it does not manage
tester membership.
4. From `main`, run **Android Store Release** with `operation=internal` and verify
that the phone and Wear builds reach their Internal testing tracks.
5. Set the repository variable `ANDROID_INTERNAL_ENABLED=true` to enable daily
runs. Set it to `false` to pause them. Manual `internal` runs do not depend on
this flag.
The local equivalent, from a clean `main` matching `origin/main`, is:
```bash
pnpm android:release:upload -- --destination internal
```
Omitting `--destination` keeps the ordinary `play-store` path, including
screenshots and listing updates. Both paths retain the saved plan, notes, and
signed artifacts for the same failure investigation and recovery process.
### Gateway APK publication and upload recovery
For a regular final or correction OpenClaw release whose tagged Android pin
matches the stable train, `OpenClaw Release Publish` dispatches **Android APK
Artifact Publish** after core npm publishes successfully. A mismatched pin

View file

@ -630,16 +630,26 @@ platform :android do
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
desc "Build and upload Android phone and Wear bundles, with store assets for play-store releases"
lane :release_upload do |options|
destination = options.fetch(:destination, "play-store").to_s
UI.user_error!("Android release destination must be play-store or internal.") unless %w(play-store internal).include?(destination)
internal = destination == "internal"
if internal
ENV["GOOGLE_PLAY_TRACK"] = "internal"
ENV["GOOGLE_PLAY_RELEASE_STATUS"] = "completed"
%w(METADATA SCREENSHOTS IMAGES).each { |kind| ENV["SUPPLY_UPLOAD_#{kind}"] = "0" }
end
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"
unless internal
screenshots
ENV["SUPPLY_UPLOAD_METADATA"] = "1"
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1"
end
build_release_artifacts!
upload_play_store_build!(version_metadata, upload_metadata: true, upload_screenshots: true)
upload_play_store_build!(version_metadata, upload_metadata: !internal, upload_screenshots: !internal)
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

View file

@ -108,7 +108,7 @@ pnpm android:release:upload
## GitHub Actions release
Run **Android Store Release** from `main` in GitHub Actions without input parameters. The workflow
Run **Android Store Release** from `main` in GitHub Actions with the default `release` operation. The workflow
plans a release from the root Gateway version, selects the Android public
revision and sequential phone/Wear codes, and generates OpenAI release notes
from source changes since each form factor's public production release. It keeps tracked version
@ -117,6 +117,12 @@ defaults and notes unchanged and passes the saved plan through
`OPENCLAW_MOBILE_RELEASE_NOTES` selects the saved generated notes artifact. The
local CLI uses the same flow. Android preparation is independent of iOS.
Choose `operation=internal`, or use `pnpm android:release:upload -- --destination internal`
locally, to distribute phone and Wear builds without screenshot capture or store
listing updates. The same path supports daily runs at 7:00 AM Pacific; see
[daily Internal testing](../VERSIONING.md#daily-internal-testing) for environment
setup and enablement.
Public versions append a single revision digit to the Gateway patch: Gateway
`2026.9.4`, revision `0` becomes `2026.9.40`. Candidates keep that revision until
it is public on either phone or Wear, then advance it; revisions run from `0`
@ -133,8 +139,9 @@ The environment supplies these secrets:
- `OPENAI_API_KEY`
The workflow uses the locked Fastlane bundle and the existing signing assets.
The upload lane commits phone and Wear bundles, metadata, and screenshots in one
Play edit to `internal` and `wear:internal`, then records the release commit at
Both operations commit phone and Wear bundles and generated notes in one Play
edit to `internal` and `wear:internal`; `release` also uploads listing metadata
and screenshots. They then record the release commit at
`refs/openclaw/mobile-releases/android/v2/<G>/<R>/<buildNumber>/<phoneCode>-<wearCode>`.
Before the first new-format upload, it records the immutable
`android/cutover-v2/<legacyMaxCode>` marker under the same mobile-release ref prefix.
@ -210,7 +217,7 @@ Release rules:
apps, launches deterministic screenshot scenes, and writes Play-ready JPEGs
to the matching `phoneScreenshots` and `wearScreenshots` metadata folders.
- `pnpm android:release:archive` builds the signed phone Play AAB, Wear AAB, and third-party APK into `apps/android/build/release-artifacts/`. It uses pinned defaults unless `OPENCLAW_ANDROID_RELEASE_PLAN` selects a saved plan matching the source commit; replay also requires the saved `OPENCLAW_MOBILE_RELEASE_NOTES` artifact.
- `pnpm android:release:upload` commits the phone AAB, Wear AAB, metadata, and screenshots in one Google Play edit across the configured phone and `wear:` form-factor tracks. The default tracks are `internal` and `wear:internal`.
- `pnpm android:release:upload` commits the phone AAB, Wear AAB, metadata, and screenshots in one Google Play edit across the configured phone and `wear:` form-factor tracks. The default tracks are `internal` and `wear:internal`. With `--destination internal`, it uses those Internal testing tracks and generated notes without screenshot capture or listing updates.
- Stable GitHub Release APK publication is separate from Google Play: `OpenClaw Release Publish` dispatches `.github/workflows/android-release.yml`, whose protected `android-release` environment provides `MATCH_PASSWORD`; the repository GitHub App reads the encrypted signing repo.
- Production promotion remains manual in Google Play Console.
- If `pnpm android:release:upload` fails, agent-driven releases must stop and report the failing step. Do not fall back to `pnpm android:release:archive`, `pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts plus Google Play upload commands, or mobile release ref recording.

View file

@ -8,22 +8,32 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/android-release-upload.sh
scripts/android-release-upload.sh [--destination play-store|internal]
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.
Use --destination internal to upload builds and notes without changing the listing
or capturing screenshots.
EOF
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "${ROOT_DIR}/scripts/lib/android-fastlane.sh"
destination="play-store"
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift
;;
--destination)
case "${2:-}" in
play-store|internal) destination="$2" ;;
*) echo "Choose --destination play-store or internal." >&2; exit 1 ;;
esac
shift 2
;;
-h|--help)
usage
exit 0
@ -38,5 +48,5 @@ done
(
cd "${ROOT_DIR}/apps/android"
run_android_fastlane android release_upload
run_android_fastlane android release_upload "destination:${destination}"
)

View file

@ -261,8 +261,8 @@ function prepareAndUpload(root, platform, recovery, releaseArgs, destination) {
if (isGithubActions) {
const eventAllowed =
process.env.GITHUB_EVENT_NAME === "workflow_dispatch" ||
(platform === "ios" &&
destination === "testflight" &&
(((platform === "ios" && destination === "testflight") ||
(platform === "android" && destination === "internal")) &&
process.env.GITHUB_EVENT_NAME === "schedule");
if (
!eventAllowed ||
@ -271,7 +271,7 @@ function prepareAndUpload(root, platform, recovery, releaseArgs, destination) {
sourceSha !== process.env.GITHUB_SHA
) {
throw new Error(
"CI releases require the exact workflow_dispatch commit on openclaw/openclaw main; scheduled events are accepted only for iOS TestFlight.",
"CI releases require the exact workflow_dispatch commit on openclaw/openclaw main; scheduled events are accepted only for iOS TestFlight or Android internal testing.",
);
}
} else if (git(root, "branch", "--show-current") !== "main") {
@ -331,6 +331,7 @@ function prepareAndUpload(root, platform, recovery, releaseArgs, destination) {
{ stdio: "inherit" },
);
plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
plan.destination = destination;
}
plan.sourceSha = sourceSha;
fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
@ -420,7 +421,7 @@ function prepareAndUpload(root, platform, recovery, releaseArgs, destination) {
[
`scripts/${platform}-release-upload.sh`,
...(stageExisting ? ["--stage-only"] : []),
...(platform === "ios" ? uploadArgs(plan) : []),
...(platform === "ios" ? uploadArgs(plan) : ["--destination", destination]),
],
source,
{
@ -474,7 +475,7 @@ function runCli() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
console.log(
"Usage: node scripts/mobile-release.mjs run --platform ios|android [--destination app-store|testflight] [--recovery-dir <directory>]\n node scripts/mobile-release.mjs stage --platform ios --recovery-dir <directory>\nRun prepares notes and uploads unchanged main source. TestFlight is iOS-only. Stage recovers the saved iOS destination without uploading again or making Git commits.",
"Usage: node scripts/mobile-release.mjs run --platform ios|android [--destination <destination>] [--recovery-dir <directory>]\n node scripts/mobile-release.mjs stage --platform ios --recovery-dir <directory>\nDestinations: iOS app-store (default) or testflight; Android play-store (default) or internal. Run prepares notes and uploads unchanged main source. Stage recovers the saved iOS destination without uploading again or making Git commits.",
);
return;
}
@ -522,12 +523,13 @@ function runCli() {
}
if (
destination !== undefined &&
(platform !== "ios" ||
operation !== "run" ||
!["app-store", "testflight"].includes(destination))
(operation !== "run" ||
!(platform === "ios" ? ["app-store", "testflight"] : ["play-store", "internal"]).includes(
destination,
))
) {
throw new Error(
"Choose --destination app-store or testflight for an iOS run; recovery uses the saved destination.",
"Choose --destination app-store or testflight for iOS, or play-store or internal for Android; recovery uses the saved destination.",
);
}
if (operation === "stage" && (platform !== "ios" || !recovery)) {
@ -551,7 +553,13 @@ function runCli() {
if (operation === "stage") {
stageIos(root, recovery);
} else {
prepareAndUpload(root, platform, recovery, releaseArgs, destination ?? "app-store");
prepareAndUpload(
root,
platform,
recovery,
releaseArgs,
destination ?? (platform === "ios" ? "app-store" : "play-store"),
);
}
}

View file

@ -51,25 +51,34 @@ const rubyFastlaneHarness = String.raw`
require "json"
require "open3"
require "fileutils"
$LOADED_FEATURES << "supply.rb"
module FastlaneCore
module Interface
class FastlaneError < StandardError; end
if ENV["OPENCLAW_TEST_FASTLANE_BUNDLE"] == "1"
require "fastlane"
require "supply"
Fastlane.load_actions
else
$LOADED_FEATURES << "supply.rb"
module FastlaneCore
class Interface
class FastlaneError < StandardError; end
end
end
end
module UI
def self.user_error!(message); raise FastlaneCore::Interface::FastlaneError, message; end
module TestUI
def self.user_error!(message); raise FastlaneCore::Interface::FastlaneError.new, message; end
def self.success(message); end
def self.message(message); end
def self.important(message); end
def self.header(message); end
end
def default_platform(name); end
def platform(name); yield; end
def desc(text); end
$lanes = {}
def lane(name, &block); $lanes[name] = block; end
def screenshots; $lanes.fetch(:screenshots).call; end
def sh(command)
class FastfileFixture
UI = TestUI
def parsing_binding; binding; end
def default_platform(name); end
def platform(name); yield; end
def desc(text); end
def lane(name, &block); define_singleton_method(name) { |options = {}| block.call(options) }; end
end
def fixture_sh(command)
args = Shellwords.split(command)
ref_script = args.index { |arg| arg.end_with?("mobile-release-ref.ts") }
if ref_script
@ -82,17 +91,19 @@ def sh(command)
end
end
module AndroidPublisher
LocalizedText = Struct.new(:language, :text, keyword_init: true)
TrackRelease = Struct.new(:name, :status, :version_codes, :release_notes, keyword_init: true)
Track = Struct.new(:track, :releases, keyword_init: true)
LocalizedText = Struct.new(:language, :text, keyword_init: true) unless const_defined?(:LocalizedText)
TrackRelease = Struct.new(:name, :status, :version_codes, :release_notes, keyword_init: true) unless const_defined?(:TrackRelease)
Track = Struct.new(:track, :releases, keyword_init: true) unless const_defined?(:Track)
end
module Supply
AVAILABLE_METADATA_FIELDS = []
SCREENSHOT_TYPES = []
AVAILABLE_METADATA_FIELDS = ["title"] unless const_defined?(:AVAILABLE_METADATA_FIELDS)
IMAGES_TYPES = ["icon"] unless const_defined?(:IMAGES_TYPES)
SCREENSHOT_TYPES = %w(phoneScreenshots wearScreenshots) unless const_defined?(:SCREENSHOT_TYPES)
def self.config; @config; end
def self.config=(value); @config = value; end
class Client
attr_reader :current_edit
def initialize; end
def self.make_from_config(params:); $client; end
def begin_edit(package_name:); $events << "begin"; $edits += 1; @current_edit = true; end
def aab_version_codes
@ -116,9 +127,16 @@ module Supply
file.include?("wear-release") ? 2026080255 : 2026080254
end
def update_track(name, track)
$tracks[name] = track.releases.map { |release| { codes: release.version_codes, notes: release.release_notes.map(&:to_h) } }
$tracks[name] = track.releases.map { |release| { codes: release.version_codes, status: release.status, notes: release.release_notes.map(&:to_h) } }
end
def commit_current_edit!; $events << "commit"; @current_edit = nil; end
def listing_for_language(language)
Struct.new(:title) do
def save; $events << "listing"; end
end.new
end
def upload_image(**); $events << "image"; end
def clear_screenshots(**); $events << "screenshots"; end
def commit_current_edit!; $events << "commit"; $committed_config = Supply.config; @current_edit = nil; end
def validate_current_edit!; $events << "validate-edit"; end
def abort_current_edit; $events << "abort"; @current_edit = nil; end
end
@ -142,23 +160,44 @@ module Open3
end
end
ENV["GOOGLE_PLAY_JSON_KEY_DATA"] = "synthetic"
%w(MATCH_PASSWORD GOOGLE_PLAY_TRACK GOOGLE_PLAY_RELEASE_STATUS GOOGLE_PLAY_VALIDATE_ONLY OPENCLAW_ANDROID_RELEASE_PLAN).each { |key| ENV.delete(key) }
load ARGV.fetch(0)
%w(MATCH_PASSWORD GOOGLE_PLAY_TRACK GOOGLE_PLAY_RELEASE_STATUS GOOGLE_PLAY_VALIDATE_ONLY OPENCLAW_ANDROID_RELEASE_PLAN SUPPLY_UPLOAD_METADATA SUPPLY_UPLOAD_SCREENSHOTS SUPPLY_UPLOAD_IMAGES).each { |key| ENV.delete(key) }
if ENV["OPENCLAW_TEST_FASTLANE_BUNDLE"] == "1"
FastlaneCore::UI.ui_object = TestUI
fastfile = Fastlane::FastFile.new(ARGV.fetch(0))
$run_lane = ->(name, options = {}) { fastfile.runner.execute(name, :android, options) }
else
fastfile = FastfileFixture.new
eval(File.read(ARGV.fetch(0)), fastfile.parsing_binding, ARGV.fetch(0))
$run_lane = ->(name, options = {}) { fastfile.public_send(name, options) }
end
$root = ARGV.fetch(1)
def repo_root; $root; end
def android_root; File.join($root, "apps", "android"); end
def play_metadata_path; File.join(android_root, "fastlane", "metadata", "android"); end
def track(name, status, *codes)
release = AndroidPublisher::TrackRelease.new(status: status, version_codes: codes, name: "Editable label, not a version")
AndroidPublisher::Track.new(track: name, releases: [release])
end
fastfile.instance_eval do
def sh(command); fixture_sh(command); end
def repo_root; $root; end
def android_root; File.join($root, "apps", "android"); end
def play_metadata_path; File.join(android_root, "fastlane", "metadata", "android"); end
`;
function runRuby(fixtureRoot: string, source: string): unknown {
const useBundle = process.env.OPENCLAW_TEST_FASTLANE_BUNDLE === "1";
const rubyArgs = [
"-e",
rubyFastlaneHarness + "\n" + source + "\nend",
path.join(fixtureRoot, "Fastfile"),
fixtureRoot,
];
const result = spawnSync(
"ruby",
["-e", rubyFastlaneHarness + "\n" + source, path.join(fixtureRoot, "Fastfile"), fixtureRoot],
{ encoding: "utf8", cwd: rootDir },
useBundle ? "bundle" : "ruby",
useBundle ? ["_4.0.21_", "exec", "ruby", ...rubyArgs] : rubyArgs,
{
encoding: "utf8",
cwd: rootDir,
env: { ...process.env, BUNDLE_GEMFILE: path.join(rootDir, "apps/android/Gemfile") },
},
);
expect(result.status, result.stderr).toBe(0);
return JSON.parse(result.stdout);
@ -170,6 +209,7 @@ type LaneResult = {
tracks: unknown;
pinned_notes?: string;
wear_code?: string;
committed_config?: Record<string, boolean>;
plan?: {
schemaVersion: number;
version: string;
@ -195,24 +235,51 @@ FileUtils.mkdir_p(File.join(android_root, "build", "release-artifacts"))
play_release_artifact_paths("2026.9.20").each { |file| File.write(file, "synthetic signed bundle") }
notes_path = File.join(play_metadata_path, "en-US", "release_notes.txt")
File.write(notes_path, "Pinned archive notes stay unchanged.\n")
File.write(File.join(play_metadata_path, "en-US", "title.txt"), "Store listing title")
File.write(File.join(play_metadata_path, "en-US", "images", "icon.png"), "synthetic icon")
plan_path = File.join($root, "recovery", "android-plan.json")
$scenario, $events, $edits, $client = "plan", [], 0, Supply::Client.new
$lanes.fetch(:release_plan).call(output_path: plan_path)
$run_lane.call(:release_plan, output_path: plan_path)
ENV["OPENCLAW_ANDROID_RELEASE_PLAN"] = plan_path
results = %w(invalid-notes changed-baseline changed-code initialize-failure validate-only upload).map do |scenario|
$scenario, $events, $tracks, $edits, $client = scenario, [], {}, 0, Supply::Client.new
results = %w(invalid-destination invalid-notes changed-baseline changed-code initialize-failure validate-only upload internal).map do |scenario|
$scenario, $events, $tracks, $edits, $client, $committed_config = scenario, [], {}, 0, Supply::Client.new, nil
scenario == "validate-only" ? ENV["GOOGLE_PLAY_VALIDATE_ONLY"] = "1" : ENV.delete("GOOGLE_PLAY_VALIDATE_ONLY")
if scenario == "internal"
ENV["GOOGLE_PLAY_TRACK"] = "production"
ENV["GOOGLE_PLAY_RELEASE_STATUS"] = "draft"
%w(METADATA SCREENSHOTS IMAGES).each { |kind| ENV["SUPPLY_UPLOAD_#{kind}"] = "1" }
ENV["SUPPLY_CHANGES_NOT_SENT_FOR_REVIEW"] = "true"
ENV["SUPPLY_RESCUE_CHANGES_NOT_SENT_FOR_REVIEW"] = "false"
FileUtils.rm_rf(File.join(play_metadata_path, "en-US", "images"))
end
begin
$lanes.fetch(:release_upload).call
{ events: $events, tracks: $tracks, pinned_notes: File.read(notes_path), wear_code: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_WEAR_VERSION_CODE"] }
options = case scenario
when "internal" then { destination: "internal" }
when "invalid-destination" then { destination: "production" }
else {}
end
$run_lane.call(:release_upload, options)
{ events: $events, tracks: $tracks, pinned_notes: File.read(notes_path), wear_code: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_WEAR_VERSION_CODE"], committed_config: $committed_config }
rescue => error
{ error: error.message, events: $events, tracks: $tracks }
end
end
puts JSON.generate(results)
STDOUT.puts JSON.generate(results)
`,
) as LaneResult[];
const [rejected, changedBaseline, changedCode, failedInitialize, validated, uploaded] = results;
const [
invalidDestination,
rejected,
changedBaseline,
changedCode,
failedInitialize,
validated,
uploaded,
internal,
] = results;
expect(internal?.error).toBeUndefined();
expect(invalidDestination?.error).toContain("destination must be play-store or internal");
expect(invalidDestination?.events).toEqual([]);
expect(rejected?.error).toContain("Saved release notes do not match source/build");
expect(rejected?.events).toEqual([]);
for (const rejectedPlan of [changedBaseline, changedCode]) {
@ -233,10 +300,18 @@ puts JSON.generate(results)
expect(uploaded?.error).toBeUndefined();
expect(uploaded?.tracks).toEqual({
internal: [
{ codes: [2026080254], notes: [{ language: "en-US", text: "Phone chat improvements." }] },
{
codes: [2026080254],
status: "completed",
notes: [{ language: "en-US", text: "Phone chat improvements." }],
},
],
"wear:internal": [
{ codes: [2026080255], notes: [{ language: "en-US", text: "Wear voice fixes." }] },
{
codes: [2026080255],
status: "completed",
notes: [{ language: "en-US", text: "Wear voice fixes." }],
},
],
});
const events = uploaded!.events;
@ -259,6 +334,21 @@ puts JSON.generate(results)
);
expect(uploaded?.wear_code).toBe("2026080255");
expect(uploaded?.pinned_notes).toBe("Pinned archive notes stay unchanged.\n");
expect(events).toContain("listing");
expect(events).toContain("screenshots");
expect(internal?.tracks).toEqual(uploaded?.tracks);
expect(internal?.events.filter((event) => event === "upload")).toHaveLength(2);
expect(internal?.events.filter((event) => event === "commit")).toHaveLength(1);
expect(internal?.events.at(-1)).toBe("ref:record");
expect(internal?.events.some((event) => event.includes("android-screenshots.sh"))).toBe(false);
expect(internal?.events).not.toContain("listing");
expect(internal?.events).not.toContain("screenshots");
expect(internal?.events).not.toContain("image");
expect(internal?.pinned_notes).toBe("Pinned archive notes stay unchanged.\n");
expect(internal?.committed_config).toMatchObject({
changes_not_sent_for_review: true,
rescue_changes_not_sent_for_review: false,
});
});
it("passes both artifact inventories and public tracks to the planner and aborts read edits on every outcome", () => {
@ -276,13 +366,13 @@ results = cases.each_with_index.map do |(scenario, tracks), index|
$scenario, $public_tracks, $events, $edits, $client = scenario, tracks, [], 0, Supply::Client.new
output = File.join($root, "recovery", "plan-#{index}.json")
begin
$lanes.fetch(:release_plan).call(output_path: output)
$run_lane.call(:release_plan, output_path: output)
{ plan: JSON.parse(File.read(output)), events: $events }
rescue => error
{ error: error.message, output_exists: File.exist?(output), events: $events }
end
end
puts JSON.generate(results)
STDOUT.puts JSON.generate(results)
`,
) as LaneResult[];
expect(results[0]?.error).toBeUndefined();

View file

@ -77,6 +77,25 @@ function readOutputs(file: string): Record<string, string> {
);
}
function evaluateWorkflowExpression(expression: string, context: Record<string, unknown>): unknown {
return runInNewContext(expression.replace(/^\$\{\{\s*|\s*\}\}$/gu, ""), context);
}
function releaseUploadArguments(upload: WorkflowStep, env: NodeJS.ProcessEnv): string[] {
return command(
"bash",
[
"-c",
[
"gh() { :; }",
"pnpm() { printf '%s\\n' \"$@\"; }",
expectDefined(upload.run, "release upload command"),
].join("\n"),
],
{ env: { ...process.env, ...env, RUNNER_TEMP: "/synthetic-runner-temp" } },
).split("\n");
}
function releaseArtifactFiles(workflowFile: string, artifactPrefix: string, runnerTemp: string) {
const workflow = parse(fs.readFileSync(workflowFile, "utf8")) as {
jobs: {
@ -97,7 +116,16 @@ function releaseArtifactFiles(workflowFile: string, artifactPrefix: string, runn
if (!upload?.with?.path) {
throw new Error(`Missing ${artifactPrefix} upload in ${workflowFile}`);
}
expect(upload.if).toBe("always()");
expect(
evaluateWorkflowExpression(upload.if ?? "success()", {
always: () => true,
success: () => false,
failure: () => true,
cancelled: () => false,
github: { event_name: "workflow_dispatch" },
inputs: { operation: "release" },
}),
).toBe(true);
const patterns = upload.with.path
.trim()
.split(/\r?\n/u)
@ -224,8 +252,7 @@ describe("mobile release CI tools", () => {
failure: () => qualificationResult === "failure",
always: () => true,
};
const evaluate = (expression: string) =>
runInNewContext(expression.replace(/^\$\{\{\s*|\s*\}\}$/gu, ""), context);
const evaluate = (expression: string) => evaluateWorkflowExpression(expression, context);
expect(Boolean(evaluate(workflow.jobs.qualify.if)), JSON.stringify(scenario)).toBe(
scenario.qualify ?? false,
);
@ -252,34 +279,18 @@ describe("mobile release CI tools", () => {
),
),
).toBe("external-group-id");
const result = spawnSync(
"bash",
[
"-c",
[
"gh() { :; }",
"pnpm() { printf '%s\\n' \"$@\"; }",
expectDefined(upload.run, "iOS upload command"),
].join("\n"),
],
{
encoding: "utf8",
env: {
...process.env,
IOS_RELEASE_DESTINATION: String(
evaluate(
expectDefined(
uploadEnvironment.IOS_RELEASE_DESTINATION,
"iOS release destination expression",
),
expect(
releaseUploadArguments(upload, {
IOS_RELEASE_DESTINATION: String(
evaluate(
expectDefined(
uploadEnvironment.IOS_RELEASE_DESTINATION,
"iOS release destination expression",
),
),
RUNNER_TEMP: "/synthetic-runner-temp",
},
},
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim().split("\n")).toEqual([
),
}),
).toEqual([
"ios:release:upload",
"--",
"--destination",
@ -290,6 +301,114 @@ describe("mobile release CI tools", () => {
}
});
it("routes enabled daily and manual Android internal builds without screenshot tooling", () => {
const workflow = parse(
fs.readFileSync(".github/workflows/android-store-release.yml", "utf8"),
) as {
on: {
schedule: Array<{ cron: string; timezone: string }>;
workflow_dispatch: { inputs: { operation: { default: string; options: string[] } } };
};
concurrency: { group: string; "cancel-in-progress": boolean };
jobs: { release: { if: string; environment: string; steps: WorkflowStep[] } };
};
expect(workflow.on.schedule).toEqual([{ cron: "0 7 * * *", timezone: "America/Los_Angeles" }]);
expect(workflow.on.workflow_dispatch.inputs.operation).toMatchObject({
default: "release",
options: ["release", "internal"],
});
expect(workflow.concurrency).toMatchObject({
group: "android-release",
"cancel-in-progress": false,
});
const release = workflow.jobs.release;
const findStep = (name: string) =>
expectDefined(
release.steps.find((step) => step.name === name),
name,
);
const upload = findStep("Prepare and upload Android release");
const uploadEnvironment = expectDefined(upload.env, "Android upload environment");
const tooling = findStep("Prepare trusted Linux Android tooling");
const diagnostics = findStep("Retain emulator startup diagnostics");
const emulators = expectDefined(
findStep("Setup Android toolchain").with?.["install-screenshot-emulators"],
"screenshot emulator selection",
);
const scenarios: Array<{
event: string;
operation: string;
enabled: string;
destination?: "internal" | "play-store";
ref?: string;
repository?: string;
}> = [
{ event: "schedule", operation: "", enabled: "true", destination: "internal" },
{ event: "schedule", operation: "", enabled: "" },
{ event: "schedule", operation: "", enabled: "false" },
{
event: "workflow_dispatch",
operation: "internal",
enabled: "false",
destination: "internal",
},
{
event: "workflow_dispatch",
operation: workflow.on.workflow_dispatch.inputs.operation.default,
enabled: "false",
destination: "play-store",
},
{ event: "workflow_dispatch", operation: "unknown", enabled: "true" },
{ event: "push", operation: "internal", enabled: "true" },
...["schedule", "workflow_dispatch"].flatMap((event) => [
{ event, operation: "internal", enabled: "true", ref: "refs/heads/candidate" },
{ event, operation: "internal", enabled: "true", repository: "example/fork" },
]),
];
for (const scenario of scenarios) {
const context = {
github: {
event_name: scenario.event,
ref: scenario.ref ?? "refs/heads/main",
repository: scenario.repository ?? "openclaw/openclaw",
},
inputs: { operation: scenario.operation },
vars: { ANDROID_INTERNAL_ENABLED: scenario.enabled },
always: () => true,
};
const evaluate = (expression: string) => evaluateWorkflowExpression(expression, context);
expect(Boolean(evaluate(release.if)), JSON.stringify(scenario)).toBe(
Boolean(scenario.destination),
);
if (!scenario.destination) {
continue;
}
const storeRelease = scenario.destination === "play-store";
expect(evaluate(release.environment)).toBe(
storeRelease ? "android-store-release" : "android-internal",
);
expect(Boolean(evaluate(tooling.if ?? "true"))).toBe(storeRelease);
expect(Boolean(evaluate(diagnostics.if ?? "true"))).toBe(storeRelease);
expect(evaluate(String(emulators))).toBe(String(storeRelease));
expect(
releaseUploadArguments(upload, {
ANDROID_RELEASE_DESTINATION: String(
evaluate(
expectDefined(uploadEnvironment.ANDROID_RELEASE_DESTINATION, "Android destination"),
),
),
}),
).toEqual([
"android:release:upload",
"--",
"--destination",
scenario.destination,
"--recovery-dir",
"/synthetic-runner-temp/android-release-recovery",
]);
}
});
describe.each([
{
platform: "ios",

View file

@ -129,6 +129,7 @@ console.log(stageOnly ? "Synthetic notes staged" : "Synthetic store upload accep
"apps/android/scripts/build-release-artifacts.ts",
"apps/android/Config/ReleaseSigning.json",
"apps/android/fastlane/Fastfile",
"scripts/android-release-upload.sh",
]) {
write(root, file, fs.readFileSync(path.join(process.cwd(), file), "utf8"));
}
@ -152,10 +153,13 @@ console.log(stageOnly ? "Synthetic notes staged" : "Synthetic store upload accep
root,
"scripts/lib/android-fastlane.sh",
`run_android_fastlane() {
if [[ "$2" != "release_plan" ]]; then
(cd ../.. && ruby scripts/fixture-upload.rb "$3")
return
fi
echo '{"schemaVersion":2,"gatewayVersion":"2026.9.2","revision":0,"buildNumber":1,"version":"2026.9.20","versionCode":2026090250,"wearVersionCode":2026090251,"legacyMaxVersionCode":2026090249,"releaseNotesBaselines":[{"audience":"phone","version":null,"build":null},{"audience":"wear","version":null,"build":null}]}' > "\u0024{3#output_path:}"
}\n`,
);
write(root, "scripts/android-release-upload.sh", "exec ruby scripts/fixture-upload.rb\n");
write(
root,
"scripts/fixture-upload.rb",
@ -180,12 +184,12 @@ def build_release_artifacts!
raise "Archive failed" unless system("node", "--import", "tsx", "apps/android/scripts/build-release-artifacts.ts", "--dry-run")
end
def upload_play_store_build!(metadata, **options)
File.write(ENV.fetch("FIXTURE_UPLOAD_AUDIT"), JSON.generate({ version: metadata.fetch(:version), versionCode: metadata.fetch(:version_code), wearVersionCode: metadata.fetch(:wear_version_code), gradleVersion: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_VERSION_NAME"], gradleCode: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_VERSION_CODE"], gradleWearCode: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_WEAR_VERSION_CODE"] }) + "\n")
File.write(ENV.fetch("FIXTURE_UPLOAD_AUDIT"), JSON.generate({ version: metadata.fetch(:version), versionCode: metadata.fetch(:version_code), wearVersionCode: metadata.fetch(:wear_version_code), uploadMetadata: options.fetch(:upload_metadata), gradleVersion: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_VERSION_NAME"], gradleCode: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_VERSION_CODE"], gradleWearCode: ENV["ORG_GRADLE_PROJECT_OPENCLAW_ANDROID_WEAR_VERSION_CODE"] }) + "\n")
%w(initialize-android record).each do |command|
raise "Record failed" unless system("node", "--import", "tsx", "scripts/mobile-release-ref.ts", command, "--plan", ENV.fetch("OPENCLAW_ANDROID_RELEASE_PLAN"))
end
end
$lanes.fetch(:release_upload).call
$lanes.fetch(:release_upload).call(destination: ARGV.fetch(0).delete_prefix("destination:"))
`,
);
}
@ -237,55 +241,79 @@ function advanceMain(f: ReturnType<typeof fixture>): string {
}
describe("mobile release CLI", () => {
it("builds Android from the saved store plan and notes without changing Git or pinned metadata", () => {
const f = fixture("android");
const pinned = [
"apps/android/version.json",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
];
const original = pinned.map((file) => fs.readFileSync(path.join(f.root, file), "utf8"));
const result = f.invoke("run");
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Android versionName: 2026.9.20");
expect(result.stdout).toContain("Android versionCode: 2026090250");
expect(result.stdout).toContain("Android Wear versionCode: 2026090251");
expect(f.audit()[0]).toMatchObject({
version: "2026.9.20",
versionCode: 2026090250,
wearVersionCode: 2026090251,
gradleVersion: "2026.9.20",
gradleCode: "2026090250",
gradleWearCode: "2026090251",
});
expect(
git(
f.remote,
"rev-parse",
"refs/openclaw/mobile-releases/android/v2/2026.9.2/0/1/2026090250-2026090251",
),
).toBe(f.base);
expect(git(f.remote, "rev-parse", "main")).toBe(f.base);
for (const [index, file] of pinned.entries()) {
expect(fs.readFileSync(path.join(f.root, file), "utf8")).toBe(original[index]);
}
const rebuilt = spawnSync(
process.execPath,
["--import", "tsx", "apps/android/scripts/build-release-artifacts.ts", "--dry-run"],
{
cwd: f.root,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_ANDROID_RELEASE_PLAN: path.join(f.recovery, "android-plan.json"),
OPENCLAW_MOBILE_RELEASE_NOTES: path.join(f.recovery, "release-notes.json"),
it.each(["play-store", "internal"])(
"builds Android %s from the saved plan without changing Git or pinned metadata",
(destination) => {
const f = fixture("android");
const pinned = [
"apps/android/version.json",
"apps/android/Config/Version.properties",
"apps/android/fastlane/metadata/android/en-US/release_notes.txt",
];
const original = pinned.map((file) => fs.readFileSync(path.join(f.root, file), "utf8"));
const ci = {
GITHUB_ACTIONS: "true",
GITHUB_EVENT_NAME: "schedule",
GITHUB_RUN_ATTEMPT: "1",
GITHUB_REF: "refs/heads/main",
GITHUB_SHA: f.base,
};
if (destination === "internal") {
const rejected = f.invoke("run", ["--destination", "play-store"], ci);
expect(rejected.status).toBe(1);
expect(rejected.stderr).toContain("scheduled events are accepted only");
expect(fs.existsSync(f.uploadAudit)).toBe(false);
}
const result = f.invoke(
"run",
destination === "internal" ? ["--destination", destination] : [],
destination === "internal" ? ci : {},
);
expect(result.status, result.stderr).toBe(0);
expect(
JSON.parse(fs.readFileSync(path.join(f.recovery, "android-plan.json"), "utf8")),
).toMatchObject({ destination, sourceSha: f.base });
expect(result.stdout).toContain("Android versionName: 2026.9.20");
expect(result.stdout).toContain("Android versionCode: 2026090250");
expect(result.stdout).toContain("Android Wear versionCode: 2026090251");
expect(f.audit()[0]).toMatchObject({
version: "2026.9.20",
versionCode: 2026090250,
wearVersionCode: 2026090251,
uploadMetadata: destination === "play-store",
gradleVersion: "2026.9.20",
gradleCode: "2026090250",
gradleWearCode: "2026090251",
});
expect(
git(
f.remote,
"rev-parse",
"refs/openclaw/mobile-releases/android/v2/2026.9.2/0/1/2026090250-2026090251",
),
).toBe(f.base);
expect(git(f.remote, "rev-parse", "main")).toBe(f.base);
for (const [index, file] of pinned.entries()) {
expect(fs.readFileSync(path.join(f.root, file), "utf8")).toBe(original[index]);
}
const rebuilt = spawnSync(
process.execPath,
["--import", "tsx", "apps/android/scripts/build-release-artifacts.ts", "--dry-run"],
{
cwd: f.root,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_ANDROID_RELEASE_PLAN: path.join(f.recovery, "android-plan.json"),
OPENCLAW_MOBILE_RELEASE_NOTES: path.join(f.recovery, "release-notes.json"),
},
},
},
);
expect(rebuilt.status, rebuilt.stderr).toBe(0);
expect(rebuilt.stdout).toContain("Android versionCode: 2026090250");
expect(git(f.root, "status", "--porcelain")).toBe("");
});
);
expect(rebuilt.status, rebuilt.stderr).toBe(0);
expect(rebuilt.stdout).toContain("Android versionCode: 2026090250");
expect(git(f.root, "status", "--porcelain")).toBe("");
},
);
it("uploads the detached dispatch source with generated notes and leaves advanced main untouched", () => {
const f = fixture();