mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
Track mobile release SHAs with refs
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user