From ff35f3bb2cd6c9cc0fd8e28676b0d812e8e92725 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:48:49 -0500 Subject: [PATCH] Track mobile release SHAs with refs --- apps/android/VERSIONING.md | 32 ++ apps/android/fastlane/Fastfile | 68 ++++ apps/ios/VERSIONING.md | 31 ++ apps/ios/fastlane/Fastfile | 67 +++- package.json | 3 + scripts/mobile-release-ref.ts | 376 ++++++++++++++++++ scripts/test-projects.test-support.mjs | 2 + .../android-release-fastlane-gates.test.ts | 46 +++ .../ios-release-fastlane-gates.test.ts | 21 + test/scripts/mobile-release-ref.test.ts | 231 +++++++++++ 10 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 scripts/mobile-release-ref.ts create mode 100644 test/scripts/android-release-fastlane-gates.test.ts create mode 100644 test/scripts/mobile-release-ref.test.ts diff --git a/apps/android/VERSIONING.md b/apps/android/VERSIONING.md index 37b3ce32aded..29ff40191ca6 100644 --- a/apps/android/VERSIONING.md +++ b/apps/android/VERSIONING.md @@ -56,6 +56,38 @@ Recommended workflow: The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane. +## Release SHA tracking + +Successful Play build uploads create a non-tag Git ref that records the source +commit for the uploaded store build: + +```text +refs/openclaw/mobile-releases/android/- +``` + +Example: + +```text +refs/openclaw/mobile-releases/android/2026.6.10-2026061008 +``` + +These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do +not appear on GitHub release or tag pages, and they do not participate in the +core OpenClaw release machinery. + +`pnpm android:release:upload` checks the ref before uploading the Play build and +records it only after `upload_to_play_store` succeeds. Existing refs are +immutable: the same ref at the same SHA is accepted, while the same ref at a +different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does +not record it because no Play build is published. + +Useful direct commands: + +```bash +pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008 +pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008 +``` + ## Signing model `apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`. diff --git a/apps/android/fastlane/Fastfile b/apps/android/fastlane/Fastfile index 386ad96224f3..9cba352893ca 100644 --- a/apps/android/fastlane/Fastfile +++ b/apps/android/fastlane/Fastfile @@ -198,6 +198,58 @@ def capture_android_screenshots! sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")])) end +def mobile_release_ref_script + File.join(repo_root, "scripts", "mobile-release-ref.ts") +end + +def release_git_sha + stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root) + UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success? + stdout.strip +end + +def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil) + args = [ + "node", + "--import", + "tsx", + mobile_release_ref_script, + command, + "--platform", + platform, + "--version", + version, + "--root", + repo_root, + ] + args.push("--build", build.to_s) if build + args.push("--version-code", version_code.to_s) if version_code + args.push("--sha", sha.to_s) if sha + sh(shell_join(args)) +end + +def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil) + mobile_release_ref_command( + "preflight", + platform: platform, + version: version, + build: build, + version_code: version_code, + sha: sha + ) +end + +def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil) + mobile_release_ref_command( + "record", + platform: platform, + version: version, + build: build, + version_code: version_code, + sha: sha + ) +end + def read_android_release_signing_properties!(path) UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path) @@ -282,6 +334,13 @@ def upload_play_store_metadata!(version_metadata) end def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false) + release_sha = release_git_sha + ensure_mobile_release_ref_available!( + platform: "android", + version: version_metadata.fetch(:version), + version_code: version_metadata.fetch(:version_code), + sha: release_sha + ) ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots validate_android_screenshots! sync_android_changelog!(version_metadata.fetch(:version_code)) @@ -302,6 +361,15 @@ def upload_play_store_build!(version_metadata, upload_metadata: false, upload_im skip_upload_screenshots: !upload_screenshots, validate_only: play_validate_only? ) + + unless play_validate_only? + record_mobile_release_ref!( + platform: "android", + version: version_metadata.fetch(:version), + version_code: version_metadata.fetch(:version_code), + sha: release_sha + ) + end end load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env")) diff --git a/apps/ios/VERSIONING.md b/apps/ios/VERSIONING.md index 94d72416f42a..185bda6c63f5 100644 --- a/apps/ios/VERSIONING.md +++ b/apps/ios/VERSIONING.md @@ -129,6 +129,37 @@ pnpm ios:version:pin -- --version 2026.4.10 This keeps the TestFlight version stable while review is in flight. +## Release SHA tracking + +Successful App Store Connect uploads create a non-tag Git ref that records the +source commit for the uploaded store build: + +```text +refs/openclaw/mobile-releases/ios/- +``` + +Example: + +```text +refs/openclaw/mobile-releases/ios/2026.6.10-8 +``` + +These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do +not appear on GitHub release or tag pages, and they do not participate in the +core OpenClaw release machinery. + +`pnpm ios:release:upload` checks the ref before archive/upload work and records +it only after `upload_to_testflight` succeeds. Existing refs are immutable: the +same ref at the same SHA is accepted, while the same ref at a different SHA +fails. + +Useful direct commands: + +```bash +pnpm mobile:release:preflight -- --platform ios --version 2026.6.10 --build 8 +pnpm mobile:release:resolve -- --platform ios --version 2026.6.10 --build 8 +``` + ## New release promotion workflow When you want the next production iOS release to align with the current gateway release: diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index b7dbcaf06a89..748fa185552d 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -1128,6 +1128,58 @@ def prepare_app_store_release!(version:, build_number:) release_xcconfig end +def mobile_release_ref_script + File.join(repo_root, "scripts", "mobile-release-ref.ts") +end + +def release_git_sha + stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root) + UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success? + stdout.strip +end + +def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil) + args = [ + "node", + "--import", + "tsx", + mobile_release_ref_script, + command, + "--platform", + platform, + "--version", + version, + "--root", + repo_root, + ] + args.push("--build", build.to_s) if build + args.push("--version-code", version_code.to_s) if version_code + args.push("--sha", sha.to_s) if sha + sh(shell_join(args)) +end + +def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil) + mobile_release_ref_command( + "preflight", + platform: platform, + version: version, + build: build, + version_code: version_code, + sha: sha + ) +end + +def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil) + mobile_release_ref_command( + "record", + platform: platform, + version: version, + build: build, + version_code: version_code, + sha: sha + ) +end + def validate_app_store_ipa!(ipa_path) script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh") sh(shell_join(["bash", script_path, "--ipa", ipa_path])) @@ -1309,15 +1361,22 @@ platform :ios do UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.") end + release_sha = release_git_sha release_signing_check! preserve_local_signing do screenshots end + context = prepare_app_store_context(require_api_key: true) + ensure_mobile_release_ref_available!( + platform: "ios", + version: context[:short_version], + build: context[:build_number], + sha: release_sha + ) ENV["DELIVER_SCREENSHOTS"] = "1" ENV["DELIVER_RELEASE_NOTES"] = "1" metadata - context = prepare_app_store_context(require_api_key: true) build = build_app_store_release(context) upload_to_testflight( @@ -1326,6 +1385,12 @@ platform :ios do skip_waiting_for_build_processing: true, uses_non_exempt_encryption: false ) + record_mobile_release_ref!( + platform: "ios", + version: build[:short_version], + build: build[:build_number], + sha: release_sha + ) UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}") UI.important("App Review submission remains manual in App Store Connect.") diff --git a/package.json b/package.json index 65a091bbb0be..9d77a9bae87f 100644 --- a/package.json +++ b/package.json @@ -1680,6 +1680,9 @@ "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", "mac:restart": "bash scripts/restart-mac.sh", + "mobile:release:preflight": "node --import tsx scripts/mobile-release-ref.ts preflight", + "mobile:release:record": "node --import tsx scripts/mobile-release-ref.ts record", + "mobile:release:resolve": "node --import tsx scripts/mobile-release-ref.ts resolve", "openclaw": "node scripts/run-node.mjs", "openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json", "perf:issue-78851": "node --import tsx scripts/perf/issue-78851-model-resolution.ts", diff --git a/scripts/mobile-release-ref.ts b/scripts/mobile-release-ref.ts new file mode 100644 index 000000000000..5dad82831db3 --- /dev/null +++ b/scripts/mobile-release-ref.ts @@ -0,0 +1,376 @@ +// Tracks uploaded mobile store builds with non-tag Git refs. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export type MobileReleasePlatform = "ios" | "android"; +export type MobileReleaseCommand = "preflight" | "record" | "resolve"; + +type GitDeps = { + execFileSync?: typeof execFileSync; +}; + +type MobileReleaseOptions = { + build: string | null; + command: MobileReleaseCommand; + platform: MobileReleasePlatform; + remote: string; + rootDir: string; + sha: string; + version: string; + versionCode: string | null; +}; + +type RemoteRefState = { + ref: string; + sha: string; +} | null; + +const REF_PREFIX = "refs/openclaw/mobile-releases"; +const VERSION_RE = /^20\d{2}\.(?:[1-9]\d?)\.(?:[1-9]\d*)$/u; +const POSITIVE_INTEGER_RE = /^[1-9]\d*$/u; + +function git(args: string[], rootDir: string, deps: GitDeps = {}): string { + const exec = deps.execFileSync ?? execFileSync; + return exec("git", args, { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 16 * 1024 * 1024, + }); +} + +function errorOutput(value: unknown): string { + if (Buffer.isBuffer(value)) { + return value.toString("utf8"); + } + if (typeof value === "string") { + return value; + } + if (value == null) { + return ""; + } + return JSON.stringify(value) ?? Object.prototype.toString.call(value); +} + +function gitAllowFailure( + args: string[], + rootDir: string, + deps: GitDeps = {}, +): { ok: boolean; stdout: string; stderr: string } { + try { + return { ok: true, stdout: git(args, rootDir, deps), stderr: "" }; + } catch (error) { + const e = error as { stdout?: unknown; stderr?: unknown }; + const stdout = errorOutput(e.stdout); + const stderr = errorOutput(e.stderr); + return { ok: false, stdout, stderr }; + } +} + +function readOptionValue(argv: string[], index: number, flag: string): string { + const value = argv[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`Missing value for ${flag}.`); + } + return value; +} + +function parsePlatform(raw: string | null): MobileReleasePlatform { + if (raw === "ios" || raw === "android") { + return raw; + } + throw new Error("Missing or invalid --platform. Expected ios or android."); +} + +function parseCommand(raw: string | undefined): MobileReleaseCommand { + if (raw === "-h" || raw === "--help") { + throw new Error(usage()); + } + if (raw === "preflight" || raw === "record" || raw === "resolve") { + return raw; + } + throw new Error(`Unknown command '${raw ?? ""}'. Expected preflight, record, or resolve.`); +} + +export function parseArgs(argv: string[]): MobileReleaseOptions { + const command = parseCommand(argv[0]); + let build: string | null = null; + let platform: string | null = null; + let remote = "origin"; + let rootDir = path.resolve("."); + let sha = "HEAD"; + let version = ""; + let versionCode: string | null = null; + + for (let index = 1; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--": + break; + case "--platform": + platform = readOptionValue(argv, index, arg); + index += 1; + break; + case "--version": + version = readOptionValue(argv, index, arg); + index += 1; + break; + case "--build": + build = readOptionValue(argv, index, arg); + index += 1; + break; + case "--version-code": + versionCode = readOptionValue(argv, index, arg); + index += 1; + break; + case "--sha": + sha = readOptionValue(argv, index, arg); + index += 1; + break; + case "--remote": + remote = readOptionValue(argv, index, arg); + index += 1; + break; + case "--root": + rootDir = path.resolve(readOptionValue(argv, index, arg)); + index += 1; + break; + case "-h": + case "--help": + throw new Error(usage()); + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return { + build, + command, + platform: parsePlatform(platform), + remote, + rootDir, + sha, + version, + versionCode, + }; +} + +function validateVersion(version: string): string { + const trimmed = version.trim(); + if (!VERSION_RE.test(trimmed)) { + throw new Error(`Invalid mobile release version '${version}'. Expected YYYY.M.D.`); + } + return trimmed; +} + +function validatePositiveInteger(label: string, value: string | null): string { + const trimmed = value?.trim() ?? ""; + if (!POSITIVE_INTEGER_RE.test(trimmed)) { + throw new Error(`Invalid ${label} '${value ?? ""}'. Expected a positive integer.`); + } + return trimmed; +} + +function androidVersionCodePrefix(version: string): string { + const [year, rawMonth, rawPatch] = version.split("."); + return `${year}${rawMonth?.padStart(2, "0")}${rawPatch?.padStart(2, "0")}`; +} + +function validateAndroidVersionCode(version: string, versionCode: string | null): string { + const code = validatePositiveInteger("Android versionCode", versionCode); + const prefix = androidVersionCodePrefix(version); + const suffix = Number.parseInt(code.slice(prefix.length), 10); + if ( + !code.startsWith(prefix) || + code.length !== prefix.length + 2 || + !Number.isInteger(suffix) || + suffix < 1 || + suffix > 99 + ) { + throw new Error( + `Invalid Android versionCode '${code}'. Expected ${prefix}01 through ${prefix}99 for version ${version}.`, + ); + } + return code; +} + +export function mobileReleaseRefFor(options: { + build?: string | null; + platform: MobileReleasePlatform; + version: string; + versionCode?: string | null; +}): string { + const version = validateVersion(options.version); + if (options.platform === "ios") { + const build = validatePositiveInteger("iOS build", options.build ?? null); + return `${REF_PREFIX}/ios/${version}-${build}`; + } + + const versionCode = validateAndroidVersionCode(version, options.versionCode ?? null); + return `${REF_PREFIX}/android/${version}-${versionCode}`; +} + +function assertRootDir(rootDir: string): void { + if (!existsSync(path.join(rootDir, ".git"))) { + throw new Error(`Not a Git checkout root: ${rootDir}`); + } +} + +export function resolveCommitSha(sha: string, rootDir: string, deps: GitDeps = {}): string { + return git(["rev-parse", "--verify", `${sha}^{commit}`], rootDir, deps).trim(); +} + +export function readRemoteRef( + remote: string, + ref: string, + rootDir: string, + deps: GitDeps = {}, +): RemoteRefState { + const result = gitAllowFailure(["ls-remote", "--refs", remote, ref], rootDir, deps); + if (!result.ok) { + const detail = (result.stderr || result.stdout).trim(); + throw new Error(`Failed to inspect remote release ref ${ref}: ${detail}`); + } + + const line = result.stdout.trim(); + if (!line) { + return null; + } + + const [sha, remoteRef] = line.split(/\s+/u); + if (!sha || remoteRef !== ref) { + throw new Error(`Unexpected remote ref lookup output for ${ref}: ${line}`); + } + return { ref: remoteRef, sha }; +} + +function shortSha(sha: string): string { + return sha.slice(0, 12); +} + +function recoveryCommand(options: { ref: string; remote: string; sha: string }): string { + return `git push --force-with-lease=${options.ref}: ${options.remote} ${options.sha}:${options.ref}`; +} + +export function preflightMobileReleaseRef( + options: MobileReleaseOptions, + deps: GitDeps = {}, +): { ref: string; sha: string; status: "available" | "already-recorded" } { + assertRootDir(options.rootDir); + const ref = mobileReleaseRefFor(options); + const sha = resolveCommitSha(options.sha, options.rootDir, deps); + const existing = readRemoteRef(options.remote, ref, options.rootDir, deps); + + if (!existing) { + return { ref, sha, status: "available" }; + } + if (existing.sha === sha) { + return { ref, sha, status: "already-recorded" }; + } + + throw new Error( + `Mobile release ref ${ref} already points at ${existing.sha}; refusing to record ${sha}.`, + ); +} + +export function recordMobileReleaseRef( + options: MobileReleaseOptions, + deps: GitDeps = {}, +): { ref: string; sha: string; status: "created" | "already-recorded" } { + const preflight = preflightMobileReleaseRef(options, deps); + if (preflight.status === "already-recorded") { + return { ...preflight, status: "already-recorded" }; + } + + const pushArgs = [ + "push", + `--force-with-lease=${preflight.ref}:`, + options.remote, + `${preflight.sha}:${preflight.ref}`, + ]; + const result = gitAllowFailure(pushArgs, options.rootDir, deps); + if (!result.ok) { + const detail = (result.stderr || result.stdout).trim(); + throw new Error( + `Failed to create mobile release ref ${preflight.ref}. Recovery command:\n${recoveryCommand({ + ref: preflight.ref, + remote: options.remote, + sha: preflight.sha, + })}\n${detail}`, + ); + } + + const recorded = readRemoteRef(options.remote, preflight.ref, options.rootDir, deps); + if (recorded?.sha !== preflight.sha) { + throw new Error( + `Mobile release ref ${preflight.ref} was not recorded at ${preflight.sha}; remote has ${recorded?.sha ?? "no ref"}.`, + ); + } + + return { ref: preflight.ref, sha: preflight.sha, status: "created" }; +} + +export function resolveMobileReleaseRef( + options: MobileReleaseOptions, + deps: GitDeps = {}, +): { ref: string; sha: string } { + assertRootDir(options.rootDir); + const ref = mobileReleaseRefFor(options); + const existing = readRemoteRef(options.remote, ref, options.rootDir, deps); + if (!existing) { + throw new Error(`Mobile release ref ${ref} does not exist on ${options.remote}.`); + } + return { ref, sha: existing.sha }; +} + +function usage(): string { + return [ + "Usage:", + " node --import tsx scripts/mobile-release-ref.ts preflight --platform ios --version YYYY.M.D --build N [--sha HEAD] [--remote origin]", + " node --import tsx scripts/mobile-release-ref.ts record --platform android --version YYYY.M.D --version-code YYYYMMDDNN [--sha HEAD] [--remote origin]", + " node --import tsx scripts/mobile-release-ref.ts resolve --platform ios --version YYYY.M.D --build N [--remote origin]", + ].join("\n"); +} + +async function main(argv: string[]): Promise { + try { + const options = parseArgs(argv); + if (options.command === "preflight") { + const result = preflightMobileReleaseRef(options); + const suffix = + result.status === "already-recorded" + ? `already records ${shortSha(result.sha)}` + : `available for ${shortSha(result.sha)}`; + process.stdout.write(`Mobile release ref ${result.ref} is ${suffix}.\n`); + return 0; + } + + if (options.command === "record") { + const result = recordMobileReleaseRef(options); + const verb = result.status === "already-recorded" ? "already records" : "recorded"; + process.stdout.write(`Mobile release ref ${result.ref} ${verb} ${result.sha}.\n`); + return 0; + } + + const result = resolveMobileReleaseRef(options); + process.stdout.write(`${result.sha}\t${result.ref}\n`); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith("Usage:")) { + process.stdout.write(`${message}\n`); + return 0; + } + process.stderr.write(`${message}\n`); + return 1; + } +} + +if (import.meta.url === pathToFileURL(path.resolve(process.argv[1] ?? "")).href) { + const exitCode = await main(process.argv.slice(2)); + if (exitCode !== 0) { + process.exit(exitCode); + } +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 95372144f21a..78134e8103e1 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -977,9 +977,11 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/github/run-openclaw-cross-os-release-checks.sh", ["test/scripts/openclaw-cross-os-release-workflow.test.ts"], ], + ["scripts/mobile-release-ref.ts", ["test/scripts/mobile-release-ref.test.ts"]], ["scripts/android-release.sh", ["test/scripts/android-release-wrapper-args.test.ts"]], ["scripts/android-release-signing.mjs", ["test/scripts/android-release-signing.test.ts"]], ["scripts/android-release-upload.sh", ["test/scripts/android-release-wrapper-args.test.ts"]], + ["apps/android/fastlane/Fastfile", ["test/scripts/android-release-fastlane-gates.test.ts"]], ["scripts/ios-release-archive.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]], ["scripts/ios-release-prepare.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]], ["scripts/ios-release-signing.mjs", ["test/scripts/ios-release-signing.test.ts"]], diff --git a/test/scripts/android-release-fastlane-gates.test.ts b/test/scripts/android-release-fastlane-gates.test.ts new file mode 100644 index 000000000000..cc12b4b0ea4d --- /dev/null +++ b/test/scripts/android-release-fastlane-gates.test.ts @@ -0,0 +1,46 @@ +// Android Fastlane release gate tests keep Play uploads tied to mobile release refs. +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const fastfilePath = path.join(process.cwd(), "apps", "android", "fastlane", "Fastfile"); + +function readFastfile(): string { + return readFileSync(fastfilePath, "utf8"); +} + +function functionBody(source: string, name: string): string { + const startMarker = `def ${name}`; + const start = source.indexOf(startMarker); + if (start < 0) { + throw new Error(`missing Fastlane helper ${name}`); + } + + const rest = source.slice(start + startMarker.length); + const nextDef = rest.search(/\n(?:def|load_env_file|platform) /); + return nextDef < 0 ? rest : rest.slice(0, nextDef); +} + +describe("Android Fastlane release upload gates", () => { + it("preflights and records mobile release refs around Play build upload", () => { + const fastfile = readFastfile(); + const uploadBuild = functionBody(fastfile, "upload_play_store_build!"); + + expect(fastfile).toContain("def mobile_release_ref_command"); + expect(fastfile).toContain("def release_git_sha"); + expect(fastfile).toContain('"--root"'); + expect(fastfile).toContain('"--sha"'); + expect(fastfile).toContain("repo_root"); + expect(uploadBuild).toContain("release_sha = release_git_sha"); + expect(uploadBuild).toContain("ensure_mobile_release_ref_available!"); + expect(uploadBuild).toContain("record_mobile_release_ref!"); + expect(uploadBuild.match(/sha: release_sha/g)).toHaveLength(2); + expect(uploadBuild.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan( + uploadBuild.indexOf("upload_to_play_store("), + ); + expect(uploadBuild.indexOf("record_mobile_release_ref!")).toBeGreaterThan( + uploadBuild.indexOf("upload_to_play_store("), + ); + expect(uploadBuild).toContain("unless play_validate_only?"); + }); +}); diff --git a/test/scripts/ios-release-fastlane-gates.test.ts b/test/scripts/ios-release-fastlane-gates.test.ts index 8fea4e7267d0..c545bebdc39a 100644 --- a/test/scripts/ios-release-fastlane-gates.test.ts +++ b/test/scripts/ios-release-fastlane-gates.test.ts @@ -74,6 +74,27 @@ describe("iOS Fastlane release upload gates", () => { expect(uploadCall).toBeGreaterThan(validationCall); }); + it("preflights and records mobile release refs around TestFlight upload", () => { + const fastfile = readFastfile(); + const releaseUpload = laneBody(fastfile, "release_upload"); + + expect(fastfile).toContain("def mobile_release_ref_command"); + expect(fastfile).toContain("def release_git_sha"); + expect(fastfile).toContain('"--root"'); + expect(fastfile).toContain('"--sha"'); + expect(fastfile).toContain("repo_root"); + expect(releaseUpload).toContain("release_sha = release_git_sha"); + expect(releaseUpload).toContain("ensure_mobile_release_ref_available!"); + expect(releaseUpload).toContain("record_mobile_release_ref!"); + expect(releaseUpload.match(/sha: release_sha/g)).toHaveLength(2); + expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan( + releaseUpload.indexOf("\n metadata\n"), + ); + expect(releaseUpload.indexOf("record_mobile_release_ref!")).toBeGreaterThan( + releaseUpload.indexOf("upload_to_testflight("), + ); + }); + it("normalizes Watch screenshots as opaque RGB PNGs for App Store upload", () => { const fastfile = readFastfile(); diff --git a/test/scripts/mobile-release-ref.test.ts b/test/scripts/mobile-release-ref.test.ts new file mode 100644 index 000000000000..51c4cd940915 --- /dev/null +++ b/test/scripts/mobile-release-ref.test.ts @@ -0,0 +1,231 @@ +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + mobileReleaseRefFor, + parseArgs, + preflightMobileReleaseRef, + recordMobileReleaseRef, + resolveMobileReleaseRef, +} from "../../scripts/mobile-release-ref.ts"; + +const SCRIPT_PATH = path.join(process.cwd(), "scripts", "mobile-release-ref.ts"); + +function run(command: string, args: string[], cwd: string): string { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function git(cwd: string, args: string[]): string { + return run("git", args, cwd); +} + +function createFixtureRepo(): { cleanup: () => void; remote: string; root: string; sha: string } { + const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-mobile-release-ref-")); + const remote = path.join(root, "remote.git"); + const checkout = path.join(root, "checkout"); + + git(root, ["init", "--bare", remote]); + git(root, ["clone", remote, checkout]); + git(checkout, ["config", "user.email", "release@example.com"]); + git(checkout, ["config", "user.name", "Release Test"]); + writeFileSync(path.join(checkout, "README.md"), "release\n", "utf8"); + git(checkout, ["add", "README.md"]); + git(checkout, ["commit", "-m", "initial"]); + const sha = git(checkout, ["rev-parse", "HEAD"]).trim(); + git(checkout, ["push", "origin", "HEAD:main"]); + + return { + cleanup: () => rmSync(root, { force: true, recursive: true }), + remote: "origin", + root: checkout, + sha, + }; +} + +describe("mobile-release-ref", () => { + it("renders platform release refs from store identities", () => { + expect(mobileReleaseRefFor({ platform: "ios", version: "2026.6.10", build: "8" })).toBe( + "refs/openclaw/mobile-releases/ios/2026.6.10-8", + ); + expect( + mobileReleaseRefFor({ + platform: "android", + version: "2026.6.10", + versionCode: "2026061008", + }), + ).toBe("refs/openclaw/mobile-releases/android/2026.6.10-2026061008"); + }); + + it("validates platform-specific numeric identities", () => { + expect(() => + mobileReleaseRefFor({ platform: "ios", version: "2026.6.10", build: "0" }), + ).toThrow("Invalid iOS build"); + expect(() => + mobileReleaseRefFor({ + platform: "android", + version: "2026.6.10", + versionCode: "not-a-code", + }), + ).toThrow("Invalid Android versionCode"); + expect(() => + mobileReleaseRefFor({ + platform: "android", + version: "2026.6.10", + versionCode: "2026061101", + }), + ).toThrow("Expected 2026061001 through 2026061099"); + expect(() => + mobileReleaseRefFor({ platform: "ios", version: "2026.06.10", build: "8" }), + ).toThrow("Invalid mobile release version"); + }); + + it("parses CLI commands and rejects missing platform-specific fields", () => { + expect( + parseArgs([ + "record", + "--", + "--platform", + "android", + "--version", + "2026.6.10", + "--version-code", + "2026061008", + "--sha", + "HEAD", + ]), + ).toMatchObject({ + command: "record", + platform: "android", + version: "2026.6.10", + versionCode: "2026061008", + }); + + expect(() => + mobileReleaseRefFor({ + platform: "android", + version: "2026.6.10", + }), + ).toThrow("Invalid Android versionCode"); + }); + + it("creates, resolves, and idempotently accepts an existing same-SHA ref", () => { + const fixture = createFixtureRepo(); + try { + const options = { + build: "8", + command: "record" as const, + platform: "ios" as const, + remote: fixture.remote, + rootDir: fixture.root, + sha: "HEAD", + version: "2026.6.10", + versionCode: null, + }; + + expect(preflightMobileReleaseRef(options).status).toBe("available"); + expect(recordMobileReleaseRef(options)).toMatchObject({ + ref: "refs/openclaw/mobile-releases/ios/2026.6.10-8", + sha: fixture.sha, + status: "created", + }); + expect(recordMobileReleaseRef(options).status).toBe("already-recorded"); + expect(resolveMobileReleaseRef(options)).toMatchObject({ + ref: "refs/openclaw/mobile-releases/ios/2026.6.10-8", + sha: fixture.sha, + }); + } finally { + fixture.cleanup(); + } + }); + + it("rejects an existing ref at a different SHA", () => { + const fixture = createFixtureRepo(); + try { + const first = { + build: null, + command: "record" as const, + platform: "android" as const, + remote: fixture.remote, + rootDir: fixture.root, + sha: "HEAD", + version: "2026.6.10", + versionCode: "2026061008", + }; + recordMobileReleaseRef(first); + + writeFileSync(path.join(fixture.root, "README.md"), "next\n", "utf8"); + git(fixture.root, ["add", "README.md"]); + git(fixture.root, ["commit", "-m", "next"]); + + expect(() => recordMobileReleaseRef(first)).toThrow("already points at"); + } finally { + fixture.cleanup(); + } + }); + + it("prints the resolved SHA from the CLI", () => { + const fixture = createFixtureRepo(); + try { + recordMobileReleaseRef({ + build: "9", + command: "record", + platform: "ios", + remote: fixture.remote, + rootDir: fixture.root, + sha: "HEAD", + version: "2026.6.10", + versionCode: null, + }); + + const stdout = run( + process.execPath, + [ + "--import", + "tsx", + SCRIPT_PATH, + "resolve", + "--platform", + "ios", + "--version", + "2026.6.10", + "--build", + "9", + "--root", + fixture.root, + ], + process.cwd(), + ); + + expect(stdout).toBe(`${fixture.sha}\trefs/openclaw/mobile-releases/ios/2026.6.10-9\n`); + } finally { + fixture.cleanup(); + } + }); + + it("runs the CLI entrypoint from a path containing spaces", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "openclaw mobile release ref-")); + try { + const scriptDir = path.join(root, "script dir"); + const scriptPath = path.join(scriptDir, "mobile-release-ref.ts"); + mkdirSync(scriptDir, { recursive: true }); + writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n', "utf8"); + copyFileSync(SCRIPT_PATH, scriptPath); + + const stdout = run( + process.execPath, + ["--import", "tsx", realpathSync(scriptPath), "--help"], + process.cwd(), + ); + + expect(stdout).toContain("scripts/mobile-release-ref.ts preflight"); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); +});