android: add pinned release versioning

This commit is contained in:
joshavant
2026-06-16 17:34:35 +02:00
parent b836946879
commit 40eec48caf
15 changed files with 852 additions and 5 deletions
+6
View File
@@ -0,0 +1,6 @@
# Shared Android version defaults.
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201
+20 -2
View File
@@ -1,6 +1,24 @@
import com.android.build.api.variant.impl.VariantOutputImpl
import java.util.Properties
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
error("Missing Android version properties. Run `pnpm android:version:sync`.")
}
openClawAndroidVersionFile.inputStream().use(::load)
}
fun requireOpenClawAndroidVersionProperty(name: String): String =
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
val openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
val openClawAndroidVersionCode =
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
@@ -65,8 +83,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026060201
versionName = "2026.6.2"
versionCode = openClawAndroidVersionCode
versionName = openClawAndroidVersionName
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
+4
View File
@@ -0,0 +1,4 @@
{
"version": "2026.6.2",
"versionCode": 2026060201
}
+4
View File
@@ -1443,6 +1443,10 @@
"android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest",
"android:test:integration": "node scripts/run-with-env.mjs OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"android:test:third-party": "node scripts/run-android-gradle.mjs :app:testThirdPartyDebugUnitTest",
"android:version": "node --import tsx scripts/android-version.ts --json",
"android:version:check": "node --import tsx scripts/android-sync-versioning.ts --check",
"android:version:pin": "node --import tsx scripts/android-pin-version.ts",
"android:version:sync": "node --import tsx scripts/android-sync-versioning.ts --write",
"audit:seams": "node scripts/audit-seams.mjs",
"build": "node scripts/build-all.mjs",
"build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts",
+188
View File
@@ -0,0 +1,188 @@
// Android Pin Version script supports OpenClaw repository automation.
import path from "node:path";
import {
canonicalAndroidVersionCode,
normalizeAndroidVersionCode,
normalizePinnedAndroidVersion,
resolveAndroidVersion,
resolveGatewayVersionForAndroidRelease,
syncAndroidVersioning,
writeAndroidVersionManifest,
} from "./lib/android-version.ts";
type CliOptions = {
explicitVersion: string | null;
explicitVersionCode: number | null;
fromGateway: boolean;
rootDir: string;
sync: boolean;
};
export type PinAndroidVersionResult = {
previousVersion: string | null;
previousVersionCode: number | null;
nextVersion: string;
nextVersionCode: number;
packageVersion: string | null;
versionFilePath: string;
syncedPaths: string[];
};
function usage(): string {
return [
"Usage: node --import tsx scripts/android-pin-version.ts (--from-gateway | --version <YYYY.M.PATCH>) [--version-code <int>] [--no-sync] [--root dir]",
"",
"Examples:",
" node --import tsx scripts/android-pin-version.ts --from-gateway",
" node --import tsx scripts/android-pin-version.ts --version 2026.6.5",
" node --import tsx scripts/android-pin-version.ts --version 2026.6.5 --version-code 2026060502",
].join("\n");
}
export function parseArgs(argv: string[]): CliOptions {
let explicitVersion: string | null = null;
let explicitVersionCode: number | null = null;
let fromGateway = false;
let rootDir = path.resolve(".");
let sync = true;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--from-gateway": {
fromGateway = true;
break;
}
case "--version": {
explicitVersion = argv[index + 1] ?? null;
index += 1;
break;
}
case "--version-code": {
const value = argv[index + 1];
if (!value) {
throw new Error("Missing value for --version-code.");
}
explicitVersionCode = Number.parseInt(value, 10);
index += 1;
break;
}
case "--no-sync": {
sync = false;
break;
}
case "--root": {
const value = argv[index + 1];
if (!value) {
throw new Error("Missing value for --root.");
}
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
console.log(`${usage()}\n`);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
if (fromGateway === (explicitVersion !== null)) {
throw new Error("Choose exactly one of --from-gateway or --version <YYYY.M.PATCH>.");
}
if (explicitVersion !== null && !explicitVersion.trim()) {
throw new Error("Missing value for --version.");
}
return { explicitVersion, explicitVersionCode, fromGateway, rootDir, sync };
}
export function pinAndroidVersion(params: CliOptions): PinAndroidVersionResult {
const rootDir = path.resolve(params.rootDir);
let previousVersion: string | null;
let previousVersionCode: number | null;
try {
const currentVersion = resolveAndroidVersion(rootDir);
previousVersion = currentVersion.canonicalVersion;
previousVersionCode = currentVersion.versionCode;
} catch {
previousVersion = null;
previousVersionCode = null;
}
const gatewayVersion = params.fromGateway
? resolveGatewayVersionForAndroidRelease(rootDir)
: null;
const packageVersion = gatewayVersion?.packageVersion ?? null;
const nextVersion =
gatewayVersion?.pinnedAndroidVersion ??
normalizePinnedAndroidVersion(params.explicitVersion ?? "");
const nextVersionCode =
params.explicitVersionCode === null
? (gatewayVersion?.versionCode ?? canonicalAndroidVersionCode(nextVersion))
: normalizeAndroidVersionCode(params.explicitVersionCode, nextVersion);
const versionFilePath = writeAndroidVersionManifest(nextVersion, nextVersionCode, rootDir);
const syncedPaths = params.sync
? syncAndroidVersioning({ mode: "write", rootDir }).updatedPaths
: [];
return {
previousVersion,
previousVersionCode,
nextVersion,
nextVersionCode,
packageVersion,
versionFilePath,
syncedPaths,
};
}
export async function main(argv: string[]): Promise<number> {
try {
const options = parseArgs(argv);
const result = pinAndroidVersion(options);
const sourceText = result.packageVersion
? ` from gateway version ${result.packageVersion}`
: "";
process.stdout.write(
`Pinned Android version to ${result.nextVersion} (${result.nextVersionCode})${sourceText}.\n`,
);
if (
result.previousVersion &&
(result.previousVersion !== result.nextVersion ||
result.previousVersionCode !== result.nextVersionCode)
) {
process.stdout.write(
`Previous pinned Android version: ${result.previousVersion} (${result.previousVersionCode}).\n`,
);
}
process.stdout.write(
`Updated version manifest: ${path.relative(process.cwd(), result.versionFilePath)}\n`,
);
if (options.sync) {
if (result.syncedPaths.length === 0) {
process.stdout.write("Android versioning artifacts already up to date.\n");
} else {
process.stdout.write(
`Updated Android versioning artifacts:\n- ${result.syncedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
);
}
}
return 0;
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const exitCode = await main(process.argv.slice(2));
if (exitCode !== 0) {
process.exit(exitCode);
}
}
+58
View File
@@ -0,0 +1,58 @@
// Android Sync Versioning script supports OpenClaw repository automation.
import path from "node:path";
import { syncAndroidVersioning } from "./lib/android-version.ts";
type Mode = "check" | "write";
export function parseArgs(argv: string[]): { mode: Mode; rootDir: string } {
let mode: Mode = "write";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--check": {
mode = "check";
break;
}
case "--write": {
mode = "write";
break;
}
case "--root": {
const value = argv[index + 1];
if (!value) {
throw new Error("Missing value for --root.");
}
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
console.log(
"Usage: node --import tsx scripts/android-sync-versioning.ts [--write|--check] [--root dir]",
);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { mode, rootDir };
}
const options = parseArgs(process.argv.slice(2));
const result = syncAndroidVersioning({ mode: options.mode, rootDir: options.rootDir });
if (options.mode === "check") {
process.stdout.write("Android versioning artifacts are up to date.\n");
} else if (result.updatedPaths.length === 0) {
process.stdout.write("Android versioning artifacts already up to date.\n");
} else {
process.stdout.write(
`Updated Android versioning artifacts:\n- ${result.updatedPaths.map((filePath) => path.relative(process.cwd(), filePath)).join("\n- ")}\n`,
);
}
+78
View File
@@ -0,0 +1,78 @@
// Android Version script supports OpenClaw repository automation.
import path from "node:path";
import { resolveAndroidVersion } from "./lib/android-version.ts";
type CliOptions = {
field: string | null;
format: "json" | "shell";
rootDir: string;
};
function parseArgs(argv: string[]): CliOptions {
let field: string | null = null;
let format: "json" | "shell" = "json";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--field": {
field = argv[index + 1] ?? null;
index += 1;
break;
}
case "--json": {
format = "json";
break;
}
case "--shell": {
format = "shell";
break;
}
case "--root": {
const value = argv[index + 1];
if (!value) {
throw new Error("Missing value for --root.");
}
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
console.log(
`Usage: node --import tsx scripts/android-version.ts [--json|--shell] [--field name] [--root dir]\n`,
);
process.exit(0);
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { field, format, rootDir };
}
const options = parseArgs(process.argv.slice(2));
const version = resolveAndroidVersion(options.rootDir);
if (options.field) {
const value = version[options.field as keyof typeof version];
if (value === undefined) {
throw new Error(`Unknown Android version field '${options.field}'.`);
}
process.stdout.write(`${value}\n`);
process.exit(0);
}
if (options.format === "shell") {
process.stdout.write(
[
`OPENCLAW_ANDROID_VERSION_NAME=${version.canonicalVersion}`,
`OPENCLAW_ANDROID_VERSION_CODE=${version.versionCode}`,
].join("\n") + "\n",
);
} else {
process.stdout.write(`${JSON.stringify(version, null, 2)}\n`);
}
+2 -1
View File
@@ -30,7 +30,8 @@ const PUBLIC_EXTENSION_CONTRACT_RE =
*/
export const RELEASE_METADATA_PATHS = new Set([
"CHANGELOG.md",
"apps/android/app/build.gradle.kts",
"apps/android/Config/Version.properties",
"apps/android/version.json",
"apps/ios/CHANGELOG.md",
"apps/ios/Config/Version.xcconfig",
"apps/ios/fastlane/metadata/en-US/release_notes.txt",
+1
View File
@@ -269,6 +269,7 @@ export function createChangedCheckPlan(result, options = {}) {
? ["--staged"]
: ["--base", options.base ?? "origin/main", "--head", options.head ?? "HEAD"]),
]);
add("Android version sync", ["android:version:check"]);
add("iOS version sync", ["ios:version:check"]);
add("config schema baseline", ["config:schema:check"]);
add("config docs baseline", ["config:docs:check"]);
+2 -1
View File
@@ -6,7 +6,8 @@ import path from "node:path";
import { RELEASE_METADATA_PATHS } from "./changed-lanes.mjs";
const VERSION_ONLY_TEXT_PATHS = new Set([
"apps/android/app/build.gradle.kts",
"apps/android/Config/Version.properties",
"apps/android/version.json",
"apps/ios/Config/Version.xcconfig",
"apps/ios/version.json",
"apps/macos/Sources/OpenClaw/Resources/Info.plist",
+210
View File
@@ -0,0 +1,210 @@
// Android Version script supports OpenClaw repository automation.
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
const ANDROID_VERSION_FILE = "apps/android/version.json";
const ANDROID_VERSION_PROPERTIES_FILE = "apps/android/Config/Version.properties";
const PINNED_ANDROID_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.[1-9]\d*)$/u;
const GATEWAY_VERSION_PATTERN = /^(\d{4}\.\d{1,2}\.[1-9]\d*)(?:-(?:alpha\.\d+|beta\.\d+|\d+))?$/u;
type AndroidVersionManifest = {
version: string;
versionCode: number;
};
export type ResolvedAndroidVersion = {
canonicalVersion: string;
versionCode: number;
versionFilePath: string;
versionPropertiesPath: string;
};
type SyncAndroidVersioningMode = "check" | "write";
function normalizeTrailingNewline(value: string): string {
return value.endsWith("\n") ? value : `${value}\n`;
}
export function normalizePinnedAndroidVersion(rawVersion: string): string {
const trimmed = rawVersion.trim();
if (!trimmed) {
throw new Error(`Missing Android version in ${ANDROID_VERSION_FILE}.`);
}
const match = PINNED_ANDROID_VERSION_PATTERN.exec(trimmed);
if (!match) {
throw new Error(
`Invalid Android version '${rawVersion}'. Expected pinned release version like 2026.6.5.`,
);
}
return match[1] ?? trimmed;
}
export function normalizeGatewayVersionToPinnedAndroidVersion(rawVersion: string): string {
const trimmed = rawVersion.trim().replace(/^v/u, "");
if (!trimmed) {
throw new Error("Missing root package.json version.");
}
const match = GATEWAY_VERSION_PATTERN.exec(trimmed);
if (!match) {
throw new Error(
`Invalid gateway version '${rawVersion}'. Expected YYYY.M.PATCH, YYYY.M.PATCH-alpha.N, YYYY.M.PATCH-beta.N, or YYYY.M.PATCH-N.`,
);
}
return match[1] ?? trimmed;
}
export function canonicalAndroidVersionCode(version: string): number {
const canonicalVersion = normalizePinnedAndroidVersion(version);
const [year, rawMonth, rawPatch] = canonicalVersion.split(".");
const month = rawMonth?.padStart(2, "0");
const patch = rawPatch?.padStart(2, "0");
const versionCode = Number.parseInt(`${year}${month}${patch}01`, 10);
if (!Number.isInteger(versionCode)) {
throw new Error(`Unable to derive Android versionCode from ${canonicalVersion}.`);
}
return versionCode;
}
export function normalizeAndroidVersionCode(rawVersionCode: number, version: string): number {
if (!Number.isInteger(rawVersionCode) || rawVersionCode <= 0 || rawVersionCode > 2_100_000_000) {
throw new Error(
`Invalid Android versionCode '${rawVersionCode}'. Expected a positive integer no greater than 2100000000.`,
);
}
const prefix = canonicalAndroidVersionCode(version).toString().slice(0, -2);
const raw = rawVersionCode.toString();
const suffix = Number.parseInt(raw.slice(prefix.length), 10);
if (
!raw.startsWith(prefix) ||
raw.length !== prefix.length + 2 ||
!Number.isInteger(suffix) ||
suffix < 1 ||
suffix > 99
) {
throw new Error(
`Invalid Android versionCode '${rawVersionCode}'. Expected ${prefix}01 through ${prefix}99 for version ${version}.`,
);
}
return rawVersionCode;
}
function readRootPackageVersion(rootDir = path.resolve(".")): string {
const packageJsonPath = path.join(rootDir, "package.json");
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
if (!version) {
throw new Error(`Missing package.json version in ${packageJsonPath}.`);
}
return version;
}
export function resolveGatewayVersionForAndroidRelease(rootDir = path.resolve(".")): {
packageVersion: string;
pinnedAndroidVersion: string;
versionCode: number;
} {
const packageVersion = readRootPackageVersion(rootDir);
const pinnedAndroidVersion = normalizeGatewayVersionToPinnedAndroidVersion(packageVersion);
return {
packageVersion,
pinnedAndroidVersion,
versionCode: canonicalAndroidVersionCode(pinnedAndroidVersion),
};
}
function readAndroidVersionManifest(rootDir = path.resolve(".")): AndroidVersionManifest {
const versionFilePath = path.join(rootDir, ANDROID_VERSION_FILE);
return JSON.parse(readFileSync(versionFilePath, "utf8")) as AndroidVersionManifest;
}
export function writeAndroidVersionManifest(
version: string,
versionCode: number | null,
rootDir = path.resolve("."),
): string {
const versionFilePath = path.join(rootDir, ANDROID_VERSION_FILE);
const normalizedVersion = normalizePinnedAndroidVersion(version);
const normalizedVersionCode = normalizeAndroidVersionCode(
versionCode ?? canonicalAndroidVersionCode(normalizedVersion),
normalizedVersion,
);
const nextContent = `${JSON.stringify(
{ version: normalizedVersion, versionCode: normalizedVersionCode },
null,
2,
)}\n`;
writeFileSync(versionFilePath, nextContent, "utf8");
return versionFilePath;
}
export function resolveAndroidVersion(rootDir = path.resolve(".")): ResolvedAndroidVersion {
const versionFilePath = path.join(rootDir, ANDROID_VERSION_FILE);
const versionPropertiesPath = path.join(rootDir, ANDROID_VERSION_PROPERTIES_FILE);
const manifest = readAndroidVersionManifest(rootDir);
const canonicalVersion = normalizePinnedAndroidVersion(manifest.version ?? "");
const versionCode = normalizeAndroidVersionCode(manifest.versionCode, canonicalVersion);
return {
canonicalVersion,
versionCode,
versionFilePath,
versionPropertiesPath,
};
}
export function renderAndroidVersionProperties(version: ResolvedAndroidVersion): string {
return `# Shared Android version defaults.\n# Source of truth: apps/android/version.json\n# Generated by scripts/android-sync-versioning.ts.\n\nOPENCLAW_ANDROID_VERSION_NAME=${version.canonicalVersion}\nOPENCLAW_ANDROID_VERSION_CODE=${version.versionCode}\n`;
}
function syncFile(params: {
mode: SyncAndroidVersioningMode;
path: string;
nextContent: string;
label: string;
}): boolean {
const nextContent = normalizeTrailingNewline(params.nextContent);
const currentContent = readFileSync(params.path, "utf8");
if (currentContent === nextContent) {
return false;
}
if (params.mode === "check") {
throw new Error(`${params.label} is stale: ${path.relative(process.cwd(), params.path)}`);
}
writeFileSync(params.path, nextContent, "utf8");
return true;
}
export function syncAndroidVersioning(params?: {
mode?: SyncAndroidVersioningMode;
rootDir?: string;
}): {
updatedPaths: string[];
} {
const mode = params?.mode ?? "write";
const rootDir = path.resolve(params?.rootDir ?? ".");
const version = resolveAndroidVersion(rootDir);
const nextVersionProperties = renderAndroidVersionProperties(version);
const updatedPaths: string[] = [];
if (
syncFile({
mode,
path: version.versionPropertiesPath,
nextContent: nextVersionProperties,
label: "Android version properties",
})
) {
updatedPaths.push(version.versionPropertiesPath);
}
return { updatedPaths };
}
+125
View File
@@ -0,0 +1,125 @@
// Android Pin Version tests cover android pin version script behavior.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseArgs, pinAndroidVersion } from "../../scripts/android-pin-version.ts";
import { resolveAndroidVersion } from "../../scripts/lib/android-version.ts";
import {
installAndroidFixtureCleanup,
writeAndroidFixture,
} from "./android-version.test-support.ts";
installAndroidFixtureCleanup();
describe("parseArgs", () => {
it("requires exactly one pin source", () => {
expect(() => parseArgs([])).toThrow(
"Choose exactly one of --from-gateway or --version <YYYY.M.PATCH>",
);
expect(() => parseArgs(["--from-gateway", "--version", "2026.6.5"])).toThrow(
"Choose exactly one of --from-gateway or --version <YYYY.M.PATCH>",
);
});
});
describe("pinAndroidVersion", () => {
it("pins an explicit Android release version and syncs generated artifacts", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
prefix: "openclaw-android-pin-",
});
const result = pinAndroidVersion({
explicitVersion: "2026.6.5",
explicitVersionCode: null,
fromGateway: false,
rootDir,
sync: true,
});
expect(result.previousVersion).toBe("2026.6.2");
expect(result.previousVersionCode).toBe(2026060201);
expect(result.nextVersion).toBe("2026.6.5");
expect(result.nextVersionCode).toBe(2026060501);
expect(result.packageVersion).toBeNull();
expect(resolveAndroidVersion(rootDir).canonicalVersion).toBe("2026.6.5");
expect(
fs.readFileSync(path.join(rootDir, "apps", "android", "version.json"), "utf8"),
).toContain('"versionCode": 2026060501');
expect(
fs.readFileSync(
path.join(rootDir, "apps", "android", "Config", "Version.properties"),
"utf8",
),
).toContain("OPENCLAW_ANDROID_VERSION_NAME=2026.6.5");
expect(result.syncedPaths).toHaveLength(1);
});
it("pins from the current gateway version without carrying prerelease suffixes", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
packageVersion: "2026.6.5-beta.3",
prefix: "openclaw-android-pin-",
});
const result = pinAndroidVersion({
explicitVersion: null,
explicitVersionCode: null,
fromGateway: true,
rootDir,
sync: true,
});
expect(result.nextVersion).toBe("2026.6.5");
expect(result.nextVersionCode).toBe(2026060501);
expect(result.packageVersion).toBe("2026.6.5-beta.3");
expect(resolveAndroidVersion(rootDir).canonicalVersion).toBe("2026.6.5");
});
it("allows explicit versionCode increments for another build on the same train", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
prefix: "openclaw-android-pin-",
});
const result = pinAndroidVersion({
explicitVersion: "2026.6.2",
explicitVersionCode: 2026060202,
fromGateway: false,
rootDir,
sync: true,
});
expect(result.nextVersion).toBe("2026.6.2");
expect(result.nextVersionCode).toBe(2026060202);
expect(resolveAndroidVersion(rootDir).versionCode).toBe(2026060202);
});
it("can skip syncing checked-in artifacts when requested", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
versionProperties: "stale\n",
prefix: "openclaw-android-pin-",
});
const result = pinAndroidVersion({
explicitVersion: "2026.6.5",
explicitVersionCode: null,
fromGateway: false,
rootDir,
sync: false,
});
expect(result.syncedPaths).toHaveLength(0);
expect(
fs.readFileSync(
path.join(rootDir, "apps", "android", "Config", "Version.properties"),
"utf8",
),
).toBe("stale\n");
});
});
@@ -0,0 +1,40 @@
// Android version test support provides shared fixtures for Android script tests.
import fs from "node:fs";
import path from "node:path";
import { afterEach } from "vitest";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
export function installAndroidFixtureCleanup(): void {
afterEach(() => {
cleanupTempDirs(tempDirs);
});
}
export function writeAndroidFixture(params: {
version: string;
versionCode: number;
packageVersion?: string;
versionProperties?: string;
prefix?: string;
}): string {
const rootDir = makeTempDir(tempDirs, params.prefix ?? "openclaw-android-version-");
fs.mkdirSync(path.join(rootDir, "apps", "android", "Config"), { recursive: true });
fs.writeFileSync(
path.join(rootDir, "package.json"),
`${JSON.stringify({ version: params.packageVersion ?? "2026.6.2" }, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "apps", "android", "version.json"),
`${JSON.stringify({ version: params.version, versionCode: params.versionCode }, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "apps", "android", "Config", "Version.properties"),
params.versionProperties ?? "",
"utf8",
);
return rootDir;
}
+111
View File
@@ -0,0 +1,111 @@
// Android Version tests cover android version script behavior.
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
canonicalAndroidVersionCode,
normalizeGatewayVersionToPinnedAndroidVersion,
renderAndroidVersionProperties,
resolveAndroidVersion,
resolveGatewayVersionForAndroidRelease,
} from "../../scripts/lib/android-version.ts";
import {
installAndroidFixtureCleanup,
writeAndroidFixture,
} from "./android-version.test-support.ts";
installAndroidFixtureCleanup();
describe("resolveAndroidVersion", () => {
it("parses pinned release versions and Android version codes", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
});
expect(resolveAndroidVersion(rootDir)).toEqual({
canonicalVersion: "2026.6.2",
versionCode: 2026060201,
versionFilePath: path.join(rootDir, "apps/android/version.json"),
versionPropertiesPath: path.join(rootDir, "apps/android/Config/Version.properties"),
});
});
it("rejects semver-only versions", () => {
const rootDir = writeAndroidFixture({
version: "1.2.3",
versionCode: 2026060201,
});
expect(() => resolveAndroidVersion(rootDir)).toThrow(
"Expected pinned release version like 2026.6.5",
);
});
it("rejects prerelease suffixes in the pinned Android version file", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2-beta.1",
versionCode: 2026060201,
});
expect(() => resolveAndroidVersion(rootDir)).toThrow(
"Expected pinned release version like 2026.6.5",
);
});
it("rejects version codes that do not match the pinned version date", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060301,
});
expect(() => resolveAndroidVersion(rootDir)).toThrow(
"Expected 2026060201 through 2026060299 for version 2026.6.2",
);
});
});
describe("gateway version normalization", () => {
it("keeps stable gateway release values", () => {
expect(normalizeGatewayVersionToPinnedAndroidVersion("2026.6.2")).toBe("2026.6.2");
});
it("strips prerelease suffixes when pinning from gateway version", () => {
expect(normalizeGatewayVersionToPinnedAndroidVersion("2026.6.2-beta.3")).toBe("2026.6.2");
expect(normalizeGatewayVersionToPinnedAndroidVersion("2026.6.2-alpha.1")).toBe("2026.6.2");
});
it("derives the default Play-compatible versionCode from the pinned version", () => {
expect(canonicalAndroidVersionCode("2026.6.2")).toBe(2026060201);
});
it("reads and normalizes the root package version for Android releases", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
packageVersion: "2026.6.5-beta.3",
});
expect(resolveGatewayVersionForAndroidRelease(rootDir)).toEqual({
packageVersion: "2026.6.5-beta.3",
pinnedAndroidVersion: "2026.6.5",
versionCode: 2026060501,
});
});
});
describe("renderAndroidVersionProperties", () => {
it("renders checked-in defaults from the pinned Android version", () => {
const rootDir = writeAndroidFixture({
version: "2026.6.2",
versionCode: 2026060201,
});
const version = resolveAndroidVersion(rootDir);
expect(renderAndroidVersionProperties(version)).toContain(
"OPENCLAW_ANDROID_VERSION_NAME=2026.6.2",
);
expect(renderAndroidVersionProperties(version)).toContain(
"OPENCLAW_ANDROID_VERSION_CODE=2026060201",
);
});
});
+3 -1
View File
@@ -1172,7 +1172,8 @@ describe("scripts/changed-lanes", () => {
it("keeps release metadata commits off the full changed gate", () => {
const result = detectChangedLanes([
"CHANGELOG.md",
"apps/android/app/build.gradle.kts",
"apps/android/Config/Version.properties",
"apps/android/version.json",
"apps/ios/CHANGELOG.md",
"apps/ios/Config/Version.xcconfig",
"apps/ios/fastlane/metadata/en-US/release_notes.txt",
@@ -1197,6 +1198,7 @@ describe("scripts/changed-lanes", () => {
"scripts/generate-npm-shrinkwrap.mjs",
"deps:patches:check",
"release-metadata:check",
"android:version:check",
"ios:version:check",
"config:schema:check",
"config:docs:check",