mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* feat(ios): distribute daily TestFlight builds for external testing * fix(ios): reuse App Store builds for first TestFlight distribution Recognize the App Store-selected build of the same recorded source when it is eligible for beta submission and has no beta notes. Freeze its identity and notes and stage it without another archive or upload. Verify the adopted build ID and preserve existing beta notes on recovery. The CLI and registered Fastlane regression cases fail before the correction. The complete two-file suite passes 43 cases; the noted-store recovery guard also passes. Script and affected root-test types, scoped lint, formatting, and independent P0-P2 review pass.
562 lines
19 KiB
JavaScript
562 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_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" &&
|
|
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.",
|
|
);
|
|
}
|
|
} 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.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) : []),
|
|
],
|
|
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);
|
|
}
|
|
bridgeLocalTools(root, source, "ios");
|
|
run("/bin/bash", ["scripts/ios-release-upload.sh", "--stage-only", ...uploadArgs(plan)], source, {
|
|
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 app-store|testflight] [--recovery-dir <directory>]\n node scripts/mobile-release.mjs stage --platform ios --recovery-dir <directory>\nRun prepares notes and uploads unchanged main source. TestFlight is iOS-only. 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 &&
|
|
(platform !== "ios" ||
|
|
operation !== "run" ||
|
|
!["app-store", "testflight"].includes(destination))
|
|
) {
|
|
throw new Error(
|
|
"Choose --destination app-store or testflight for an iOS run; 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 ?? "app-store");
|
|
}
|
|
}
|
|
|
|
try {
|
|
runCli();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|