mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
feat(android): add daily internal testing builds (#161812)
This commit is contained in:
parent
5b9e29c20c
commit
e09dfc8897
11 changed files with 501 additions and 149 deletions
31
.github/workflows/android-store-release.yml
vendored
31
.github/workflows/android-store-release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue