// iOS Fastlane release gate tests keep TestFlight upload on one canonical path. import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; const fastfilePath = path.join(process.cwd(), "apps", "ios", "fastlane", "Fastfile"); const packageJsonPath = path.join(process.cwd(), "package.json"); const legacyReleaseScriptPath = path.join(process.cwd(), "scripts", "ios-release.sh"); const uploadScriptPath = path.join(process.cwd(), "scripts", "ios-release-upload.sh"); function readFastfile(): string { return readFileSync(fastfilePath, "utf8"); } function laneBody(source: string, name: string): string { const startMarker = `lane :${name} do`; const start = source.indexOf(startMarker); if (start < 0) { throw new Error(`missing Fastlane lane ${name}`); } const rest = source.slice(start + startMarker.length); const nextLane = rest.search(/\n\s+(?:desc|lane|private_lane) /); return nextLane < 0 ? rest : rest.slice(0, nextLane); } function functionBody(source: string, name: string): string { const startMarker = `def ${name}`; const start = source.indexOf(startMarker); if (start < 0) { throw new Error(`missing Fastfile function ${name}`); } const rest = source.slice(start + startMarker.length); const nextFunction = rest.search(/\ndef /); return nextFunction < 0 ? rest : rest.slice(0, nextFunction); } describe("iOS Fastlane release upload gates", () => { it("does not keep the old package release alias", () => { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { scripts?: Record; }; expect(packageJson.scripts).toHaveProperty("ios:release:upload"); expect(packageJson.scripts).not.toHaveProperty("ios:release"); expect(existsSync(legacyReleaseScriptPath)).toBe(false); }); it("routes the package upload wrapper through the guarded Fastlane lane", () => { const script = readFileSync(uploadScriptPath, "utf8"); expect(script).toContain("OPENCLAW_IOS_RELEASE_WRAPPER=1"); expect(script).toContain("Missing required --version."); expect(script).toContain('"release_version:${RELEASE_VERSION}"'); expect(script).toContain('"build_number:${BUILD_NUMBER}"'); expect(script).toContain("DELIVER_NUMBER_OF_THREADS=1"); expect(script).toContain("FL_MAX_NUMBER_OF_THREADS=1"); expect(script).toContain('run_ios_fastlane "${FASTLANE_ARGS[@]}"'); }); it("keeps release_upload as the only Fastlane TestFlight upload implementation", () => { const fastfile = readFastfile(); const uploadCalls = fastfile.match(/\bupload_to_testflight\s*\(/g) ?? []; expect(uploadCalls).toHaveLength(1); expect(laneBody(fastfile, "release_upload")).toContain("upload_to_testflight("); expect(fastfile).not.toMatch(/\n\s+lane :app_store do\b/); expect(fastfile).not.toContain("Deprecated. Use `pnpm ios:release:upload`."); }); it("rejects direct Fastlane upload before release work", () => { const fastfile = readFastfile(); const releaseUpload = laneBody(fastfile, "release_upload"); const prepareContext = laneBody(fastfile, "prepare_app_store_context"); expect(releaseUpload).toContain('ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"'); expect(releaseUpload).toContain("Use `pnpm ios:release:upload`"); expect(prepareContext).toContain("options[:release_version]"); expect(prepareContext).toContain("options[:build_number]"); expect(prepareContext).toContain("Missing iOS release version"); expect(releaseUpload).toContain("metadata(release_version: context[:short_version])"); expect(laneBody(fastfile, "metadata")).toContain("options[:release_version]"); expect(laneBody(fastfile, "metadata")).toContain("Missing iOS release version"); expect(releaseUpload.indexOf("UI.user_error!")).toBeLessThan( releaseUpload.indexOf("prepare_app_store_context"), ); }); it("validates the exported IPA before the sole TestFlight upload call", () => { const fastfile = readFastfile(); const validationCall = fastfile.indexOf("expected_commit: context[:git_commit]"); const uploadCall = fastfile.indexOf("upload_to_testflight("); expect(validationCall).toBeGreaterThanOrEqual(0); expect(uploadCall).toBeGreaterThan(validationCall); }); it("requires clean matching source before preparing and building release artifacts", () => { const fastfile = readFastfile(); const verifier = functionBody(fastfile, "verify_apple_release_source!"); const provenance = functionBody(fastfile, "pin_release_build_provenance!"); const builder = functionBody(fastfile, "build_app_store_release"); expect(verifier).toContain('"apple-release-source-check.sh"'); expect(verifier).toContain('"--root"'); expect(verifier).toContain('"--expected-commit"'); expect(provenance).toContain("verify_apple_release_source!(normalized_commit)"); expect(provenance).not.toContain('ENV["GITHUB_SHA"]'); expect(builder).toContain("verify_apple_release_source!(context[:git_commit])"); expect(builder.indexOf("verify_apple_release_source!")).toBeLessThan( builder.indexOf("FileUtils.mkdir_p(output_directory)"), ); }); it("preflights and records mobile release refs around TestFlight upload", () => { const fastfile = readFastfile(); const releaseUpload = laneBody(fastfile, "release_upload"); expect(fastfile).toContain("def mobile_release_ref_command"); expect(fastfile).toContain("def release_git_sha"); expect(fastfile).toContain('"--root"'); expect(fastfile).toContain('"--sha"'); expect(fastfile).toContain("repo_root"); expect(fastfile).toContain("def pin_release_build_provenance!"); expect(laneBody(fastfile, "prepare_app_store_context")).toContain( "provenance = pin_release_build_provenance!", ); expect(releaseUpload).toContain("release_sha = context[:git_commit]"); expect(releaseUpload).toContain("ensure_mobile_release_ref_available!"); expect(releaseUpload).toContain("record_mobile_release_ref!"); expect(releaseUpload).toContain( "screenshots(release_version: context[:version], build_number: context[:build_number])", ); expect(fastfile).toContain("def without_xcode_xcconfig_file"); expect(releaseUpload).toContain("without_xcode_xcconfig_file do"); expect(releaseUpload.match(/sha: release_sha/g)).toHaveLength(2); expect(releaseUpload.indexOf("prepare_app_store_context")).toBeLessThan( releaseUpload.indexOf("screenshots(release_version: context[:version]"), ); expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan( releaseUpload.indexOf("screenshots(release_version: context[:version]"), ); expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan( releaseUpload.indexOf("\n metadata(release_version: context[:short_version])\n"), ); expect(releaseUpload.indexOf("record_mobile_release_ref!")).toBeGreaterThan( releaseUpload.indexOf("upload_to_testflight("), ); }); it("normalizes Watch screenshots as opaque RGB PNGs for App Store upload", () => { const fastfile = readFastfile(); expect(laneBody(fastfile, "screenshots")).toContain( 'File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args', ); expect(laneBody(fastfile, "watch_screenshot")).toContain( 'File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh"), *version_args', ); expect(fastfile).toContain("def normalize_watch_screenshot_status_bar(path)"); expect(fastfile).toContain("CGImageAlphaInfo.noneSkipLast.rawValue"); expect(fastfile).toContain("CGImageDestinationCreateWithURL"); expect(fastfile).toContain("operation: .sourceOver"); }); });