From 8ccb8dd9092b4f49bdadce625f59dfb246663f42 Mon Sep 17 00:00:00 2001 From: Revopush Date: Thu, 15 Oct 2026 17:41:41 +0300 Subject: [PATCH 1/3] Remove duplicated release, upload and zip code; fix iOS release without --podFile Duplication (no behaviour change): - release-react and release-expo share releaseReactBundle(); the parser shares their options and createCommand case - One release metadata builder for react/expo and native releases - management-sdk release and releaseNative share uploadPackage() - One createZip() in utils/file-utils for the SDK and binary-utils Fix: getIosHermesEnabled crashed when the parser passed podFile: null, which every iOS release-react/release-expo without --podFile does. The release-react test helper now uses the parser's null defaults, so the existing iOS tests cover it. Co-Authored-By: Claude Opus 5.5 --- script/binary-utils.ts | 30 +- script/command-executor.ts | 107 ++---- script/command-parser.ts | 619 +++++++++++------------------------ script/management-sdk.ts | 133 +++----- script/utils/file-utils.ts | 24 ++ script/utils/hermes-utils.ts | 8 +- test/cli.ts | 19 +- test/hermes-utils.ts | 6 + 8 files changed, 320 insertions(+), 626 deletions(-) diff --git a/script/binary-utils.ts b/script/binary-utils.ts index addd82f..a4bf936 100644 --- a/script/binary-utils.ts +++ b/script/binary-utils.ts @@ -4,8 +4,8 @@ import * as crypto from "crypto"; import * as chalk from "chalk"; import { log } from "./ui/log"; import * as os from "os"; -import * as yazl from "yazl"; import * as yauzl from "yauzl"; +import { createZip, ZipEntry } from "./utils/file-utils"; import { readFile } from "node:fs/promises"; import { buffer as readStreamToBuffer } from "node:stream/consumers"; import * as protobuf from "protobufjs"; @@ -140,30 +140,10 @@ async function takeHashesFromMetadata(metadataPath: string): Promise { - return new Promise((resolve, reject) => { - const zipFile = new yazl.ZipFile(); - const writeStream = fs.createWriteStream(zipPath); - - // yazl reports unreadable input files on the ZipFile itself - zipFile.on("error", reject); - zipFile.outputStream - .pipe(writeStream) - .on("error", (error: Error) => { - reject(error); - }) - .on("close", () => { - resolve(); - }); - - for (const file of filesToInclude) { - const filePath = path.join(sourceFolder, file); - if (fs.existsSync(filePath)) { - zipFile.addFile(filePath, file); - } - } - - zipFile.end(); - }); + const entries: ZipEntry[] = filesToInclude + .map((file): ZipEntry => [path.join(sourceFolder, file), file]) + .filter(([filePath]) => fs.existsSync(filePath)); + return createZip(zipPath, entries); } function parsePlistBuffer(buf: Buffer): any { diff --git a/script/command-executor.ts b/script/command-executor.ts index 0b708a1..1af6dc7 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -843,72 +843,16 @@ function printReleaseSizeWarnings(analysis: ReleaseSizeAnalysis | null, force: b } } -export const releaseExpo = async (command: cli.IReleaseReactCommand): Promise => { - const { platform, bundleName, projectName } = validateReactReleaseCommand(command, { - resolveEntryFile: false, - }); - - const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); - const sourcemapOutputFolder: string = command.sourcemapOutput || path.join(os.tmpdir(), "CodePushSourceMap"); - const baseReleaseTmpFolder: string = path.join(os.tmpdir(), "CodePushBaseRelease"); - // releaseCommand below is `command` itself, and it overwrites command.outputDir with the - // resolved path -- so capture whether the user actually passed one before that happens. - const isTempOutputFolder: boolean = !command.outputDir; - - const releaseCommand: cli.IReleaseReactCommand = command; - releaseCommand.package = outputFolder; - releaseCommand.outputDir = outputFolder; - releaseCommand.bundleName = bundleName; - await sdk.getDeployment(command.appName, command.deploymentName); - - // For release-expo, buildNumber is NOT auto-detected — it must be passed explicitly - // via --buildNumber. Auto-detection only applies to release-native where the binary - // build number is the natural targeting key. - const appVersion: string = command.appStoreVersion || (await getReactNativeProjectVersionInfo(command, projectName)).appVersion; +type ReactBundler = "react-native" | "expo"; - if (!command.sourcemapOutput) { - await createEmptyTempReleaseFolder(sourcemapOutputFolder); - } - - throwForInvalidSemverRange(appVersion); - releaseCommand.appStoreVersion = appVersion; - - await createEmptyTempReleaseFolder(outputFolder); - await deleteReactTempFolders(); - - const { isHermes, sourceMaps, bundleOptions } = await planBundle(command, outputFolder, bundleName, sourcemapOutputFolder); - await runExpoExportEmbedCommand(bundleOptions); - - if (isHermes) { - await compileHermesBundle(command, outputFolder, bundleName, sourceMaps, baseReleaseTmpFolder); - } +export const releaseReact = (command: cli.IReleaseReactCommand): Promise => releaseReactBundle(command, "react-native"); - const releaseSizeAnalysis: ReleaseSizeAnalysis | null = validateGeneratedReleaseSizes(command, outputFolder, bundleName, platform); +export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => releaseReactBundle(command, "expo"); - if (command.privateKeyPath) { - log(chalk.cyan("\nSigning the bundle:\n")); - await sign(command.privateKeyPath, outputFolder); - } else { - console.log("private key was not provided"); - } - - log(chalk.cyan("\nReleasing update contents to CodePush:\n")); - await releaseReactNative(releaseCommand, () => printReleaseSizeWarnings(releaseSizeAnalysis, command.force || false)); - - if (isTempOutputFolder) { - await deleteFolder(outputFolder); - } - - if (!command.sourcemapOutput) { - await deleteFolder(sourcemapOutputFolder); - } - - await deleteFolder(baseReleaseTmpFolder); -}; - -export const releaseReact = async (command: cli.IReleaseReactCommand): Promise => { +async function releaseReactBundle(command: cli.IReleaseReactCommand, bundler: ReactBundler): Promise { const { platform, bundleName, entryFile, projectName } = validateReactReleaseCommand(command, { - resolveEntryFile: true, + // Only the react-native bundler takes the JS entry file; Expo resolves it itself + resolveEntryFile: bundler === "react-native", }); const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); @@ -926,9 +870,8 @@ export const releaseReact = async (command: cli.IReleaseReactCommand): Promise printReleaseSizeWarnings(releaseSizeAnalysis, command.force || false)); + await doRelease(releaseCommand, getReactNativePackageInfo(releaseCommand), () => + printReleaseSizeWarnings(releaseSizeAnalysis, command.force || false) + ); if (isTempOutputFolder) { await deleteFolder(outputFolder); @@ -971,7 +920,7 @@ export const releaseReact = async (command: cli.IReleaseReactCommand): Promise => { const platform: string = command.platform.toLowerCase(); @@ -1076,9 +1025,9 @@ export const releaseNative = async (command: cli.IReleaseNativeCommand): Promise } }; -const releaseReactNative = async (command: cli.IReleaseReactCommand, onReleaseCompleted?: () => void): Promise => { - // for initial release we explicitly define release as optional, disabled, without rollout, with a special description - const updateMetadata: ReactNativePackageInfo = { +// For an initial release we explicitly define it as optional, disabled, without rollout, with a special description +function getReactNativePackageInfo(command: cli.IReleaseReactCommand): ReactNativePackageInfo { + return { description: command.initial ? `Zero release for v${command.appStoreVersion}` : command.description, isDisabled: command.initial ? true : command.disabled, isMandatory: command.initial ? false : command.mandatory, @@ -1089,9 +1038,7 @@ const releaseReactNative = async (command: cli.IReleaseReactCommand, onReleaseCo appVersion: command.appStoreVersion, buildNumber: command.buildNumber, }; - - return doRelease(command, updateMetadata, onReleaseCompleted); -}; +} const doRelease = async ( command: cli.IReleaseCommand | cli.IReleaseReactCommand, @@ -1144,17 +1091,7 @@ const doNativeRelease = async (releaseCommand: cli.IReleaseReactCommand): Promis const filePath: string = releaseCommand.package; - const updateMetadata: ReactNativePackageInfo = { - description: releaseCommand.initial ? `Zero release for v${releaseCommand.appStoreVersion}` : releaseCommand.description, - isDisabled: releaseCommand.initial ? true : releaseCommand.disabled, - isMandatory: releaseCommand.initial ? false : releaseCommand.mandatory, - isInitial: releaseCommand.initial, - bundleName: releaseCommand.bundleName, - outputDir: releaseCommand.outputDir, - rollout: releaseCommand.initial ? undefined : releaseCommand.rollout, - appVersion: releaseCommand.appStoreVersion, - buildNumber: releaseCommand.buildNumber, - }; + const updateMetadata: ReactNativePackageInfo = getReactNativePackageInfo(releaseCommand); const uploadProgress = createUploadProgress(); diff --git a/script/command-parser.ts b/script/command-parser.ts index cdf6646..6c65f5f 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -117,6 +117,198 @@ function accessKeyRemove(commandName: string, yargs: yargs.Argv): void { addCommonConfiguration(yargs); } +// Options shared by release-react and release-expo +function addReactReleaseOptions(yargs: yargs.Argv): void { + yargs + .option("bundleName", { + alias: "b", + default: null, + demand: false, + description: + 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS) or "index.android.bundle" (Android)', + type: "string", + }) + .option("deploymentName", { + alias: "d", + default: "Staging", + demand: false, + description: "Deployment to release the update to", + type: "string", + }) + .option("description", { + alias: "des", + default: null, + demand: false, + description: "Description of the changes made to the app with this release", + type: "string", + }) + .option("development", { + alias: "dev", + default: false, + demand: false, + description: "Specifies whether to generate a dev or release build", + type: "boolean", + }) + .option("disabled", { + alias: "x", + default: false, + demand: false, + description: "Specifies whether this release should be immediately downloadable", + type: "boolean", + }) + .option("entryFile", { + alias: "e", + default: null, + demand: false, + description: + 'Path to the app\'s entry Javascript file. If omitted, "index..js" and then "index.js" will be used (if they exist)', + type: "string", + }) + .option("gradleFile", { + alias: "g", + default: null, + demand: false, + description: "Path to the gradle file which specifies the binary version you want to target this release at (android only).", + }) + .option("initial", { + alias: "i", + default: false, + demand: false, + description: "Specifies whether release is initial (base) for given targetBinaryVersion.", + type: "boolean", + }) + .option("mandatory", { + alias: "m", + default: false, + demand: false, + description: "Specifies whether this release should be considered mandatory", + type: "boolean", + }) + .option("noDuplicateReleaseError", { + default: false, + demand: false, + description: + "When this flag is set, releasing a package that is identical to the latest release will produce a warning instead of an error", + type: "boolean", + }) + .option("plistFile", { + alias: "p", + default: null, + demand: false, + description: "Path to the plist file which specifies the binary version you want to target this release at (iOS only).", + }) + .option("plistFilePrefix", { + alias: "pre", + default: null, + demand: false, + description: "Prefix to append to the file name when attempting to find your app's Info.plist file (iOS only).", + }) + .option("rollout", { + alias: "r", + default: "100%", + demand: false, + description: "Percentage of users this release should be immediately available to", + type: "string", + }) + .option("sourcemapOutput", { + alias: "s", + default: null, + demand: false, + description: + "Path to where the sourcemap for the resulting bundle should be written. If omitted, a sourcemap will not be generated.", + type: "string", + }) + .option("targetBinaryVersion", { + alias: "t", + default: null, + demand: false, + description: + 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS) or "build.gradle" (Android) files.', + type: "string", + }) + .option("outputDir", { + alias: "o", + default: null, + demand: false, + description: "Path to where the bundle and sourcemap should be written. If omitted, a bundle and sourcemap will not be written.", + type: "string", + }) + .option("useHermes", { + alias: "h", + default: false, + demand: false, + description: "Enable hermes and bypass automatic checks", + type: "boolean", + }) + .option("podFile", { + alias: "pod", + default: null, + demand: false, + description: "Path to the cocopods config file (iOS only).", + type: "string", + }) + .option("extraHermesFlags", { + alias: "hf", + default: [], + demand: false, + description: "Flags that get passed to Hermes, JavaScript to bytecode compiler. Can be specified multiple times.", + type: "array", + }) + .option("privateKeyPath", { + alias: "k", + default: null, + demand: false, + description: "Path to private key used for code signing.", + type: "string", + }) + .option("xcodeProjectFile", { + alias: "xp", + default: null, + demand: false, + description: "Path to the Xcode project or project.pbxproj file", + type: "string", + }) + .option("xcodeTargetName", { + alias: "xt", + default: undefined, + demand: false, + description: "Name of target (PBXNativeTarget) which specifies the binary version you want to target this release at (iOS only)", + type: "string", + }) + .option("buildConfigurationName", { + alias: "c", + default: undefined, + demand: false, + description: + "Name of build configuration which specifies the binary version you want to target this release at. For example, 'Debug' or 'Release' (iOS only)", + type: "string", + }) + .option("extraBundlerOption", { + alias: "eo", + default: [], + demand: false, + description: "Option that gets passed to react-native bundler. Can be specified multiple times.", + type: "array", + }) + .option("force", { + default: false, + demand: false, + description: "Release even when generated assets exceed the 500 KiB per-file limit", + type: "boolean", + }) + .option("buildNumber", { + alias: "bn", + default: null, + demand: false, + description: + "Target clients on a specific native build (CFBundleVersion on iOS, versionCode on Android). Requires an exact appVersion (e.g. 1.2.3, not a range) and an existing native release with that build number. If omitted, targets all clients on the specified appVersion.", + type: "string", + }) + .check((argv: any): any => { + return checkValidReleaseOptions(argv); + }); +} + function addCommonConfiguration(yargs: yargs.Argv): void { yargs .wrap(/*columnLimit*/ null) @@ -689,196 +881,9 @@ yargs .example( "release-react MyApp android -d Production", 'Releases the React Native Android project in the current working directory to the "MyApp" app\'s "Production" deployment' - ) - .option("bundleName", { - alias: "b", - default: null, - demand: false, - description: - 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS) or "index.android.bundle" (Android)', - type: "string", - }) - .option("deploymentName", { - alias: "d", - default: "Staging", - demand: false, - description: "Deployment to release the update to", - type: "string", - }) - .option("description", { - alias: "des", - default: null, - demand: false, - description: "Description of the changes made to the app with this release", - type: "string", - }) - .option("development", { - alias: "dev", - default: false, - demand: false, - description: "Specifies whether to generate a dev or release build", - type: "boolean", - }) - .option("disabled", { - alias: "x", - default: false, - demand: false, - description: "Specifies whether this release should be immediately downloadable", - type: "boolean", - }) - .option("entryFile", { - alias: "e", - default: null, - demand: false, - description: - 'Path to the app\'s entry Javascript file. If omitted, "index..js" and then "index.js" will be used (if they exist)', - type: "string", - }) - .option("gradleFile", { - alias: "g", - default: null, - demand: false, - description: "Path to the gradle file which specifies the binary version you want to target this release at (android only).", - }) - .option("initial", { - alias: "i", - default: false, - demand: false, - description: "Specifies whether release is initial (base) for given targetBinaryVersion.", - type: "boolean", - }) - .option("mandatory", { - alias: "m", - default: false, - demand: false, - description: "Specifies whether this release should be considered mandatory", - type: "boolean", - }) - .option("noDuplicateReleaseError", { - default: false, - demand: false, - description: - "When this flag is set, releasing a package that is identical to the latest release will produce a warning instead of an error", - type: "boolean", - }) - .option("plistFile", { - alias: "p", - default: null, - demand: false, - description: "Path to the plist file which specifies the binary version you want to target this release at (iOS only).", - }) - .option("plistFilePrefix", { - alias: "pre", - default: null, - demand: false, - description: "Prefix to append to the file name when attempting to find your app's Info.plist file (iOS only).", - }) - .option("rollout", { - alias: "r", - default: "100%", - demand: false, - description: "Percentage of users this release should be immediately available to", - type: "string", - }) - .option("sourcemapOutput", { - alias: "s", - default: null, - demand: false, - description: - "Path to where the sourcemap for the resulting bundle should be written. If omitted, a sourcemap will not be generated.", - type: "string", - }) - .option("targetBinaryVersion", { - alias: "t", - default: null, - demand: false, - description: - 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS) or "build.gradle" (Android) files.', - type: "string", - }) - .option("outputDir", { - alias: "o", - default: null, - demand: false, - description: - "Path to where the bundle and sourcemap should be written. If omitted, a bundle and sourcemap will not be written.", - type: "string", - }) - .option("useHermes", { - alias: "h", - default: false, - demand: false, - description: "Enable hermes and bypass automatic checks", - type: "boolean", - }) - .option("podFile", { - alias: "pod", - default: null, - demand: false, - description: "Path to the cocopods config file (iOS only).", - type: "string", - }) - .option("extraHermesFlags", { - alias: "hf", - default: [], - demand: false, - description: "Flags that get passed to Hermes, JavaScript to bytecode compiler. Can be specified multiple times.", - type: "array", - }) - .option("privateKeyPath", { - alias: "k", - default: null, - demand: false, - description: "Path to private key used for code signing.", - type: "string", - }) - .option("xcodeProjectFile", { - alias: "xp", - default: null, - demand: false, - description: "Path to the Xcode project or project.pbxproj file", - type: "string", - }) - .option("xcodeTargetName", { - alias: "xt", - default: undefined, - demand: false, - description: - "Name of target (PBXNativeTarget) which specifies the binary version you want to target this release at (iOS only)", - type: "string", - }) - .option("buildConfigurationName", { - alias: "c", - default: undefined, - demand: false, - description: - "Name of build configuration which specifies the binary version you want to target this release at. For example, 'Debug' or 'Release' (iOS only)", - type: "string", - }) - .option("extraBundlerOption", { - alias: "eo", - default: [], - demand: false, - description: "Option that gets passed to react-native bundler. Can be specified multiple times.", - type: "array", - }) - .option("force", { - default: false, - demand: false, - description: "Release even when generated assets exceed the 500 KiB per-file limit", - type: "boolean", - }) - .option("buildNumber", { - alias: "bn", - default: null, - demand: false, - description: - "Target clients on a specific native build (CFBundleVersion on iOS, versionCode on Android). Requires an exact appVersion (e.g. 1.2.3, not a range) and an existing native release with that build number. If omitted, targets all clients on the specified appVersion.", - type: "string", - }) - .check((argv: any): any => { - return checkValidReleaseOptions(argv); - }); + ); + + addReactReleaseOptions(yargs); addCommonConfiguration(yargs); }) @@ -895,196 +900,9 @@ yargs .example( "release-expo MyApp android -d Production", 'Releases the Expo-managed Android project in the current working directory to the "MyApp" app\'s "Production" deployment' - ) - .option("bundleName", { - alias: "b", - default: null, - demand: false, - description: - 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS) or "index.android.bundle" (Android)', - type: "string", - }) - .option("deploymentName", { - alias: "d", - default: "Staging", - demand: false, - description: "Deployment to release the update to", - type: "string", - }) - .option("description", { - alias: "des", - default: null, - demand: false, - description: "Description of the changes made to the app with this release", - type: "string", - }) - .option("development", { - alias: "dev", - default: false, - demand: false, - description: "Specifies whether to generate a dev or release build", - type: "boolean", - }) - .option("disabled", { - alias: "x", - default: false, - demand: false, - description: "Specifies whether this release should be immediately downloadable", - type: "boolean", - }) - .option("entryFile", { - alias: "e", - default: null, - demand: false, - description: - 'Path to the app\'s entry Javascript file. If omitted, "index..js" and then "index.js" will be used (if they exist)', - type: "string", - }) - .option("gradleFile", { - alias: "g", - default: null, - demand: false, - description: "Path to the gradle file which specifies the binary version you want to target this release at (android only).", - }) - .option("initial", { - alias: "i", - default: false, - demand: false, - description: "Specifies whether release is initial (base) for given targetBinaryVersion.", - type: "boolean", - }) - .option("mandatory", { - alias: "m", - default: false, - demand: false, - description: "Specifies whether this release should be considered mandatory", - type: "boolean", - }) - .option("noDuplicateReleaseError", { - default: false, - demand: false, - description: - "When this flag is set, releasing a package that is identical to the latest release will produce a warning instead of an error", - type: "boolean", - }) - .option("plistFile", { - alias: "p", - default: null, - demand: false, - description: "Path to the plist file which specifies the binary version you want to target this release at (iOS only).", - }) - .option("plistFilePrefix", { - alias: "pre", - default: null, - demand: false, - description: "Prefix to append to the file name when attempting to find your app's Info.plist file (iOS only).", - }) - .option("rollout", { - alias: "r", - default: "100%", - demand: false, - description: "Percentage of users this release should be immediately available to", - type: "string", - }) - .option("sourcemapOutput", { - alias: "s", - default: null, - demand: false, - description: - "Path to where the sourcemap for the resulting bundle should be written. If omitted, a sourcemap will not be generated.", - type: "string", - }) - .option("targetBinaryVersion", { - alias: "t", - default: null, - demand: false, - description: - 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS) or "build.gradle" (Android) files.', - type: "string", - }) - .option("outputDir", { - alias: "o", - default: null, - demand: false, - description: - "Path to where the bundle and sourcemap should be written. If omitted, a bundle and sourcemap will not be written.", - type: "string", - }) - .option("useHermes", { - alias: "h", - default: false, - demand: false, - description: "Enable hermes and bypass automatic checks", - type: "boolean", - }) - .option("podFile", { - alias: "pod", - default: null, - demand: false, - description: "Path to the cocopods config file (iOS only).", - type: "string", - }) - .option("extraHermesFlags", { - alias: "hf", - default: [], - demand: false, - description: "Flags that get passed to Hermes, JavaScript to bytecode compiler. Can be specified multiple times.", - type: "array", - }) - .option("privateKeyPath", { - alias: "k", - default: null, - demand: false, - description: "Path to private key used for code signing.", - type: "string", - }) - .option("xcodeProjectFile", { - alias: "xp", - default: null, - demand: false, - description: "Path to the Xcode project or project.pbxproj file", - type: "string", - }) - .option("xcodeTargetName", { - alias: "xt", - default: undefined, - demand: false, - description: - "Name of target (PBXNativeTarget) which specifies the binary version you want to target this release at (iOS only)", - type: "string", - }) - .option("buildConfigurationName", { - alias: "c", - default: undefined, - demand: false, - description: - "Name of build configuration which specifies the binary version you want to target this release at. For example, 'Debug' or 'Release' (iOS only)", - type: "string", - }) - .option("extraBundlerOption", { - alias: "eo", - default: [], - demand: false, - description: "Option that gets passed to react-native bundler. Can be specified multiple times.", - type: "array", - }) - .option("force", { - default: false, - demand: false, - description: "Release even when generated assets exceed the 500 KiB per-file limit", - type: "boolean", - }) - .option("buildNumber", { - alias: "bn", - default: null, - demand: false, - description: - "Target clients on a specific native build (CFBundleVersion on iOS, versionCode on Android). Requires an exact appVersion (e.g. 1.2.3, not a range) and an existing native release with that build number. If omitted, targets all clients on the specified appVersion.", - type: "string", - }) - .check((argv: any) => { - return checkValidReleaseOptions(argv); - }); + ); + + addReactReleaseOptions(yargs); addCommonConfiguration(yargs); }) @@ -1499,8 +1317,9 @@ export function createCommand(): cli.ICommand { break; case "release-react": + case "release-expo": if (arg1 && arg2) { - cmd = { type: cli.CommandType.releaseReact }; + cmd = { type: arg0 === "release-expo" ? cli.CommandType.releaseExpo : cli.CommandType.releaseReact }; const releaseReactCommand = cmd as cli.IReleaseReactCommand; @@ -1536,44 +1355,6 @@ export function createCommand(): cli.ICommand { } break; - case "release-expo": - if (arg1 && arg2) { - cmd = { type: cli.CommandType.releaseExpo }; - - const releaseExpoCommand = cmd as cli.IReleaseReactCommand; - - releaseExpoCommand.appName = arg1; - releaseExpoCommand.platform = arg2; - - releaseExpoCommand.appStoreVersion = argv["targetBinaryVersion"] as any; - releaseExpoCommand.buildNumber = argv["buildNumber"] as any; - releaseExpoCommand.bundleName = argv["bundleName"] as any; - releaseExpoCommand.deploymentName = argv["deploymentName"] as any; - releaseExpoCommand.disabled = argv["disabled"] as any; - releaseExpoCommand.description = argv["description"] ? backslash(argv["description"]) : ""; - releaseExpoCommand.development = argv["development"] as any; - releaseExpoCommand.entryFile = argv["entryFile"] as any; - releaseExpoCommand.gradleFile = argv["gradleFile"] as any; - releaseExpoCommand.mandatory = argv["mandatory"] as any; - releaseExpoCommand.initial = argv["initial"] as any; - releaseExpoCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"] as any; - releaseExpoCommand.plistFile = argv["plistFile"] as any; - releaseExpoCommand.plistFilePrefix = argv["plistFilePrefix"] as any; - releaseExpoCommand.rollout = getRolloutValue(argv["rollout"] as any); - releaseExpoCommand.sourcemapOutput = argv["sourcemapOutput"] as any; - releaseExpoCommand.outputDir = argv["outputDir"] as any; - releaseExpoCommand.useHermes = argv["useHermes"] as any; - releaseExpoCommand.extraHermesFlags = argv["extraHermesFlags"] as any; - releaseExpoCommand.podFile = argv["podFile"] as any; - releaseExpoCommand.privateKeyPath = argv["privateKeyPath"] as any; - releaseExpoCommand.xcodeProjectFile = argv["xcodeProjectFile"] as any; - releaseExpoCommand.xcodeTargetName = argv["xcodeTargetName"] as any; - releaseExpoCommand.buildConfigurationName = argv["buildConfigurationName"] as any; - releaseExpoCommand.extraBundlerOptions = argv["extraBundlerOption"] as any; - releaseExpoCommand.force = argv["force"] as any; - } - break; - case "release-native": if (arg1 && arg2 && arg3) { cmd = { type: cli.CommandType.releaseNative }; diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 09a3c60..8df2ddb 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -5,7 +5,6 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import superagent = require("superagent"); -import * as yazl from "yazl"; import { AccessKey, @@ -24,7 +23,7 @@ import { BaseRelease, } from "./types"; import { ReactNativePackageInfo } from "./types/rest-definitions"; -import { listFiles, normalizePath } from "./utils/file-utils"; +import { createZip, listFiles, normalizePath } from "./utils/file-utils"; const packageJson = require("../../package.json"); @@ -317,55 +316,12 @@ class AccountManager { uploadProgressCallback?: (progress: number) => void ): Promise { const packageFile: PackageFile = await this.packageFileFromPath(filePath); - return new Promise((resolve, reject) => { - const request: superagent.Request = superagent.post( - this._serverUrl + urlEncode([`/apps/${appName}/deployments/${deploymentName}/release`]) - ); - - this.attachCredentials(request); - - const file: any = fs.createReadStream(packageFile.path); - request - .attach("package", file) - .field("packageInfo", JSON.stringify(updateMetadata)) - .on("progress", (event: any) => { - if (uploadProgressCallback && event && event.total > 0) { - const currentProgress: number = (event.loaded / event.total) * 100; - uploadProgressCallback(currentProgress); - } - }) - .end((err: any, res: superagent.Response) => { - if (packageFile.isTemporary) { - fs.unlinkSync(packageFile.path); - } - - if (err) { - reject(this.getCodePushError(err, res)); - return; - } - - if (res.ok) { - resolve(null); - } else { - let body; - try { - body = JSON.parse(res.text); - } catch {} - - if (body) { - reject({ - message: body.message, - statusCode: res && res.status, - } as CodePushError); - } else { - reject({ - message: res.text, - statusCode: res && res.status, - } as CodePushError); - } - } - }); - }); + return this.uploadPackage( + `/apps/${appName}/deployments/${deploymentName}/release`, + packageFile, + updateMetadata, + uploadProgressCallback + ); } public releaseNative( @@ -374,15 +330,29 @@ class AccountManager { filePath: string, updateMetadata: ReactNativePackageInfo, uploadProgressCallback?: (progress: number) => void + ): Promise { + // The metadata zip is created for this upload, so it's always deleted afterwards + const packageFile: PackageFile = { isTemporary: true, path: filePath }; + return this.uploadPackage( + `/apps/${appName}/deployments/${deploymentName}/nativerelease`, + packageFile, + updateMetadata, + uploadProgressCallback + ); + } + + private uploadPackage( + endpoint: string, + packageFile: PackageFile, + updateMetadata: PackageInfo, + uploadProgressCallback?: (progress: number) => void ): Promise { return new Promise((resolve, reject) => { - const request: superagent.Request = superagent.post( - this._serverUrl + urlEncode([`/apps/${appName}/deployments/${deploymentName}/nativerelease`]) - ); + const request: superagent.Request = superagent.post(this._serverUrl + urlEncode([endpoint])); this.attachCredentials(request); - const file: any = fs.createReadStream(filePath); + const file: any = fs.createReadStream(packageFile.path); request .attach("package", file) .field("packageInfo", JSON.stringify(updateMetadata)) @@ -393,7 +363,9 @@ class AccountManager { } }) .end((err: any, res: superagent.Response) => { - fs.unlinkSync(filePath); + if (packageFile.isTemporary) { + fs.unlinkSync(packageFile.path); + } if (err) { reject(this.getCodePushError(err, res)); @@ -408,17 +380,10 @@ class AccountManager { body = JSON.parse(res.text); } catch {} - if (body) { - reject({ - message: body.message, - statusCode: res && res.status, - } as CodePushError); - } else { - reject({ - message: res.text, - statusCode: res && res.status, - } as CodePushError); - } + reject({ + message: body ? body.message : res.text, + statusCode: res && res.status, + } as CodePushError); } }); }); @@ -464,32 +429,14 @@ class AccountManager { } const files: string[] = await listFiles(filePath); - return new Promise((resolve, reject) => { - const baseDirectoryPath = path.dirname(filePath); - const fileName: string = this.generateRandomFilename(15) + ".zip"; - const zipFile = new yazl.ZipFile(); - const writeStream: fs.WriteStream = fs.createWriteStream(fileName); - - // yazl reports unreadable input files on the ZipFile itself - zipFile.on("error", reject); - zipFile.outputStream - .pipe(writeStream) - .on("error", (error: Error): void => { - reject(error); - }) - .on("close", (): void => { - resolve({ isTemporary: true, path: path.join(process.cwd(), fileName) }); - }); - - for (const file of files) { - // yazl does not like backslash (\) in the metadata path. - const relativePath: string = normalizePath(path.relative(baseDirectoryPath, file)); - - zipFile.addFile(file, relativePath); - } - - zipFile.end(); - }); + const baseDirectoryPath: string = path.dirname(filePath); + const zipPath: string = path.join(process.cwd(), this.generateRandomFilename(15) + ".zip"); + await createZip( + zipPath, + // yazl does not like backslash (\) in the metadata path. + files.map((file) => [file, normalizePath(path.relative(baseDirectoryPath, file))]) + ); + return { isTemporary: true, path: zipPath }; } private generateRandomFilename(length: number): string { diff --git a/script/utils/file-utils.ts b/script/utils/file-utils.ts index 5f389dc..ae0597e 100644 --- a/script/utils/file-utils.ts +++ b/script/utils/file-utils.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import * as yauzl from "yauzl"; +import * as yazl from "yazl"; import { pipeline } from "node:stream/promises"; import superagent = require("superagent"); @@ -94,3 +95,26 @@ export async function extractArchive(zipPath: string, extractTo: string) { await pipeline(await zipFile.openReadStreamPromise(entry), fs.createWriteStream(outPath)); } } + +export type ZipEntry = [filePath: string, zipEntryPath: string]; + +/** Writes a zip with each [file on disk, path inside the zip] entry, in order */ +export function createZip(zipPath: string, entries: ZipEntry[]): Promise { + return new Promise((resolve, reject) => { + const zipFile = new yazl.ZipFile(); + const writeStream = fs.createWriteStream(zipPath); + + // yazl reports unreadable input files on the ZipFile itself + zipFile.on("error", reject); + zipFile.outputStream + .pipe(writeStream) + .on("error", reject) + .on("close", () => resolve()); + + for (const [filePath, zipEntryPath] of entries) { + zipFile.addFile(filePath, zipEntryPath); + } + + zipFile.end(); + }); +} diff --git a/script/utils/hermes-utils.ts b/script/utils/hermes-utils.ts index fc98716..9199e52 100644 --- a/script/utils/hermes-utils.ts +++ b/script/utils/hermes-utils.ts @@ -25,13 +25,15 @@ export async function isHermesEnabled({ platform, gradleFile, podFile }: cli.Nat } // pod install writes USE_HERMES into the Xcode project since RN 0.75; older apps set :hermes_enabled in the Podfile -function getIosHermesEnabled(podFile = path.join("ios", "Podfile")): boolean | undefined { - const iosDir = path.dirname(podFile); +function getIosHermesEnabled(podFile?: string | null): boolean | undefined { + // The parser passes null when --podFile isn't given + const podfilePath: string = podFile || path.join("ios", "Podfile"); + const iosDir = path.dirname(podfilePath); const xcodeProjects = fs.existsSync(iosDir) ? fs.readdirSync(iosDir).filter((file) => file.endsWith(".xcodeproj")) : []; const pbxprojs = xcodeProjects.map((project) => path.join(iosDir, project, "project.pbxproj")); const value = matchInFiles(pbxprojs, /\bUSE_HERMES = "?(true|false)"?;/) ?? - matchInFiles([podFile], /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m); + matchInFiles([podfilePath], /^[^#\n]*:?hermes_enabled\s*(?:=>|:)\s*(true|false)/m); return value === undefined ? undefined : value === "true"; } diff --git a/test/cli.ts b/test/cli.ts index a1024c0..9b16571 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -87,9 +87,26 @@ function releaseReactCommand(overrides: Partial = {}): mandatory: false, rollout: null, platform: "ios", - // Defaults the argument parser sets + // Defaults the argument parser sets: options that aren't given are null, not undefined + buildNumber: null, + bundleName: null, + development: false, + disabled: false, + entryFile: null, extraBundlerOptions: [], extraHermesFlags: [], + force: false, + gradleFile: null, + initial: false, + noDuplicateReleaseError: false, + outputDir: null, + plistFile: null, + plistFilePrefix: null, + podFile: null, + privateKeyPath: null, + sourcemapOutput: null, + useHermes: false, + xcodeProjectFile: null, ...overrides, }; } diff --git a/test/hermes-utils.ts b/test/hermes-utils.ts index 08a046d..93a1291 100644 --- a/test/hermes-utils.ts +++ b/test/hermes-utils.ts @@ -225,6 +225,12 @@ describe("hermes-utils", () => { assert.equal(await isHermesEnabled({ platform: "ios" }), false); }); + it("looks in ios/ when the parser passes no Podfile (null)", async () => { + project.write("ios/Podfile", "use_react_native!(\n :hermes_enabled => false\n)\n"); + + assert.equal(await isHermesEnabled({ platform: "ios", podFile: null }), false); + }); + it("reads the USE_HERMES build setting pod install writes into the Xcode project", async () => { project.write("ios/Podfile", "use_react_native!(\n :hermes_enabled => true\n)\n"); project.write("ios/App.xcodeproj/project.pbxproj", "buildSettings = {\n\t\t\t\tUSE_HERMES = false;\n};\n"); From 4945f77dd8ac88edc32737c1ab4878b134251b44 Mon Sep 17 00:00:00 2001 From: Revopush Date: Thu, 15 Oct 2026 20:08:07 +0300 Subject: [PATCH 2/3] Describe react-native and expo bundling as two bundler objects instead of if/else Co-Authored-By: Claude Opus 5.5 --- script/command-executor.ts | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/script/command-executor.ts b/script/command-executor.ts index 1af6dc7..56ef6c5 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -843,17 +843,29 @@ function printReleaseSizeWarnings(analysis: ReleaseSizeAnalysis | null, force: b } } -type ReactBundler = "react-native" | "expo"; +// The only steps where release-react and release-expo differ +interface ReactBundler { + resolveEntryFile: boolean; + bundle(options: BundleOptions, entryFile: string, isHermes: boolean): Promise; +} + +const reactNativeBundler: ReactBundler = { + resolveEntryFile: true, + bundle: (options, entryFile, isHermes) => runReactNativeBundleCommand(options, entryFile, isHermes), +}; + +// Expo finds the JS entry file itself +const expoBundler: ReactBundler = { + resolveEntryFile: false, + bundle: (options) => runExpoExportEmbedCommand(options), +}; -export const releaseReact = (command: cli.IReleaseReactCommand): Promise => releaseReactBundle(command, "react-native"); +export const releaseReact = (command: cli.IReleaseReactCommand): Promise => releaseReactBundle(command, reactNativeBundler); -export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => releaseReactBundle(command, "expo"); +export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => releaseReactBundle(command, expoBundler); async function releaseReactBundle(command: cli.IReleaseReactCommand, bundler: ReactBundler): Promise { - const { platform, bundleName, entryFile, projectName } = validateReactReleaseCommand(command, { - // Only the react-native bundler takes the JS entry file; Expo resolves it itself - resolveEntryFile: bundler === "react-native", - }); + const { platform, bundleName, entryFile, projectName } = validateReactReleaseCommand(command, bundler); const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); const sourcemapOutputFolder: string = command.sourcemapOutput || path.join(os.tmpdir(), "CodePushSourceMap"); @@ -887,11 +899,7 @@ async function releaseReactBundle(command: cli.IReleaseReactCommand, bundler: Re await deleteReactTempFolders(); const { isHermes, sourceMaps, bundleOptions } = await planBundle(command, outputFolder, bundleName, sourcemapOutputFolder); - if (bundler === "expo") { - await runExpoExportEmbedCommand(bundleOptions); - } else { - await runReactNativeBundleCommand(bundleOptions, entryFile, isHermes); - } + await bundler.bundle(bundleOptions, entryFile, isHermes); if (isHermes) { await compileHermesBundle(command, outputFolder, bundleName, sourceMaps, baseReleaseTmpFolder); From 9ba500a2e1e1198c6b5af5ce7f25d1dc3f34f8b4 Mon Sep 17 00:00:00 2001 From: Revopush Date: Thu, 15 Oct 2026 20:16:33 +0300 Subject: [PATCH 3/3] Give release-react and release-expo their own parser cases Co-Authored-By: Claude Opus 5.5 --- script/command-parser.ts | 80 +++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/script/command-parser.ts b/script/command-parser.ts index 6c65f5f..84dd65d 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -1317,41 +1317,14 @@ export function createCommand(): cli.ICommand { break; case "release-react": + if (arg1 && arg2) { + cmd = getReleaseReactCommand(cli.CommandType.releaseReact, arg1, arg2, argv); + } + break; + case "release-expo": if (arg1 && arg2) { - cmd = { type: arg0 === "release-expo" ? cli.CommandType.releaseExpo : cli.CommandType.releaseReact }; - - const releaseReactCommand = cmd as cli.IReleaseReactCommand; - - releaseReactCommand.appName = arg1; - releaseReactCommand.platform = arg2; - - releaseReactCommand.appStoreVersion = argv["targetBinaryVersion"] as any; - releaseReactCommand.buildNumber = argv["buildNumber"] as any; - releaseReactCommand.bundleName = argv["bundleName"] as any; - releaseReactCommand.deploymentName = argv["deploymentName"] as any; - releaseReactCommand.disabled = argv["disabled"] as any; - releaseReactCommand.description = argv["description"] ? backslash(argv["description"]) : ""; - releaseReactCommand.development = argv["development"] as any; - releaseReactCommand.entryFile = argv["entryFile"] as any; - releaseReactCommand.gradleFile = argv["gradleFile"] as any; - releaseReactCommand.mandatory = argv["mandatory"] as any; - releaseReactCommand.initial = argv["initial"] as any; - releaseReactCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"] as any; - releaseReactCommand.plistFile = argv["plistFile"] as any; - releaseReactCommand.plistFilePrefix = argv["plistFilePrefix"] as any; - releaseReactCommand.rollout = getRolloutValue(argv["rollout"] as any); - releaseReactCommand.sourcemapOutput = argv["sourcemapOutput"] as any; - releaseReactCommand.outputDir = argv["outputDir"] as any; - releaseReactCommand.useHermes = argv["useHermes"] as any; - releaseReactCommand.extraHermesFlags = argv["extraHermesFlags"] as any; - releaseReactCommand.podFile = argv["podFile"] as any; - releaseReactCommand.privateKeyPath = argv["privateKeyPath"] as any; - releaseReactCommand.xcodeProjectFile = argv["xcodeProjectFile"] as any; - releaseReactCommand.xcodeTargetName = argv["xcodeTargetName"] as any; - releaseReactCommand.buildConfigurationName = argv["buildConfigurationName"] as any; - releaseReactCommand.extraBundlerOptions = argv["extraBundlerOption"] as any; - releaseReactCommand.force = argv["force"] as any; + cmd = getReleaseReactCommand(cli.CommandType.releaseExpo, arg1, arg2, argv); } break; @@ -1416,6 +1389,47 @@ export function createCommand(): cli.ICommand { } } +function getReleaseReactCommand( + type: cli.CommandType, + appName: string, + platform: string, + argv: yargs.Arguments +): cli.IReleaseReactCommand { + const releaseReactCommand = { type } as cli.IReleaseReactCommand; + + releaseReactCommand.appName = appName; + releaseReactCommand.platform = platform; + + releaseReactCommand.appStoreVersion = argv["targetBinaryVersion"] as any; + releaseReactCommand.buildNumber = argv["buildNumber"] as any; + releaseReactCommand.bundleName = argv["bundleName"] as any; + releaseReactCommand.deploymentName = argv["deploymentName"] as any; + releaseReactCommand.disabled = argv["disabled"] as any; + releaseReactCommand.description = argv["description"] ? backslash(argv["description"]) : ""; + releaseReactCommand.development = argv["development"] as any; + releaseReactCommand.entryFile = argv["entryFile"] as any; + releaseReactCommand.gradleFile = argv["gradleFile"] as any; + releaseReactCommand.mandatory = argv["mandatory"] as any; + releaseReactCommand.initial = argv["initial"] as any; + releaseReactCommand.noDuplicateReleaseError = argv["noDuplicateReleaseError"] as any; + releaseReactCommand.plistFile = argv["plistFile"] as any; + releaseReactCommand.plistFilePrefix = argv["plistFilePrefix"] as any; + releaseReactCommand.rollout = getRolloutValue(argv["rollout"] as any); + releaseReactCommand.sourcemapOutput = argv["sourcemapOutput"] as any; + releaseReactCommand.outputDir = argv["outputDir"] as any; + releaseReactCommand.useHermes = argv["useHermes"] as any; + releaseReactCommand.extraHermesFlags = argv["extraHermesFlags"] as any; + releaseReactCommand.podFile = argv["podFile"] as any; + releaseReactCommand.privateKeyPath = argv["privateKeyPath"] as any; + releaseReactCommand.xcodeProjectFile = argv["xcodeProjectFile"] as any; + releaseReactCommand.xcodeTargetName = argv["xcodeTargetName"] as any; + releaseReactCommand.buildConfigurationName = argv["buildConfigurationName"] as any; + releaseReactCommand.extraBundlerOptions = argv["extraBundlerOption"] as any; + releaseReactCommand.force = argv["force"] as any; + + return releaseReactCommand; +} + function isValidRollout(args: any): boolean { const rollout: string = args["rollout"]; if (rollout && !ROLLOUT_PERCENTAGE_REGEX.test(rollout)) {