Files
openclaw/scripts/android-release-signing.mjs

458 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(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 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 || "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--mode") {
options.mode = readOptionValue(argv, index, arg);
index += 1;
} else if (arg === "--manifest") {
options.manifestPath = path.resolve(readOptionValue(argv, index, arg));
index += 1;
} else if (arg === "--workspace") {
options.workspace = path.resolve(readOptionValue(argv, index, arg));
index += 1;
} else if (arg === "--materialized-dir") {
options.materializedDir = path.resolve(readOptionValue(argv, index, arg));
index += 1;
} else if (arg === "--keystore") {
options.keystorePath = path.resolve(readOptionValue(argv, index, arg));
index += 1;
} else if (arg === "--properties") {
options.propertiesPath = path.resolve(readOptionValue(argv, index, arg));
index += 1;
} else if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.mode) {
throw new Error("Missing required --mode.");
}
return options;
}
function readOptionValue(argv, index, option) {
const value = argv[index + 1] ?? "";
if (!value || value.startsWith("-")) {
throw new Error(`Missing value for ${option}.`);
}
return value;
}
function requireString(value, key) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`Android release signing manifest missing ${key}.`);
}
return value.trim();
}
function readManifest(manifestPath) {
const parsed = 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",
),
materializedRoot: requireString(parsed.materializedRoot, "materializedRoot"),
gradlePropertyNames: parsed.gradlePropertyNames,
};
if (
!Array.isArray(manifest.gradlePropertyNames) ||
manifest.gradlePropertyNames.length !== requiredPropertyNames.length ||
!requiredPropertyNames.every((name) => manifest.gradlePropertyNames.includes(name))
) {
throw new Error(
`Android release signing manifest must list Gradle properties: ${requiredPropertyNames.join(", ")}.`,
);
}
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 = {}) {
execFileSync(command, args, {
cwd: options.cwd,
env: options.env || process.env,
stdio: options.stdio || "pipe",
});
}
function runText(command, args, options = {}) {
return execFileSync(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}
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,
gradlePropertyNames: requiredPropertyNames,
};
fs.writeFileSync(signingManifestPath, `${JSON.stringify(signingManifest, null, 2)}\n`);
}
function syncPull(manifest, options) {
const workspace = resolveWorkspace(manifest, options);
const materializedDir = resolveMaterializedDir(manifest, options);
const tempPropertiesPath = path.join(materializedDir, ".gradle.properties.decrypted.tmp");
cloneSigningRepo(manifest, 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 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 === "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.message}\n`);
process.exit(1);
}