mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 15:39:34 +00:00
458 lines
16 KiB
JavaScript
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);
|
|
}
|