Files
openclaw/scripts/android-release-signing.mjs
T
Peter Steinberger c70aee247e refactor(scripts): migrate JavaScript tools to TypeScript (#121005)
* refactor(scripts): migrate JavaScript tools to TypeScript

* fix(ci): keep changed-scope preflight zero-install

* fix(ci): preserve zero-install script owners

* fix(ci): complete script migration follow-through

* fix(release): keep stable closeout zero-install

* fix(scripts): preserve standalone execution boundaries

* fix(scripts): repair standalone loader boundaries

* fix(scripts): normalize gateway observation ids

* fix(scripts): keep Docker packager standalone

* test(scripts): preserve rebase cleanup helpers

* test(sessions): use tracked temp directory
2026-08-09 07:21:35 -07:00

487 lines
17 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { runAndroidSigningCommandSync } from "./lib/android-release-signing-process.mjs";
import { parseFlagArgs, stringFlag } from "./lib/arg-utils.runtime.mjs";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
const rootDir = resolveRepoRoot(import.meta.url);
const defaultManifestPath = path.join(rootDir, "apps", "android", "Config", "ReleaseSigning.json");
const requiredPropertyNames = [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD",
];
const sourceRequiredPropertyNames = requiredPropertyNames.filter(
(name) => name !== "OPENCLAW_ANDROID_STORE_FILE",
);
function usage() {
process.stdout.write(`Usage:
scripts/android-release-signing.mjs --mode plan
scripts/android-release-signing.mjs --mode check
scripts/android-release-signing.mjs --mode materialize
scripts/android-release-signing.mjs --mode sync-pull
scripts/android-release-signing.mjs --mode sync-push --keystore PATH --properties PATH
Options:
--manifest PATH Defaults to apps/android/Config/ReleaseSigning.json.
--workspace PATH Defaults to <materializedRoot>/apps-signing.
--materialized-dir PATH Defaults to materializedRoot from the manifest.
--keystore PATH Upload keystore source for --mode sync-push.
--properties PATH Signing properties source for --mode sync-push.
sync-pull and sync-push use MATCH_PASSWORD to decrypt/encrypt Android release
signing assets in the shared apps-signing repository.
`);
}
function parseArgs(argv) {
const options = {
mode: "",
manifestPath: defaultManifestPath,
workspace: "",
materializedDir: "",
keystorePath: process.env.OPENCLAW_ANDROID_UPLOAD_KEYSTORE || "",
propertiesPath: process.env.OPENCLAW_ANDROID_SIGNING_PROPERTIES || "",
};
const helpIndex = argv.findIndex((arg) => arg === "-h" || arg === "--help");
parseFlagArgs(
helpIndex === -1 ? argv : argv.slice(0, helpIndex),
options,
[
stringFlag("--mode", "mode", {
allowInline: false,
missingValueMessage: "Missing value for --mode.",
rejectShortOptions: true,
repeatable: true,
}),
...[
["--manifest", "manifestPath"],
["--workspace", "workspace"],
["--materialized-dir", "materializedDir"],
["--keystore", "keystorePath"],
["--properties", "propertiesPath"],
].map(([flag, key]) =>
stringFlag(flag, key, {
allowInline: false,
missingValueMessage: `Missing value for ${flag}.`,
rejectShortOptions: true,
repeatable: true,
transform: path.resolve,
}),
),
],
{
ignoreDoubleDash: false,
onUnhandledArg(arg) {
throw new Error(`Unknown argument: ${arg}`);
},
},
);
if (helpIndex !== -1) {
usage();
process.exit(0);
}
if (!options.mode) {
throw new Error("Missing required --mode.");
}
return options;
}
function requireString(value, key) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Android release signing manifest missing ${key}.`);
}
return value.trim();
}
// This release entrypoint runs before dependencies are installed.
function asRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function requireGradlePropertyNames(value) {
if (
!Array.isArray(value) ||
!value.every((name) => typeof name === "string") ||
value.length !== requiredPropertyNames.length ||
!requiredPropertyNames.every((name) => value.includes(name))
) {
throw new Error(
`Android release signing manifest must list Gradle properties: ${requiredPropertyNames.join(", ")}.`,
);
}
return value;
}
function readManifest(manifestPath) {
const parsed = asRecord(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
const manifest = {
signingRepo: requireString(parsed.signingRepo, "signingRepo"),
signingBranch: requireString(parsed.signingBranch, "signingBranch"),
assetPath: requireString(parsed.assetPath, "assetPath"),
uploadKeystoreEncryptedFile: requireString(
parsed.uploadKeystoreEncryptedFile,
"uploadKeystoreEncryptedFile",
),
gradlePropertiesEncryptedFile: requireString(
parsed.gradlePropertiesEncryptedFile,
"gradlePropertiesEncryptedFile",
),
apkCertificateSha256: requireString(parsed.apkCertificateSha256, "apkCertificateSha256"),
materializedRoot: requireString(parsed.materializedRoot, "materializedRoot"),
gradlePropertyNames: requireGradlePropertyNames(parsed.gradlePropertyNames),
};
if (!/^[a-f0-9]{64}$/u.test(manifest.apkCertificateSha256)) {
throw new Error(
"Android release signing manifest apkCertificateSha256 must be 64 lowercase hex digits.",
);
}
return manifest;
}
function relativePath(filePath) {
const relative = path.relative(rootDir, filePath);
return relative && !relative.startsWith("..") ? relative : filePath;
}
function resolveMaterializedDir(manifest, options) {
return options.materializedDir || path.resolve(rootDir, manifest.materializedRoot);
}
function resolveWorkspace(manifest, options) {
return options.workspace || path.join(resolveMaterializedDir(manifest, options), "apps-signing");
}
function assertWorkspaceInsideMaterialized(workspace, materializedDir) {
const resolvedWorkspace = path.resolve(workspace);
const resolvedMaterializedDir = path.resolve(materializedDir);
const relative = path.relative(resolvedMaterializedDir, resolvedWorkspace);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(
`Android signing workspace must be inside ${relativePath(resolvedMaterializedDir)}.`,
);
}
}
function assetDir(workspace, manifest) {
return path.join(workspace, manifest.assetPath);
}
function encryptedKeystorePath(workspace, manifest) {
return path.join(assetDir(workspace, manifest), manifest.uploadKeystoreEncryptedFile);
}
function encryptedPropertiesPath(workspace, manifest) {
return path.join(assetDir(workspace, manifest), manifest.gradlePropertiesEncryptedFile);
}
function materializedKeystorePath(materializedDir) {
return path.join(materializedDir, "upload-keystore.jks");
}
function materializedPropertiesPath(materializedDir) {
return path.join(materializedDir, "gradle.properties");
}
function requireMatchPassword() {
if (!process.env.MATCH_PASSWORD || process.env.MATCH_PASSWORD.trim() === "") {
throw new Error("MATCH_PASSWORD is required for Android release signing sync.");
}
}
function run(command, args, options = {}) {
runAndroidSigningCommandSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
stdio: options.stdio || "pipe",
});
}
function runText(command, args, options = {}) {
return runAndroidSigningCommandSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function cloneSigningRepo(manifest, workspace, materializedDir) {
assertWorkspaceInsideMaterialized(workspace, materializedDir);
fs.rmSync(workspace, { recursive: true, force: true });
fs.mkdirSync(path.dirname(workspace), { recursive: true });
run("git", ["clone", "--branch", manifest.signingBranch, manifest.signingRepo, workspace]);
}
function opensslCrypt({ decrypt, inputPath, outputPath }) {
requireMatchPassword();
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (decrypt) {
fs.rmSync(outputPath, { force: true });
}
const args = [
"enc",
"-aes-256-cbc",
"-pbkdf2",
"-md",
"sha256",
...(decrypt ? ["-d"] : ["-salt"]),
"-in",
inputPath,
"-out",
outputPath,
"-pass",
"env:MATCH_PASSWORD",
];
const previousUmask = decrypt ? process.umask(0o077) : undefined;
try {
run("openssl", args);
} finally {
if (previousUmask !== undefined) {
process.umask(previousUmask);
}
}
if (decrypt) {
fs.chmodSync(outputPath, 0o600);
}
}
function readProperties(filePath) {
const properties = new Map();
for (const rawLine of fs.readFileSync(filePath, "utf8").split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separator = line.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid signing properties line in ${relativePath(filePath)}.`);
}
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
if (!key || !value) {
throw new Error(`Invalid empty signing property in ${relativePath(filePath)}.`);
}
properties.set(key, value);
}
return properties;
}
function requireProperties(properties, names, filePath) {
const missing = names.filter((name) => !properties.get(name));
if (missing.length > 0) {
throw new Error(
`${relativePath(filePath)} is missing Android signing properties: ${missing.join(", ")}.`,
);
}
}
function writeMaterializedProperties(materializedDir, sourceProperties) {
const keystorePath = materializedKeystorePath(materializedDir);
const propertiesPath = materializedPropertiesPath(materializedDir);
const tempPath = `${propertiesPath}.${process.pid}.tmp`;
const properties = new Map(sourceProperties);
properties.set("OPENCLAW_ANDROID_STORE_FILE", keystorePath);
requireProperties(properties, requiredPropertyNames, propertiesPath);
const content = [
"# Generated by scripts/android-release-signing.mjs.",
"# Contains decrypted Android release signing values. Do not commit.",
...requiredPropertyNames.map((name) => `${name}=${properties.get(name)}`),
"",
].join("\n");
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.chmodSync(tempPath, 0o600);
fs.renameSync(tempPath, propertiesPath);
fs.chmodSync(propertiesPath, 0o600);
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function validateMaterializedSigning(materializedDir) {
const keystorePath = materializedKeystorePath(materializedDir);
const propertiesPath = materializedPropertiesPath(materializedDir);
if (!fs.existsSync(keystorePath) || fs.statSync(keystorePath).size === 0) {
throw new Error(
`Missing materialized Android upload keystore at ${relativePath(keystorePath)}.`,
);
}
if (!fs.existsSync(propertiesPath)) {
throw new Error(
`Missing materialized Android signing properties at ${relativePath(propertiesPath)}.`,
);
}
const properties = readProperties(propertiesPath);
requireProperties(properties, requiredPropertyNames, propertiesPath);
if (properties.get("OPENCLAW_ANDROID_STORE_FILE") !== keystorePath) {
throw new Error(
`${relativePath(propertiesPath)} must point OPENCLAW_ANDROID_STORE_FILE at ${relativePath(keystorePath)}.`,
);
}
}
function writePlan(manifest, options) {
const materializedDir = resolveMaterializedDir(manifest, options);
process.stdout.write(`Android release signing plan
Signing repo: ${manifest.signingRepo}
Signing branch: ${manifest.signingBranch}
Signing assets: ${manifest.assetPath}
Encrypted upload keystore: ${manifest.uploadKeystoreEncryptedFile}
Encrypted Gradle properties: ${manifest.gradlePropertiesEncryptedFile}
Pinned APK certificate SHA-256: ${manifest.apkCertificateSha256}
Materialized output: ${relativePath(materializedDir)}
Gradle bridge: Fastlane exports ORG_GRADLE_PROJECT_* values from the materialized properties file.
`);
}
function writeSigningRepoManifest(workspace, manifest) {
const signingManifestPath = path.join(assetDir(workspace, manifest), "manifest.json");
const signingManifest = {
version: 1,
assetPath: manifest.assetPath,
uploadKeystoreEncryptedFile: manifest.uploadKeystoreEncryptedFile,
gradlePropertiesEncryptedFile: manifest.gradlePropertiesEncryptedFile,
apkCertificateSha256: manifest.apkCertificateSha256,
gradlePropertyNames: requiredPropertyNames,
};
fs.writeFileSync(signingManifestPath, `${JSON.stringify(signingManifest, null, 2)}\n`);
}
function materialize(manifest, options) {
const workspace = resolveWorkspace(manifest, options);
const materializedDir = resolveMaterializedDir(manifest, options);
const tempPropertiesPath = path.join(materializedDir, ".gradle.properties.decrypted.tmp");
assertWorkspaceInsideMaterialized(workspace, materializedDir);
if (!fs.existsSync(encryptedKeystorePath(workspace, manifest))) {
throw new Error(
`Missing encrypted Android upload keystore in signing repo at ${manifest.assetPath}/${manifest.uploadKeystoreEncryptedFile}.`,
);
}
if (!fs.existsSync(encryptedPropertiesPath(workspace, manifest))) {
throw new Error(
`Missing encrypted Android signing properties in signing repo at ${manifest.assetPath}/${manifest.gradlePropertiesEncryptedFile}.`,
);
}
fs.mkdirSync(materializedDir, { recursive: true });
opensslCrypt({
decrypt: true,
inputPath: encryptedKeystorePath(workspace, manifest),
outputPath: materializedKeystorePath(materializedDir),
});
try {
opensslCrypt({
decrypt: true,
inputPath: encryptedPropertiesPath(workspace, manifest),
outputPath: tempPropertiesPath,
});
const properties = readProperties(tempPropertiesPath);
requireProperties(properties, sourceRequiredPropertyNames, tempPropertiesPath);
writeMaterializedProperties(materializedDir, properties);
} finally {
fs.rmSync(tempPropertiesPath, { force: true });
}
validateMaterializedSigning(materializedDir);
process.stdout.write(
`Materialized Android release signing assets in ${relativePath(materializedDir)}.\n`,
);
}
function syncPull(manifest, options) {
const workspace = resolveWorkspace(manifest, options);
const materializedDir = resolveMaterializedDir(manifest, options);
cloneSigningRepo(manifest, workspace, materializedDir);
materialize(manifest, options);
}
function requirePushSources(options) {
if (!options.keystorePath) {
throw new Error(
"Missing Android upload keystore source. Pass --keystore or set OPENCLAW_ANDROID_UPLOAD_KEYSTORE.",
);
}
if (!options.propertiesPath) {
throw new Error(
"Missing Android signing properties source. Pass --properties or set OPENCLAW_ANDROID_SIGNING_PROPERTIES.",
);
}
if (!fs.existsSync(options.keystorePath) || fs.statSync(options.keystorePath).size === 0) {
throw new Error(
`Android upload keystore source is missing or empty: ${relativePath(options.keystorePath)}.`,
);
}
if (!fs.existsSync(options.propertiesPath)) {
throw new Error(
`Android signing properties source is missing: ${relativePath(options.propertiesPath)}.`,
);
}
const properties = readProperties(options.propertiesPath);
requireProperties(properties, sourceRequiredPropertyNames, options.propertiesPath);
}
function syncPush(manifest, options) {
requireMatchPassword();
requirePushSources(options);
const workspace = resolveWorkspace(manifest, options);
cloneSigningRepo(manifest, workspace, resolveMaterializedDir(manifest, options));
fs.mkdirSync(assetDir(workspace, manifest), { recursive: true });
opensslCrypt({
decrypt: false,
inputPath: options.keystorePath,
outputPath: encryptedKeystorePath(workspace, manifest),
});
opensslCrypt({
decrypt: false,
inputPath: options.propertiesPath,
outputPath: encryptedPropertiesPath(workspace, manifest),
});
writeSigningRepoManifest(workspace, manifest);
run("git", ["add", manifest.assetPath], { cwd: workspace });
const status = runText("git", ["status", "--porcelain"], { cwd: workspace }).trim();
if (!status) {
process.stdout.write("Android release signing assets were already up to date.\n");
return;
}
run("git", ["commit", "-m", "Update Android release signing assets"], { cwd: workspace });
run("git", ["push", "origin", manifest.signingBranch], { cwd: workspace });
process.stdout.write("Pushed encrypted Android release signing assets.\n");
}
try {
const options = parseArgs(process.argv.slice(2));
const manifest = readManifest(options.manifestPath);
if (options.mode === "plan") {
writePlan(manifest, options);
} else if (options.mode === "check") {
validateMaterializedSigning(resolveMaterializedDir(manifest, options));
process.stdout.write("Android release signing materialization is valid.\n");
} else if (options.mode === "materialize") {
materialize(manifest, options);
} else if (options.mode === "sync-pull") {
syncPull(manifest, options);
} else if (options.mode === "sync-push") {
syncPush(manifest, options);
} else {
throw new Error(`Unknown mode: ${options.mode}`);
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}