mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
647 lines
23 KiB
JavaScript
647 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createAndroidFirebaseDistribution } from "./lib/android-firebase-distribution.mjs";
|
|
|
|
function run(command, args, cwd, options = {}) {
|
|
const output = execFileSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "inherit"],
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
...options,
|
|
});
|
|
return output?.trim() ?? "";
|
|
}
|
|
|
|
function git(root, ...args) {
|
|
return run("git", args, root);
|
|
}
|
|
|
|
function clean(root) {
|
|
if (git(root, "status", "--porcelain", "--untracked-files=all")) {
|
|
throw new Error(
|
|
"Release commands require a clean checkout; commit or move your changes first.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function mainSha(root) {
|
|
git(root, "fetch", "--no-tags", "origin", "refs/heads/main");
|
|
return git(root, "rev-parse", "FETCH_HEAD");
|
|
}
|
|
|
|
function uploadedRef(root, platform, plan, planPath) {
|
|
if (platform === "android") {
|
|
const [sha, ref] = run(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
"./scripts/tsx.mjs",
|
|
"scripts/mobile-release-ref.ts",
|
|
"resolve",
|
|
"--plan",
|
|
planPath,
|
|
"--root",
|
|
root,
|
|
],
|
|
root,
|
|
).split(/\s+/);
|
|
if (sha !== plan.sourceSha || !ref) {
|
|
throw new Error(
|
|
"Android uploaded source ref does not match the saved plan. Inspect the store outcome and perform record-only recovery; do not upload again blindly.",
|
|
);
|
|
}
|
|
return ref;
|
|
}
|
|
const version = platform === "ios" ? plan.appStoreVersion : plan.version;
|
|
const build = platform === "ios" ? plan.buildNumber : plan.versionCode;
|
|
if (!/^20\d{2}\.[1-9]\d?\.[1-9]\d*$/.test(version) || !Number.isSafeInteger(build) || build < 1) {
|
|
throw new Error("Saved release plan has an invalid store identity.");
|
|
}
|
|
const ref = `refs/openclaw/mobile-releases/${platform}/${version}-${build}`;
|
|
const row = git(root, "ls-remote", "--refs", "origin", ref).split(/\s+/);
|
|
if (row[0] !== plan.sourceSha || row[1] !== ref) {
|
|
throw new Error(
|
|
`No matching upload record for ${ref}. Inspect the store outcome and use the record-only recovery command before continuing; do not upload again blindly.`,
|
|
);
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
function bridgeLocalTools(root, source, platform) {
|
|
// Reuse installed dependencies; release source stays on the selected main commit.
|
|
const manifests = git(root, "ls-files", "package.json", "**/package.json").split("\n");
|
|
for (const manifest of manifests) {
|
|
const relative = path.join(path.dirname(manifest), "node_modules");
|
|
const installed = path.join(root, relative);
|
|
const target = path.join(source, relative);
|
|
if (fs.existsSync(installed) && !fs.existsSync(target)) {
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.symlinkSync(installed, target, "dir");
|
|
}
|
|
}
|
|
for (const relative of [
|
|
`apps/${platform}/.bundle`,
|
|
`apps/${platform}/fastlane/.env`,
|
|
`apps/${platform}/fastlane/.env.default`,
|
|
...(platform === "android"
|
|
? ["apps/android/local.properties", "apps/android/build/release-signing"]
|
|
: []),
|
|
]) {
|
|
const local = path.join(root, relative);
|
|
if (fs.existsSync(local)) {
|
|
fs.cpSync(local, path.join(source, relative), { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectArtifacts(source, recovery, platform) {
|
|
const groups = [
|
|
{
|
|
source: `apps/${platform}/build/${platform === "ios" ? "app-store" : "release-artifacts"}`,
|
|
destination: "artifacts",
|
|
filename: /\.(ipa|aab|apk|sha256)$/,
|
|
},
|
|
...(platform === "ios"
|
|
? [
|
|
{
|
|
source: "apps/ios/fastlane/screenshots/en-US",
|
|
destination: "screenshot-diagnostics/screenshots",
|
|
filename: /\.png$/,
|
|
},
|
|
{
|
|
source: "apps/ios/build/SnapshotTestResults",
|
|
destination: "screenshot-diagnostics",
|
|
filename: /^capture-attempts\.json$/,
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
// Raw Xcode logs and xcresults can contain environment or pairing credentials.
|
|
for (const group of groups) {
|
|
const directory = path.join(source, group.source);
|
|
if (!fs.existsSync(directory)) {
|
|
continue;
|
|
}
|
|
for (const file of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
if (!file.isFile() || !group.filename.test(file.name)) {
|
|
continue;
|
|
}
|
|
const destination = path.join(recovery, group.destination);
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
fs.copyFileSync(path.join(directory, file.name), path.join(destination, file.name));
|
|
}
|
|
}
|
|
}
|
|
|
|
function uploadArgs(plan) {
|
|
return [
|
|
...(plan.destination === "testflight" ? ["--destination", "testflight"] : []),
|
|
"--version",
|
|
plan.gatewayVersion,
|
|
"--revision",
|
|
String(plan.appStoreRevision),
|
|
"--build-number",
|
|
String(plan.buildNumber),
|
|
];
|
|
}
|
|
|
|
function releaseEnvironment(platform, recovery, sourceSha) {
|
|
return {
|
|
...process.env,
|
|
GIT_COMMIT: sourceSha,
|
|
GIT_SHA: sourceSha,
|
|
OPENCLAW_MOBILE_RELEASE_NOTES: path.join(recovery, "release-notes.json"),
|
|
...(platform === "android"
|
|
? { OPENCLAW_ANDROID_RELEASE_PLAN: path.join(recovery, "android-plan.json") }
|
|
: {
|
|
OPENCLAW_IOS_RELEASE_PLAN: path.join(recovery, "ios-plan.json"),
|
|
OPENCLAW_IOS_RELEASE_SOURCE_ROOT: path.join(recovery, "source"),
|
|
OPENCLAW_TESTFLIGHT_RESULT_FILE: path.join(recovery, "testflight-result.json"),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function retainSummary(artifactPath) {
|
|
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
|
|
const summary = artifact.entries
|
|
.map((entry) => `### ${entry.audience} release notes\n\n${entry.text}\n`)
|
|
.join("\n");
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
}
|
|
}
|
|
|
|
function testflightNonUploadOutcome(root, plan, sourceSha) {
|
|
const facts = plan.testflight;
|
|
if (!facts?.groupId || !Array.isArray(facts.builds)) {
|
|
throw new Error("The TestFlight plan is missing its external-group and build preflight.");
|
|
}
|
|
const refs = new Map(
|
|
git(root, "ls-remote", "--refs", "origin", "refs/openclaw/mobile-releases/ios/*")
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((row) => {
|
|
const [sha, ref] = row.split(/\s+/);
|
|
return [ref, sha];
|
|
}),
|
|
);
|
|
const sameSourceBuilds = facts.builds.filter((build) => {
|
|
const ref = `refs/openclaw/mobile-releases/ios/${build.shortVersion}-${build.buildNumber}`;
|
|
return refs.get(ref) === sourceSha;
|
|
});
|
|
for (const build of sameSourceBuilds) {
|
|
if (
|
|
build.configured &&
|
|
[
|
|
"WAITING_FOR_BETA_REVIEW",
|
|
"IN_BETA_REVIEW",
|
|
"BETA_APPROVED",
|
|
"READY_FOR_BETA_TESTING",
|
|
"IN_BETA_TESTING",
|
|
].includes(build.externalState)
|
|
) {
|
|
return { outcome: "unchanged", groupId: facts.groupId, build, sourceSha };
|
|
}
|
|
}
|
|
if (facts.pendingBuild) {
|
|
return {
|
|
outcome: "deferred-review",
|
|
groupId: facts.groupId,
|
|
build: facts.pendingBuild,
|
|
sourceSha,
|
|
};
|
|
}
|
|
const pendingUpload = plan.buildUploads?.find((upload) =>
|
|
["AWAITING_UPLOAD", "PROCESSING"].includes(upload.state),
|
|
);
|
|
if (pendingUpload) {
|
|
return {
|
|
outcome: "deferred-processing",
|
|
groupId: facts.groupId,
|
|
upload: pendingUpload,
|
|
sourceSha,
|
|
};
|
|
}
|
|
const storeBuild = sameSourceBuilds.find(
|
|
(build) =>
|
|
build.selectedForAppStore === true &&
|
|
build.hasBetaNotes === false &&
|
|
build.externalState === "READY_FOR_BETA_SUBMISSION",
|
|
);
|
|
if (storeBuild) {
|
|
return { outcome: "stage-existing", build: storeBuild };
|
|
}
|
|
if (sameSourceBuilds.length) {
|
|
const build = sameSourceBuilds[0];
|
|
throw new Error(
|
|
`This source already uploaded iOS build ${build.shortVersion} (${build.buildNumber}) in state ${build.externalState}. Recover its saved destination with mobile-release.mjs stage; do not upload it again.`,
|
|
);
|
|
}
|
|
for (const build of facts.builds) {
|
|
if (!refs.has(`refs/openclaw/mobile-releases/ios/${build.shortVersion}-${build.buildNumber}`)) {
|
|
throw new Error(
|
|
`TestFlight build ${build.shortVersion} (${build.buildNumber}) has no recorded source. Reconcile that upload before creating another build.`,
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function prepareAndUpload(root, platform, recovery, releaseArgs, destination) {
|
|
clean(root);
|
|
const isGithubActions = process.env.GITHUB_ACTIONS === "true";
|
|
if (isGithubActions && process.env.GITHUB_RUN_ATTEMPT !== "1") {
|
|
throw new Error(
|
|
"Do not rerun the upload job. Inspect the original store outcome before starting a new release; iOS metadata staging has a separate recovery command.",
|
|
);
|
|
}
|
|
const sourceSha = git(root, "rev-parse", "HEAD");
|
|
if (isGithubActions) {
|
|
const eventAllowed =
|
|
process.env.GITHUB_EVENT_NAME === "workflow_dispatch" ||
|
|
(((platform === "ios" && destination === "testflight") ||
|
|
(platform === "android" && destination === "internal")) &&
|
|
process.env.GITHUB_EVENT_NAME === "schedule");
|
|
if (
|
|
!eventAllowed ||
|
|
process.env.GITHUB_REPOSITORY !== "openclaw/openclaw" ||
|
|
process.env.GITHUB_REF !== "refs/heads/main" ||
|
|
sourceSha !== process.env.GITHUB_SHA
|
|
) {
|
|
throw new Error(
|
|
"CI releases require the exact workflow_dispatch commit on openclaw/openclaw main; scheduled events are accepted only for iOS TestFlight or Android internal testing.",
|
|
);
|
|
}
|
|
} else if (git(root, "branch", "--show-current") !== "main") {
|
|
throw new Error(
|
|
"Start a release from a clean, current main checkout. This command never switches your branch.",
|
|
);
|
|
}
|
|
const currentMain = mainSha(root);
|
|
if (isGithubActions) {
|
|
git(root, "merge-base", "--is-ancestor", sourceSha, currentMain);
|
|
} else if (sourceSha !== currentMain) {
|
|
throw new Error("Local main differs from origin/main. Update it before starting the release.");
|
|
}
|
|
if (destination !== "testflight" && !process.env.OPENAI_API_KEY?.trim()) {
|
|
throw new Error("OPENAI_API_KEY is required to prepare store release notes.");
|
|
}
|
|
if (fs.existsSync(recovery) && fs.readdirSync(recovery).length) {
|
|
throw new Error(
|
|
"This recovery directory already contains an attempt. Inspect its outcome or use a new directory; never repeat an upload blindly.",
|
|
);
|
|
}
|
|
const firebase =
|
|
platform === "android" && destination === "internal"
|
|
? createAndroidFirebaseDistribution({ env: process.env })
|
|
: null;
|
|
await firebase?.preflight();
|
|
fs.mkdirSync(recovery, { recursive: true, mode: 0o700 });
|
|
const source = path.join(recovery, "source");
|
|
const planPath = path.join(recovery, `${platform}-plan.json`);
|
|
const notesPath = path.join(recovery, "release-notes.json");
|
|
git(root, "worktree", "add", "--detach", source, sourceSha);
|
|
let completed = false;
|
|
try {
|
|
bridgeLocalTools(root, source, platform);
|
|
let plan;
|
|
if (platform === "ios") {
|
|
plan = JSON.parse(
|
|
run(
|
|
"/bin/bash",
|
|
[
|
|
"scripts/ios-release-plan.sh",
|
|
"--json",
|
|
...(destination === "testflight" ? ["--destination", destination] : []),
|
|
...releaseArgs,
|
|
],
|
|
source,
|
|
),
|
|
);
|
|
if ((plan.destination ?? "app-store") !== destination) {
|
|
throw new Error("The planned iOS destination does not match the requested destination.");
|
|
}
|
|
} else {
|
|
run(
|
|
"/bin/bash",
|
|
[
|
|
"-c",
|
|
'source scripts/lib/android-fastlane.sh; cd apps/android; run_android_fastlane android release_plan "output_path:$1"',
|
|
"release-plan",
|
|
planPath,
|
|
],
|
|
source,
|
|
{ stdio: "inherit" },
|
|
);
|
|
plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
|
|
plan.destination = destination;
|
|
}
|
|
plan.sourceSha = sourceSha;
|
|
if (firebase) {
|
|
plan.firebase = firebase.configuration;
|
|
}
|
|
fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
|
|
let stageExisting = false;
|
|
if (destination === "testflight") {
|
|
const outcome = testflightNonUploadOutcome(root, plan, sourceSha);
|
|
if (outcome?.outcome === "stage-existing") {
|
|
const build = outcome.build;
|
|
const buildNumber = Number(build.buildNumber);
|
|
if (
|
|
build.shortVersion !== plan.appStoreVersion ||
|
|
!/^[1-9]\d*$/.test(build.buildNumber) ||
|
|
!Number.isSafeInteger(buildNumber) ||
|
|
!build.id
|
|
) {
|
|
throw new Error(
|
|
"The existing App Store build does not match the planned TestFlight train.",
|
|
);
|
|
}
|
|
if (releaseArgs.includes("--build-number")) {
|
|
throw new Error(
|
|
"This source already has an App Store build. Omit --build-number to distribute that build through TestFlight without another upload.",
|
|
);
|
|
}
|
|
plan = {
|
|
...plan,
|
|
buildNumber,
|
|
decision: "stage-existing",
|
|
testflight: { ...plan.testflight, existingBuildId: build.id },
|
|
};
|
|
fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
|
|
stageExisting = true;
|
|
} else if (outcome) {
|
|
fs.writeFileSync(
|
|
path.join(recovery, "testflight-result.json"),
|
|
`${JSON.stringify(outcome, null, 2)}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const summary = `TestFlight: ${outcome.outcome}. No new build uploaded.\n`;
|
|
console.log(summary.trim());
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
}
|
|
completed = true;
|
|
return;
|
|
}
|
|
if (!process.env.OPENAI_API_KEY?.trim()) {
|
|
throw new Error("OPENAI_API_KEY is required to prepare TestFlight notes.");
|
|
}
|
|
}
|
|
run(
|
|
process.execPath,
|
|
[
|
|
"--import",
|
|
"./scripts/tsx.mjs",
|
|
"scripts/mobile-release-notes.ts",
|
|
"generate",
|
|
"--platform",
|
|
platform,
|
|
"--plan",
|
|
planPath,
|
|
"--output",
|
|
notesPath,
|
|
],
|
|
source,
|
|
{ stdio: "inherit" },
|
|
);
|
|
retainSummary(notesPath);
|
|
clean(source);
|
|
if (git(source, "rev-parse", "HEAD") !== sourceSha) {
|
|
throw new Error("Release preparation changed source identity.");
|
|
}
|
|
if (process.env.GITHUB_OUTPUT) {
|
|
fs.appendFileSync(
|
|
process.env.GITHUB_OUTPUT,
|
|
`release_sha=${sourceSha}\nplatform=${platform}\n`,
|
|
);
|
|
}
|
|
console.log(
|
|
`Prepared ${platform} release from main source ${sourceSha}. Saved plan and notes: ${recovery}`,
|
|
);
|
|
if (stageExisting) {
|
|
uploadedRef(root, platform, plan, planPath);
|
|
}
|
|
run(
|
|
"/bin/bash",
|
|
[
|
|
`scripts/${platform}-release-upload.sh`,
|
|
...(stageExisting ? ["--stage-only"] : []),
|
|
...(platform === "ios" ? uploadArgs(plan) : ["--destination", destination]),
|
|
],
|
|
source,
|
|
{
|
|
stdio: "inherit",
|
|
env: releaseEnvironment(platform, recovery, sourceSha),
|
|
},
|
|
);
|
|
const playRef = uploadedRef(root, platform, plan, planPath);
|
|
console.log(`Verified uploaded release: ${playRef}`);
|
|
if (firebase) {
|
|
collectArtifacts(source, recovery, platform);
|
|
await distributeAndroidFirebase(firebase, plan, recovery, playRef);
|
|
}
|
|
completed = true;
|
|
} finally {
|
|
collectArtifacts(source, recovery, platform);
|
|
if (completed) {
|
|
git(root, "worktree", "remove", "--force", source);
|
|
} else {
|
|
console.error(
|
|
`Release attempt retained at ${recovery}. Inspect the first failure and store state before another upload.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function distributeAndroidFirebase(firebase, plan, recovery, playRef) {
|
|
try {
|
|
const result = await firebase.distribute({
|
|
plan,
|
|
notes: JSON.parse(fs.readFileSync(path.join(recovery, "release-notes.json"), "utf8")),
|
|
artifactsDirectory: path.join(recovery, "artifacts"),
|
|
receiptPath: path.join(recovery, "firebase-result.json"),
|
|
playRef,
|
|
});
|
|
const summary = `Android Internal testing: Play upload confirmed; Firebase distribution complete (Wear OS, then Phone). Receipt: firebase-result.json\n`;
|
|
console.log(summary.trim());
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
const summary =
|
|
"Android Internal testing: Play upload confirmed; Firebase incomplete. Preserve the recovery artifacts and inspect firebase-result.json before Firebase-only recovery.\n";
|
|
console.error(summary.trim());
|
|
console.error(
|
|
`Firebase-only recovery: node scripts/mobile-release.mjs firebase --platform android --recovery-dir <recovery-directory>`,
|
|
);
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function recoverAndroidFirebase(root, recovery) {
|
|
clean(root);
|
|
const planPath = path.join(recovery, "android-plan.json");
|
|
const plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
|
|
if (plan.destination !== "internal" || !/^[a-f0-9]{40}$/.test(plan.sourceSha) || !plan.firebase) {
|
|
throw new Error(
|
|
"Firebase recovery requires a saved Android internal plan with Firebase configuration.",
|
|
);
|
|
}
|
|
// The immutable Play record must exist before any Firebase-only recovery.
|
|
const playRef = uploadedRef(root, "android", plan, planPath);
|
|
const firebase = createAndroidFirebaseDistribution({ env: process.env });
|
|
await distributeAndroidFirebase(firebase, plan, recovery, playRef);
|
|
const source = path.join(recovery, "source");
|
|
const registered = git(root, "worktree", "list", "--porcelain")
|
|
.split("\n")
|
|
.includes(`worktree ${source}`);
|
|
if (registered) {
|
|
clean(source);
|
|
if (git(source, "rev-parse", "HEAD") !== plan.sourceSha) {
|
|
throw new Error(
|
|
"Firebase distribution completed; retained source changed, so it was preserved.",
|
|
);
|
|
}
|
|
git(root, "worktree", "remove", "--force", source);
|
|
}
|
|
}
|
|
|
|
function stageIos(root, recovery) {
|
|
clean(root);
|
|
const plan = JSON.parse(fs.readFileSync(path.join(recovery, "ios-plan.json"), "utf8"));
|
|
if (!/^[a-f0-9]{40}$/.test(plan.sourceSha)) {
|
|
throw new Error("Saved iOS plan must identify a full source SHA.");
|
|
}
|
|
const ref = uploadedRef(root, "ios", plan);
|
|
git(root, "fetch", "--no-tags", "origin", ref);
|
|
const source = path.join(recovery, "source");
|
|
if (fs.existsSync(source)) {
|
|
clean(source);
|
|
if (git(source, "rev-parse", "HEAD") !== plan.sourceSha) {
|
|
throw new Error("Retained source differs from the uploaded build.");
|
|
}
|
|
} else {
|
|
git(root, "worktree", "add", "--detach", source, plan.sourceSha);
|
|
}
|
|
// Recover with corrected tooling while retaining the uploaded source for identity checks.
|
|
run("/bin/bash", ["scripts/ios-release-upload.sh", "--stage-only", ...uploadArgs(plan)], root, {
|
|
stdio: "inherit",
|
|
env: releaseEnvironment("ios", recovery, plan.sourceSha),
|
|
});
|
|
git(root, "worktree", "remove", "--force", source);
|
|
console.log(
|
|
`Recovered the saved ${plan.destination ?? "app-store"} destination for iOS build: ${ref}`,
|
|
);
|
|
}
|
|
|
|
async function runCli() {
|
|
const args = process.argv.slice(2);
|
|
if (args.includes("--help") || args.includes("-h")) {
|
|
console.log(
|
|
"Usage: node scripts/mobile-release.mjs run --platform ios|android [--destination <destination>] [--recovery-dir <directory>]\n node scripts/mobile-release.mjs stage --platform ios --recovery-dir <directory>\n node scripts/mobile-release.mjs firebase --platform android --recovery-dir <directory>\nDestinations: iOS app-store (default) or testflight; Android play-store (default) or internal. Run prepares notes and uploads unchanged main source. Stage recovers the saved iOS destination without uploading again or making Git commits. Firebase recovers only the saved Android Firebase distribution; it never builds or uploads to Play.",
|
|
);
|
|
return;
|
|
}
|
|
const operation = args.shift();
|
|
let platform;
|
|
let recovery;
|
|
let destination;
|
|
const releaseArgs = [];
|
|
while (args.length) {
|
|
const arg = args.shift();
|
|
if (arg === "--") {
|
|
continue;
|
|
}
|
|
if (
|
|
![
|
|
"--platform",
|
|
"--recovery-dir",
|
|
"--destination",
|
|
"--version",
|
|
"--revision",
|
|
"--build-number",
|
|
].includes(arg)
|
|
) {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
const value = args.shift();
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`Missing value for ${arg}.`);
|
|
}
|
|
if (arg === "--platform") {
|
|
platform = value;
|
|
} else if (arg === "--recovery-dir") {
|
|
recovery = path.resolve(value);
|
|
} else if (arg === "--destination") {
|
|
destination = value;
|
|
} else {
|
|
releaseArgs.push(arg, value);
|
|
}
|
|
}
|
|
if (!["run", "stage", "firebase"].includes(operation) || !["ios", "android"].includes(platform)) {
|
|
throw new Error("Choose run, stage, or firebase and --platform ios or android.");
|
|
}
|
|
if (releaseArgs.length && (operation !== "run" || platform !== "ios")) {
|
|
throw new Error("Release overrides are accepted only for an iOS run.");
|
|
}
|
|
if (
|
|
destination !== undefined &&
|
|
(operation !== "run" ||
|
|
!(platform === "ios" ? ["app-store", "testflight"] : ["play-store", "internal"]).includes(
|
|
destination,
|
|
))
|
|
) {
|
|
throw new Error(
|
|
"Choose --destination app-store or testflight for iOS, or play-store or internal for Android; recovery uses the saved destination.",
|
|
);
|
|
}
|
|
if (operation === "stage" && (platform !== "ios" || !recovery)) {
|
|
throw new Error("Stage recovery requires --platform ios and --recovery-dir.");
|
|
}
|
|
if (operation === "firebase" && (platform !== "android" || !recovery)) {
|
|
throw new Error("Firebase recovery requires --platform android and --recovery-dir.");
|
|
}
|
|
const root = git(process.cwd(), "rev-parse", "--show-toplevel");
|
|
if (!recovery) {
|
|
const parent = path.join(root, ".artifacts");
|
|
fs.mkdirSync(parent, { recursive: true });
|
|
recovery = fs.mkdtempSync(path.join(parent, `${platform}-release-`));
|
|
}
|
|
// Recovery data must survive removing its source worktree.
|
|
const relativeRecovery = path.relative(root, recovery);
|
|
if (
|
|
relativeRecovery === "" ||
|
|
relativeRecovery.startsWith(`.git${path.sep}`) ||
|
|
relativeRecovery === ".git"
|
|
) {
|
|
throw new Error("Choose a dedicated release recovery directory outside .git.");
|
|
}
|
|
if (operation === "stage") {
|
|
stageIos(root, recovery);
|
|
} else if (operation === "firebase") {
|
|
await recoverAndroidFirebase(root, recovery);
|
|
} else {
|
|
await prepareAndUpload(
|
|
root,
|
|
platform,
|
|
recovery,
|
|
releaseArgs,
|
|
destination ?? (platform === "ios" ? "app-store" : "play-store"),
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await runCli();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|