diff --git a/apps/android/Config/Version.properties b/apps/android/Config/Version.properties new file mode 100644 index 000000000000..593f9633e836 --- /dev/null +++ b/apps/android/Config/Version.properties @@ -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 diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 62d01d91b3f9..4dd7af5bea1d 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -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") diff --git a/apps/android/version.json b/apps/android/version.json new file mode 100644 index 000000000000..19c0bf43fe8b --- /dev/null +++ b/apps/android/version.json @@ -0,0 +1,4 @@ +{ + "version": "2026.6.2", + "versionCode": 2026060201 +} diff --git a/package.json b/package.json index feed4bd4196f..2d6e23a2a328 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/android-pin-version.ts b/scripts/android-pin-version.ts new file mode 100644 index 000000000000..024624954741 --- /dev/null +++ b/scripts/android-pin-version.ts @@ -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 ) [--version-code ] [--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 ."); + } + + 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 { + 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); + } +} diff --git a/scripts/android-sync-versioning.ts b/scripts/android-sync-versioning.ts new file mode 100644 index 000000000000..ba942b2fb8c5 --- /dev/null +++ b/scripts/android-sync-versioning.ts @@ -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`, + ); +} diff --git a/scripts/android-version.ts b/scripts/android-version.ts new file mode 100644 index 000000000000..255a451bfead --- /dev/null +++ b/scripts/android-version.ts @@ -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`); +} diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index 67e51dcd4109..99027c92cc17 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -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", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 7db39d1903f5..706e434cfde4 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -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"]); diff --git a/scripts/check-release-metadata-only.mjs b/scripts/check-release-metadata-only.mjs index a78c7637e55a..57e6ac3e7f7a 100644 --- a/scripts/check-release-metadata-only.mjs +++ b/scripts/check-release-metadata-only.mjs @@ -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", diff --git a/scripts/lib/android-version.ts b/scripts/lib/android-version.ts new file mode 100644 index 000000000000..a976a22360ee --- /dev/null +++ b/scripts/lib/android-version.ts @@ -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 }; +} diff --git a/test/scripts/android-pin-version.test.ts b/test/scripts/android-pin-version.test.ts new file mode 100644 index 000000000000..e929e14a7d5f --- /dev/null +++ b/test/scripts/android-pin-version.test.ts @@ -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 ", + ); + expect(() => parseArgs(["--from-gateway", "--version", "2026.6.5"])).toThrow( + "Choose exactly one of --from-gateway or --version ", + ); + }); +}); + +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"); + }); +}); diff --git a/test/scripts/android-version.test-support.ts b/test/scripts/android-version.test-support.ts new file mode 100644 index 000000000000..110264da95e6 --- /dev/null +++ b/test/scripts/android-version.test-support.ts @@ -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; +} diff --git a/test/scripts/android-version.test.ts b/test/scripts/android-version.test.ts new file mode 100644 index 000000000000..2b4f000a52a8 --- /dev/null +++ b/test/scripts/android-version.test.ts @@ -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", + ); + }); +}); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index aa8855e387ce..61fbff13c167 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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",