#!/usr/bin/env bun import { $ } from "bun"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const androidDir = join(scriptDir, ".."); const buildGradlePath = join(androidDir, "app", "build.gradle.kts"); const releaseOutputDir = join(androidDir, "build", "release-bundles"); const releaseVariants = [ { flavorName: "play", gradleTask: ":app:bundlePlayRelease", bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"), }, { flavorName: "third-party", gradleTask: ":app:bundleThirdPartyRelease", bundlePath: join( androidDir, "app", "build", "outputs", "bundle", "thirdPartyRelease", "app-thirdParty-release.aab", ), }, ] as const; type VersionState = { versionName: string; versionCode: number; }; type ParsedVersionMatches = { versionNameMatch: RegExpMatchArray; versionCodeMatch: RegExpMatchArray; }; function formatVersionName(date: Date): string { const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); return `${year}.${month}.${day}`; } function formatVersionCodePrefix(date: Date): string { const year = date.getFullYear().toString(); const month = (date.getMonth() + 1).toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, "0"); return `${year}${month}${day}`; } function parseVersionMatches(buildGradleText: string): ParsedVersionMatches { const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/); const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/); if (!versionCodeMatch || !versionNameMatch) { throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`); } return { versionCodeMatch, versionNameMatch }; } function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number { const currentRaw = currentVersionCode.toString(); let nextSuffix = 0; if (currentRaw.startsWith(todayPrefix)) { const suffixRaw = currentRaw.slice(todayPrefix.length); nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1; } if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) { throw new Error( `Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`, ); } return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10); } function resolveNextVersion(buildGradleText: string, date: Date): VersionState { const { versionCodeMatch } = parseVersionMatches(buildGradleText); const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10); if (!Number.isInteger(currentVersionCode)) { throw new Error(`Invalid Android versionCode in ${buildGradlePath}`); } const versionName = formatVersionName(date); const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date)); return { versionName, versionCode }; } function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string { return buildGradleText .replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`) .replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`); } async function sha256Hex(path: string): Promise { const buffer = await Bun.file(path).arrayBuffer(); const digest = await crypto.subtle.digest("SHA-256", buffer); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); } async function verifyBundleSignature(path: string): Promise { await $`jarsigner -verify ${path}`.quiet(); } async function copyBundle(sourcePath: string, destinationPath: string): Promise { const sourceFile = Bun.file(sourcePath); if (!(await sourceFile.exists())) { throw new Error(`Signed bundle missing at ${sourcePath}`); } await Bun.write(destinationPath, sourceFile); } async function main() { const buildGradleFile = Bun.file(buildGradlePath); const originalText = await buildGradleFile.text(); const nextVersion = resolveNextVersion(originalText, new Date()); const updatedText = updateBuildGradleVersions(originalText, nextVersion); if (updatedText === originalText) { throw new Error("Android version bump produced no change"); } console.log(`Android versionName -> ${nextVersion.versionName}`); console.log(`Android versionCode -> ${nextVersion.versionCode}`); await Bun.write(buildGradlePath, updatedText); await $`mkdir -p ${releaseOutputDir}`; try { await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir); } catch (error) { await Bun.write(buildGradlePath, originalText); throw error; } for (const variant of releaseVariants) { const outputPath = join( releaseOutputDir, `openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`, ); await copyBundle(variant.bundlePath, outputPath); await verifyBundleSignature(outputPath); const hash = await sha256Hex(outputPath); console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`); console.log(`SHA-256 (${variant.flavorName}): ${hash}`); } } await main();