#!/usr/bin/env node import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; 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; } 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.", ); } 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; 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), }, ); console.log(`Verified uploaded release: ${uploadedRef(root, platform, plan, planPath)}`); 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.`, ); } } } 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}`, ); } 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 ] [--recovery-dir ]\n node scripts/mobile-release.mjs stage --platform ios --recovery-dir \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.", ); 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"].includes(operation) || !["ios", "android"].includes(platform)) { throw new Error("Choose run or stage 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."); } 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 { prepareAndUpload( root, platform, recovery, releaseArgs, destination ?? (platform === "ios" ? "app-store" : "play-store"), ); } } try { runCli(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }