mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
android: archive release artifacts from pinned version
This commit is contained in:
parent
40eec48caf
commit
91220cbd31
4 changed files with 226 additions and 168 deletions
|
|
@ -32,7 +32,7 @@ cd apps/android
|
|||
./gradlew :app:installPlayDebug
|
||||
./gradlew :app:testPlayDebugUnitTest
|
||||
cd ../..
|
||||
bun run android:bundle:release
|
||||
pnpm android:release:archive
|
||||
```
|
||||
|
||||
Third-party debug flavor:
|
||||
|
|
@ -44,10 +44,21 @@ cd apps/android
|
|||
./gradlew :app:testThirdPartyDebugUnitTest
|
||||
```
|
||||
|
||||
`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles:
|
||||
Android release archives use the pinned version in `apps/android/version.json`. Update it with:
|
||||
|
||||
- Play build: `apps/android/build/release-bundles/openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-third-party-release.aab`
|
||||
```bash
|
||||
pnpm android:version
|
||||
pnpm android:version:check
|
||||
pnpm android:version:pin -- --from-gateway
|
||||
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
|
||||
```
|
||||
|
||||
`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files:
|
||||
|
||||
- Play build: `openclaw-<version>-play-release.aab`
|
||||
- Third-party build: `openclaw-<version>-third-party-release.apk`
|
||||
|
||||
`pnpm android:bundle:release` is an alias for the same archive helper.
|
||||
|
||||
Flavor-specific direct Gradle tasks:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Android release helper that bumps version fields, builds release AAB variants,
|
||||
* verifies signatures, and prints SHA-256 checksums.
|
||||
*/
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
}
|
||||
|
||||
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
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();
|
||||
209
apps/android/scripts/build-release-artifacts.ts
Normal file
209
apps/android/scripts/build-release-artifacts.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Android release helper that builds signed release artifacts from the pinned
|
||||
* version metadata, verifies signatures, and writes SHA-256 checksum files.
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts";
|
||||
|
||||
type ReleaseArtifact = {
|
||||
flavorName: "play" | "third-party";
|
||||
kind: "aab" | "apk";
|
||||
gradleTask: string;
|
||||
sourcePath: string;
|
||||
};
|
||||
|
||||
type CliOptions = {
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const androidDir = join(scriptDir, "..");
|
||||
const rootDir = join(androidDir, "..", "..");
|
||||
const releaseOutputDir = join(androidDir, "build", "release-artifacts");
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let dryRun = false;
|
||||
|
||||
for (const arg of argv) {
|
||||
switch (arg) {
|
||||
case "--dry-run": {
|
||||
dryRun = true;
|
||||
break;
|
||||
}
|
||||
case "-h":
|
||||
case "--help": {
|
||||
console.log(
|
||||
[
|
||||
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--dry-run]",
|
||||
"",
|
||||
"Builds the signed Play AAB and third-party APK from apps/android/version.json.",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dryRun };
|
||||
}
|
||||
|
||||
function releaseArtifacts(versionName: string): ReleaseArtifact[] {
|
||||
return [
|
||||
{
|
||||
flavorName: "play",
|
||||
kind: "aab",
|
||||
gradleTask: ":app:bundlePlayRelease",
|
||||
sourcePath: join(
|
||||
androidDir,
|
||||
"app",
|
||||
"build",
|
||||
"outputs",
|
||||
"bundle",
|
||||
"playRelease",
|
||||
"app-play-release.aab",
|
||||
),
|
||||
},
|
||||
{
|
||||
flavorName: "third-party",
|
||||
kind: "apk",
|
||||
gradleTask: ":app:assembleThirdPartyRelease",
|
||||
sourcePath: join(
|
||||
androidDir,
|
||||
"app",
|
||||
"build",
|
||||
"outputs",
|
||||
"apk",
|
||||
"thirdParty",
|
||||
"release",
|
||||
`openclaw-${versionName}-thirdParty-release.apk`,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function sha256Hex(path: string): Promise<string> {
|
||||
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 writeSha256File(path: string): Promise<string> {
|
||||
const hash = await sha256Hex(path);
|
||||
const checksumPath = `${path}.sha256`;
|
||||
await Bun.write(checksumPath, `${hash} ${basename(path)}\n`);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function verifyAabSignature(path: string): Promise<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
}
|
||||
|
||||
function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null {
|
||||
if (!sdkRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buildToolsDir = join(sdkRoot, "build-tools");
|
||||
if (!existsSync(buildToolsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = readdirSync(buildToolsDir)
|
||||
.toSorted((left, right) => right.localeCompare(left))
|
||||
.map((version) => join(buildToolsDir, version, "apksigner"))
|
||||
.filter((candidate) => existsSync(candidate));
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
async function resolveApkSigner(): Promise<string> {
|
||||
const sdkApkSigner =
|
||||
resolveApkSignerFromSdk(Bun.env.ANDROID_HOME) ??
|
||||
resolveApkSignerFromSdk(Bun.env.ANDROID_SDK_ROOT);
|
||||
if (sdkApkSigner) {
|
||||
return sdkApkSigner;
|
||||
}
|
||||
|
||||
try {
|
||||
return (await $`command -v apksigner`.text()).trim();
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Missing apksigner. Install Android SDK build-tools or put apksigner on PATH.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyApkSignature(path: string): Promise<void> {
|
||||
const apkSigner = await resolveApkSigner();
|
||||
const apkSignerProcess = Bun.spawn([apkSigner, "verify", path], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const exitCode = await apkSignerProcess.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`apksigner verification failed for ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyArtifact(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
const sourceFile = Bun.file(sourcePath);
|
||||
if (!(await sourceFile.exists())) {
|
||||
throw new Error(`Signed release artifact missing at ${sourcePath}`);
|
||||
}
|
||||
|
||||
await Bun.write(destinationPath, sourceFile);
|
||||
}
|
||||
|
||||
async function verifyArtifactSignature(artifact: ReleaseArtifact, outputPath: string): Promise<void> {
|
||||
if (artifact.kind === "aab") {
|
||||
await verifyAabSignature(outputPath);
|
||||
} else {
|
||||
await verifyApkSignature(outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
syncAndroidVersioning({ mode: "check", rootDir });
|
||||
const version = resolveAndroidVersion(rootDir);
|
||||
const artifacts = releaseArtifacts(version.canonicalVersion);
|
||||
|
||||
console.log(`Android versionName: ${version.canonicalVersion}`);
|
||||
console.log(`Android versionCode: ${version.versionCode}`);
|
||||
for (const artifact of artifacts) {
|
||||
console.log(`Release artifact: ${artifact.flavorName} ${artifact.kind}`);
|
||||
console.log(`Gradle task: ${artifact.gradleTask}`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log("Dry run complete. No Gradle tasks were executed.");
|
||||
return;
|
||||
}
|
||||
|
||||
await $`mkdir -p ${releaseOutputDir}`;
|
||||
await $`./gradlew ${artifacts.map((artifact) => artifact.gradleTask)}`.cwd(androidDir);
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const outputPath = join(
|
||||
releaseOutputDir,
|
||||
`openclaw-${version.canonicalVersion}-${artifact.flavorName}-release.${artifact.kind}`,
|
||||
);
|
||||
|
||||
await copyArtifact(artifact.sourcePath, outputPath);
|
||||
await verifyArtifactSignature(artifact, outputPath);
|
||||
const hash = await writeSha256File(outputPath);
|
||||
|
||||
console.log(`Signed ${artifact.kind.toUpperCase()} (${artifact.flavorName}): ${outputPath}`);
|
||||
console.log(`SHA-256 (${artifact.flavorName}): ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
@ -1432,7 +1432,7 @@
|
|||
"scripts": {
|
||||
"android:assemble": "node scripts/run-android-gradle.mjs :app:assemblePlayDebug",
|
||||
"android:assemble:third-party": "node scripts/run-android-gradle.mjs :app:assembleThirdPartyDebug",
|
||||
"android:bundle:release": "bun apps/android/scripts/build-release-aab.ts",
|
||||
"android:bundle:release": "bun apps/android/scripts/build-release-artifacts.ts",
|
||||
"android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat",
|
||||
"android:install": "node scripts/run-android-gradle.mjs :app:installPlayDebug",
|
||||
"android:install:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug",
|
||||
|
|
@ -1440,6 +1440,7 @@
|
|||
"android:lint:android": "node scripts/run-android-gradle.mjs :app:lintDebug",
|
||||
"android:run": "node scripts/run-android-gradle.mjs :app:installPlayDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
|
||||
"android:run:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug -- adb shell am start -n ai.openclaw.app/.MainActivity",
|
||||
"android:release:archive": "bun apps/android/scripts/build-release-artifacts.ts",
|
||||
"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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue