mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
571 lines
19 KiB
JavaScript
571 lines
19 KiB
JavaScript
#!/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 <destination>] [--recovery-dir <directory>]\n node scripts/mobile-release.mjs stage --platform ios --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.",
|
|
);
|
|
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;
|
|
}
|