Track mobile release SHAs with refs

This commit is contained in:
joshavant
2026-06-26 18:48:49 -05:00
parent ff18374293
commit ff35f3bb2c
10 changed files with 876 additions and 1 deletions
+32
View File
@@ -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/<versionName>-<versionCode>
```
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`.
+68
View File
@@ -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"))
+31
View File
@@ -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/<CFBundleShortVersionString>-<CFBundleVersion>
```
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:
+66 -1
View File
@@ -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.")
+3
View File
@@ -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",
+376
View File
@@ -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<number> {
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);
}
}
+2
View File
@@ -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"]],
@@ -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?");
});
});
@@ -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();
+231
View File
@@ -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 });
}
});
});