From 385c7eab93cb03ee1c0e49750b8b1062d6bc8e0e Mon Sep 17 00:00:00 2001 From: Revopush Date: Sun, 5 Apr 2026 22:51:05 +0300 Subject: [PATCH 1/6] add version code --- script/command-executor.ts | 40 +++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/script/command-executor.ts b/script/command-executor.ts index 3f74d93..fa56753 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -922,8 +922,12 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj if (parsedPlist && parsedPlist.CFBundleShortVersionString) { if (isValidVersion(parsedPlist.CFBundleShortVersionString)) { - log(`Using the target binary version value "${parsedPlist.CFBundleShortVersionString}" from "${resolvedPlistFile}".\n`); - return Q(parsedPlist.CFBundleShortVersionString); + let appVersion: string = parsedPlist.CFBundleShortVersionString; + if (parsedPlist.CFBundleVersion) { + appVersion = `${appVersion}-${parsedPlist.CFBundleVersion}`; + } + log(`Using the target binary version value "${appVersion}" from "${resolvedPlistFile}".\n`); + return Q(appVersion); } else { if (parsedPlist.CFBundleShortVersionString !== "$(MARKETING_VERSION)") { throw new Error( @@ -956,6 +960,7 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj }) .then((buildGradle: any) => { let versionName: string = null; + let versionCode: string = null; // First 'if' statement was implemented as workaround for case // when 'build.gradle' file contains several 'android' nodes. @@ -966,11 +971,13 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj const gradlePart = buildGradle.android[i]; if (gradlePart.defaultConfig && gradlePart.defaultConfig.versionName) { versionName = gradlePart.defaultConfig.versionName; + versionCode = gradlePart.defaultConfig.versionCode || null; break; } } } else if (buildGradle.android && buildGradle.android.defaultConfig && buildGradle.android.defaultConfig.versionName) { versionName = buildGradle.android.defaultConfig.versionName; + versionCode = buildGradle.android.defaultConfig.versionCode || null; } else { throw new Error( `The "${buildGradlePath}" file doesn't specify a value for the "android.defaultConfig.versionName" property.` @@ -988,6 +995,9 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj if (isValidVersion(appVersion)) { // The versionName property is a valid semver string, // so we can safely use that and move on. + if (versionCode) { + appVersion = `${appVersion}-${versionCode}`; + } log(`Using the target binary version value "${appVersion}" from "${buildGradlePath}".\n`); return appVersion; } else if (/^\d.*/.test(appVersion)) { @@ -1034,6 +1044,9 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj ); } + if (versionCode) { + appVersion = `${appVersion}-${versionCode}`; + } log(`Using the target binary version value "${appVersion}" from the "${propertyName}" key in the "${propertiesFile}" file.\n`); return appVersion.toString(); }); @@ -1105,9 +1118,13 @@ function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projec `The "MARKETING_VERSION" key in the "${resolvedPbxprojFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` ); } - console.log(`Using the target binary version value "${marketingVersion}" from "${resolvedPbxprojFile}".\n`); - return marketingVersion; + const currentProjectVersion = xcodeProj.getBuildProperty("CURRENT_PROJECT_VERSION", command.buildConfigurationName, command.xcodeTargetName); + const appVersion = currentProjectVersion ? `${marketingVersion}-${currentProjectVersion}` : marketingVersion; + + console.log(`Using the target binary version value "${appVersion}" from "${resolvedPbxprojFile}".\n`); + + return appVersion; } function printJson(object: any): void { @@ -1642,23 +1659,28 @@ export const releaseNative = (command: cli.IReleaseNativeCommand): Promise await extractIPA(targetBinaryPath, extractFolder); const metadataZip = await extractMetadataFromIOS(extractFolder, outputFolder); const buildVersion = await getIosVersion(extractFolder); - releaseCommandPartial = { package: metadataZip, appStoreVersion: buildVersion?.version }; + const iosAppStoreVersion = buildVersion?.build + ? `${buildVersion.version}-${buildVersion.build}` + : buildVersion?.version; + releaseCommandPartial = { package: metadataZip, appStoreVersion: iosAppStoreVersion }; } else { if (targetBinaryPathNormalised.endsWith(".apk")) { log(chalk.cyan(`\nExtracting APK/ARR file:\n`)); await extractAPK(targetBinaryPath, extractFolder); const reader = await ApkReader.open(targetBinaryPath); - const { versionName: appStoreVersion } = await reader.readManifest(); + const { versionName, versionCode } = await reader.readManifest(); + const apkAppStoreVersion = versionCode ? `${versionName}-${versionCode}` : versionName; const metadataZip = await extractMetadataFromAndroid(extractFolder, outputFolder); - releaseCommandPartial = { package: metadataZip, appStoreVersion }; + releaseCommandPartial = { package: metadataZip, appStoreVersion: apkAppStoreVersion }; } else if (targetBinaryPathNormalised.endsWith(".aab")) { log(chalk.cyan(`\nExtracting AAB file:\n`)); await extractAAB(targetBinaryPath, extractFolder); - const { versionName: appStoreVersion } = await aabParser.parseAabManifest(targetBinaryPath); + const { versionName, versionCode } = await aabParser.parseAabManifest(targetBinaryPath); + const aabAppStoreVersion = versionCode ? `${versionName}-${versionCode}` : versionName; const metadataZip = await extractMetadataFromAndroid(`${extractFolder}/base`, outputFolder); // base folder is nested in AAB - releaseCommandPartial = { package: metadataZip, appStoreVersion }; + releaseCommandPartial = { package: metadataZip, appStoreVersion: aabAppStoreVersion }; } else { throw new Error("For Android platform, target binary must be an .apk or .aab file."); } From 7cf29f02918b811512b8726129ac8c1f9583f5ae Mon Sep 17 00:00:00 2001 From: Revopush Date: Sun, 5 Apr 2026 23:23:59 +0300 Subject: [PATCH 2/6] use min version to conditionally enable build number --- script/command-executor.ts | 26 +++++++++----------------- script/react-native-utils.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/script/command-executor.ts b/script/command-executor.ts index fa56753..1e71cd3 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -32,6 +32,7 @@ import { UpdateMetrics, } from "../script/types"; import { + buildAppVersion, getBundleSourceMapOutput, getMinifyParams, getReactNativePackagePath, @@ -922,10 +923,7 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj if (parsedPlist && parsedPlist.CFBundleShortVersionString) { if (isValidVersion(parsedPlist.CFBundleShortVersionString)) { - let appVersion: string = parsedPlist.CFBundleShortVersionString; - if (parsedPlist.CFBundleVersion) { - appVersion = `${appVersion}-${parsedPlist.CFBundleVersion}`; - } + const appVersion: string = buildAppVersion(parsedPlist.CFBundleShortVersionString, parsedPlist.CFBundleVersion); log(`Using the target binary version value "${appVersion}" from "${resolvedPlistFile}".\n`); return Q(appVersion); } else { @@ -960,7 +958,7 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj }) .then((buildGradle: any) => { let versionName: string = null; - let versionCode: string = null; + let versionCode: number | null = null; // First 'if' statement was implemented as workaround for case // when 'build.gradle' file contains several 'android' nodes. @@ -995,9 +993,7 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj if (isValidVersion(appVersion)) { // The versionName property is a valid semver string, // so we can safely use that and move on. - if (versionCode) { - appVersion = `${appVersion}-${versionCode}`; - } + appVersion = buildAppVersion(appVersion, versionCode); log(`Using the target binary version value "${appVersion}" from "${buildGradlePath}".\n`); return appVersion; } else if (/^\d.*/.test(appVersion)) { @@ -1044,9 +1040,7 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj ); } - if (versionCode) { - appVersion = `${appVersion}-${versionCode}`; - } + appVersion = buildAppVersion(appVersion, versionCode); log(`Using the target binary version value "${appVersion}" from the "${propertyName}" key in the "${propertiesFile}" file.\n`); return appVersion.toString(); }); @@ -1120,7 +1114,7 @@ function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projec } const currentProjectVersion = xcodeProj.getBuildProperty("CURRENT_PROJECT_VERSION", command.buildConfigurationName, command.xcodeTargetName); - const appVersion = currentProjectVersion ? `${marketingVersion}-${currentProjectVersion}` : marketingVersion; + const appVersion = buildAppVersion(marketingVersion, currentProjectVersion); console.log(`Using the target binary version value "${appVersion}" from "${resolvedPbxprojFile}".\n`); @@ -1659,9 +1653,7 @@ export const releaseNative = (command: cli.IReleaseNativeCommand): Promise await extractIPA(targetBinaryPath, extractFolder); const metadataZip = await extractMetadataFromIOS(extractFolder, outputFolder); const buildVersion = await getIosVersion(extractFolder); - const iosAppStoreVersion = buildVersion?.build - ? `${buildVersion.version}-${buildVersion.build}` - : buildVersion?.version; + const iosAppStoreVersion = buildAppVersion(buildVersion.version, buildVersion.build); releaseCommandPartial = { package: metadataZip, appStoreVersion: iosAppStoreVersion }; } else { if (targetBinaryPathNormalised.endsWith(".apk")) { @@ -1670,14 +1662,14 @@ export const releaseNative = (command: cli.IReleaseNativeCommand): Promise const reader = await ApkReader.open(targetBinaryPath); const { versionName, versionCode } = await reader.readManifest(); - const apkAppStoreVersion = versionCode ? `${versionName}-${versionCode}` : versionName; + const apkAppStoreVersion = buildAppVersion(versionName, versionCode); const metadataZip = await extractMetadataFromAndroid(extractFolder, outputFolder); releaseCommandPartial = { package: metadataZip, appStoreVersion: apkAppStoreVersion }; } else if (targetBinaryPathNormalised.endsWith(".aab")) { log(chalk.cyan(`\nExtracting AAB file:\n`)); await extractAAB(targetBinaryPath, extractFolder); const { versionName, versionCode } = await aabParser.parseAabManifest(targetBinaryPath); - const aabAppStoreVersion = versionCode ? `${versionName}-${versionCode}` : versionName; + const aabAppStoreVersion = buildAppVersion(versionName, versionCode); const metadataZip = await extractMetadataFromAndroid(`${extractFolder}/base`, outputFolder); // base folder is nested in AAB releaseCommandPartial = { package: metadataZip, appStoreVersion: aabAppStoreVersion }; diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 7518366..12d3c5d 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as chalk from "chalk"; import * as path from "path"; import * as childProcess from "child_process"; -import { coerce, compare, valid } from "semver"; +import { coerce, compare, gte, valid } from "semver"; import { downloadBlob, extractIPA, fileDoesNotExistOrIsDirectory } from "./utils/file-utils"; import * as dotenv from "dotenv"; import { DotenvParseOutput } from "dotenv"; @@ -493,3 +493,33 @@ export function getReactNativeVersion(): string { ); } } + +function getRevopushCodePushVersion(): string | null { + try { + const result = childProcess.spawnSync("node", ["--print", "require('@revopush/react-native-code-push/package.json').version"]); + if (result.status !== 0 || !result.stdout) { + return null; + } + return result.stdout.toString().trim(); + } catch { + return null; + } +} + +const BUILD_NUMBER_MIN_VERSION = "2.0.0"; + +function isBuildNumberSupported(): boolean { + const version = getRevopushCodePushVersion(); + if (!version) { + return false; + } + const coerced = coerce(version); + return coerced ? gte(coerced, BUILD_NUMBER_MIN_VERSION) : false; +} + +export function buildAppVersion(version: string, buildNumber: string | number | undefined): string { + if (buildNumber && isBuildNumberSupported()) { + return `${version}-${buildNumber}`; + } + return version; +} From 57986aac6cb715b262be349f0965601257e3fff0 Mon Sep 17 00:00:00 2001 From: Revopush Date: Sun, 12 Apr 2026 02:14:55 +0300 Subject: [PATCH 3/6] add build number support --- package-lock.json | 26 --- package.json | 1 - script/command-executor.ts | 275 +++++++++++++++++-------------- script/command-parser.ts | 36 ++++ script/types/cli.ts | 1 + script/types/rest-definitions.ts | 1 + 6 files changed, 185 insertions(+), 155 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9fd5199..6d3b92f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,6 @@ "which": "^1.2.7", "wordwrap": "1.0.0", "xcode": "^3.0.1", - "xml2js": "^0.6.0", "yargs": "^17.7.2", "yazl": "^2.5.1" }, @@ -4474,11 +4473,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, - "node_modules/sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -5287,26 +5281,6 @@ "node": ">=10.0.0" } }, - "node_modules/xml2js": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", - "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 8c6356f..659d43b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "which": "^1.2.7", "wordwrap": "1.0.0", "xcode": "^3.0.1", - "xml2js": "^0.6.0", "yargs": "^17.7.2", "yazl": "^2.5.1" }, diff --git a/script/command-executor.ts b/script/command-executor.ts index 3f74d93..5d62feb 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -65,7 +65,6 @@ const xcode = require("xcode"); const configFilePath: string = path.join(process.env.LOCALAPPDATA || process.env.HOME, ".revopush.config"); const emailValidator = require("email-validator"); const packageJson = require("../../package.json"); -const parseXml = Q.denodeify(require("xml2js").parseString); const properties = require("properties"); @@ -875,7 +874,12 @@ function getPackageMetricsString(obj: Package): string { return returnString; } -function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, projectName: string): Promise { +interface ProjectVersionInfo { + appVersion?: string; + buildNumber?: string; +} + +function getReactNativeProjectVersionInfo(command: cli.IReleaseReactCommand, projectName: string): Promise { log(chalk.cyan(`Detecting ${command.platform} app version:\n`)); if (command.platform === "ios") { @@ -894,7 +898,7 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj command.plistFilePrefix += "-"; } - const iOSDirectory: string = "ios"; + const iOSDirectory = "ios"; const plistFileName = `${command.plistFilePrefix || ""}Info.plist`; const knownLocations = [path.join(iOSDirectory, projectName, plistFileName), path.join(iOSDirectory, plistFileName)]; @@ -912,7 +916,7 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj const plistContents = fs.readFileSync(resolvedPlistFile).toString(); - let parsedPlist; + let parsedPlist: any; try { parsedPlist = plist.parse(plistContents); @@ -920,22 +924,21 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj throw new Error(`Unable to parse "${resolvedPlistFile}". Please ensure it is a well-formed plist file.`); } - if (parsedPlist && parsedPlist.CFBundleShortVersionString) { - if (isValidVersion(parsedPlist.CFBundleShortVersionString)) { - log(`Using the target binary version value "${parsedPlist.CFBundleShortVersionString}" from "${resolvedPlistFile}".\n`); - return Q(parsedPlist.CFBundleShortVersionString); - } else { - if (parsedPlist.CFBundleShortVersionString !== "$(MARKETING_VERSION)") { - throw new Error( - `The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` - ); - } + const rawShortVersion: string | undefined = parsedPlist.CFBundleShortVersionString; + const rawBundleVersion: string | undefined = parsedPlist.CFBundleVersion; - return getAppVersionFromXcodeProject(command, projectName); - } - } else { - throw new Error(`The "CFBundleShortVersionString" key doesn't exist within the "${resolvedPlistFile}" file.`); + // Both keys may reference Xcode build settings — delegate to the project file if either does + if (rawShortVersion === "$(MARKETING_VERSION)" || rawBundleVersion === "$(CURRENT_PROJECT_VERSION)") { + return getAppVersionFromXcodeProject(command, projectName); + } + + if (rawShortVersion && !isValidVersion(rawShortVersion)) { + throw new Error( + `The "CFBundleShortVersionString" key in the "${resolvedPlistFile}" file needs to specify a valid semver string (e.g. 1.3.2).` + ); } + + return Q({ appVersion: rawShortVersion, buildNumber: rawBundleVersion }); } else if (command.platform === "android") { let buildGradlePath: string = path.join("android", "app"); if (command.gradleFile) { @@ -955,119 +958,103 @@ function getReactNativeProjectAppVersion(command: cli.IReleaseReactCommand, proj throw new Error(`Unable to parse the "${buildGradlePath}" file. Please ensure it is a well-formed Gradle file.`); }) .then((buildGradle: any) => { - let versionName: string = null; + const warnMissingBuildNumber = () => log(chalk.yellow( + `Warning: Unable to read "android.defaultConfig.versionCode" from "${buildGradlePath}". ` + + `This is expected if it is set dynamically (e.g. on CI). ` + + `Pass --buildNumber explicitly to include it in the release.` + )); + + const knownLocations = [path.join("android", "app", "gradle.properties"), path.join("android", "gradle.properties")]; + + const parsePropertiesFile = (filePath: string): any | null => { + if (!fileExists(filePath)) return null; + try { + return properties.parse(fs.readFileSync(filePath).toString()); + } catch (e) { + throw new Error(`Unable to parse "${filePath}". Please ensure it is a well-formed properties file.`); + } + }; + + let versionName: string | null = null; + let versionCode: string | number | null = null; // First 'if' statement was implemented as workaround for case // when 'build.gradle' file contains several 'android' nodes. // In this case 'buildGradle.android' prop represents array instead of object // due to parsing issue in 'g2js.parseFile' method. if (buildGradle.android instanceof Array) { - for (let i = 0; i < buildGradle.android.length; i++) { - const gradlePart = buildGradle.android[i]; - if (gradlePart.defaultConfig && gradlePart.defaultConfig.versionName) { - versionName = gradlePart.defaultConfig.versionName; - break; + for (const gradlePart of buildGradle.android) { + if (gradlePart.defaultConfig) { + versionName = versionName ?? gradlePart.defaultConfig.versionName ?? null; + versionCode = versionCode ?? gradlePart.defaultConfig.versionCode ?? null; + if (versionName !== null && versionCode !== null) break; } } - } else if (buildGradle.android && buildGradle.android.defaultConfig && buildGradle.android.defaultConfig.versionName) { - versionName = buildGradle.android.defaultConfig.versionName; - } else { - throw new Error( - `The "${buildGradlePath}" file doesn't specify a value for the "android.defaultConfig.versionName" property.` - ); + } else if (buildGradle.android && buildGradle.android.defaultConfig) { + versionName = buildGradle.android.defaultConfig.versionName ?? null; + versionCode = buildGradle.android.defaultConfig.versionCode ?? null; } - if (typeof versionName !== "string") { - throw new Error( - `The "android.defaultConfig.versionName" property value in "${buildGradlePath}" is not a valid string. If this is expected, consider using the --targetBinaryVersion option to specify the value manually.` - ); + // versionCode may be a direct value or a property reference (non-numeric string) + const versionCodeProperty = typeof versionCode === "string" && !/^\d+$/.test(versionCode) + ? versionCode.replace("project.", "") + : null; + let buildNumber: string | undefined = versionCodeProperty ? undefined : versionCode?.toString(); + + if (!versionName) { + if (!buildNumber) warnMissingBuildNumber(); + return { appVersion: undefined, buildNumber }; } - let appVersion: string = versionName.replace(/"/g, "").trim(); - - if (isValidVersion(appVersion)) { - // The versionName property is a valid semver string, - // so we can safely use that and move on. - log(`Using the target binary version value "${appVersion}" from "${buildGradlePath}".\n`); - return appVersion; - } else if (/^\d.*/.test(appVersion)) { - // The versionName property isn't a valid semver string, - // but it starts with a number, and therefore, it can't - // be a valid Gradle property reference. + const rawAppVersion = versionName.replace(/"/g, "").trim(); + + if (/^\d/.test(rawAppVersion) && !isValidVersion(rawAppVersion)) { + // Starts with a digit but isn't valid semver — can't be a property reference. throw new Error( - `The "android.defaultConfig.versionName" property in the "${buildGradlePath}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` + `The "android.defaultConfig.versionName" property in the "${buildGradlePath}" file needs to specify a valid semver string (e.g. 1.3.2).` ); } - // The version property isn't a valid semver string - // so we assume it is a reference to a property variable. - const propertyName = appVersion.replace("project.", ""); - const propertiesFileName = "gradle.properties"; - - const knownLocations = [path.join("android", "app", propertiesFileName), path.join("android", propertiesFileName)]; - - // Search for gradle properties across all `gradle.properties` files - let propertiesFile: string = null; - for (let i = 0; i < knownLocations.length; i++) { - propertiesFile = knownLocations[i]; - if (fileExists(propertiesFile)) { - const propertiesContent: string = fs.readFileSync(propertiesFile).toString(); - try { - const parsedProperties: any = properties.parse(propertiesContent); - appVersion = parsedProperties[propertyName]; - if (appVersion) { - break; - } - } catch (e) { - throw new Error(`Unable to parse "${propertiesFile}". Please ensure it is a well-formed properties file.`); + // If versionName isn't a valid semver, treat it as a Gradle property reference + const versionNameProperty: string | null = isValidVersion(rawAppVersion) ? null : rawAppVersion.replace("project.", ""); + let appVersion: string | undefined = versionNameProperty ? undefined : rawAppVersion; + + let resolvedPropertiesFile: string | null = null; + if (versionNameProperty || versionCodeProperty) { + for (const propertiesFile of knownLocations) { + const parsed = parsePropertiesFile(propertiesFile); + if (!parsed) continue; + if (versionNameProperty && !appVersion) { + appVersion = parsed[versionNameProperty]; + if (appVersion) resolvedPropertiesFile = propertiesFile; + } + if (versionCodeProperty && !buildNumber) { + buildNumber = parsed[versionCodeProperty]; } + if ((!versionNameProperty || appVersion) && (!versionCodeProperty || buildNumber)) break; } - } - - if (!appVersion) { - throw new Error(`No property named "${propertyName}" exists in the "${propertiesFile}" file.`); - } - if (!isValidVersion(appVersion)) { - throw new Error( - `The "${propertyName}" property in the "${propertiesFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` - ); + if (versionNameProperty && !appVersion) { + throw new Error(`No property named "${versionNameProperty}" exists in the following files: ${knownLocations.join(", ")}.`); + } + if (versionNameProperty && !isValidVersion(appVersion)) { + throw new Error( + `The "${versionNameProperty}" property in the "${resolvedPropertiesFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` + ); + } } - log(`Using the target binary version value "${appVersion}" from the "${propertyName}" key in the "${propertiesFile}" file.\n`); - return appVersion.toString(); - }); - } else { - const appxManifestFileName: string = "Package.appxmanifest"; - let appxManifestContainingFolder: string; - let appxManifestContents: string; - - try { - appxManifestContainingFolder = path.join("windows", projectName); - appxManifestContents = fs.readFileSync(path.join(appxManifestContainingFolder, "Package.appxmanifest")).toString(); - } catch (err) { - throw new Error(`Unable to find or read "${appxManifestFileName}" in the "${path.join("windows", projectName)}" folder.`); - } + if (!buildNumber) warnMissingBuildNumber(); - return parseXml(appxManifestContents) - .catch((err: any) => { - throw new Error( - `Unable to parse the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file, it could be malformed.` - ); - }) - .then((parsedAppxManifest: any) => { - try { - return parsedAppxManifest.Package.Identity[0]["$"].Version.match(/^\d+\.\d+\.\d+/)[0]; - } catch (e) { - throw new Error( - `Unable to parse the package version from the "${path.join(appxManifestContainingFolder, appxManifestFileName)}" file.` - ); - } + return { appVersion, buildNumber }; }); } } -function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projectName: string): Promise { +function getAppVersionFromXcodeProject( + command: cli.IReleaseReactCommand, + projectName: string +): Promise<{ appVersion: string; buildNumber: string }> { const pbxprojFileName = "project.pbxproj"; let resolvedPbxprojFile: string = command.xcodeProjectFile; if (resolvedPbxprojFile) { @@ -1105,9 +1092,17 @@ function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projec `The "MARKETING_VERSION" key in the "${resolvedPbxprojFile}" file needs to specify a valid semver string, containing both a major and minor version (e.g. 1.3.2, 1.1).` ); } - console.log(`Using the target binary version value "${marketingVersion}" from "${resolvedPbxprojFile}".\n`); - return marketingVersion; + const currentProjectVersion = xcodeProj.getBuildProperty( + "CURRENT_PROJECT_VERSION", + command.buildConfigurationName, + command.xcodeTargetName + ); + if (!currentProjectVersion) { + throw new Error(`The "CURRENT_PROJECT_VERSION" key doesn't exist in the "${resolvedPbxprojFile}" file.`); + } + + return Q({ appVersion: marketingVersion, buildNumber: String(currentProjectVersion) }); } function printJson(object: any): void { @@ -1229,6 +1224,7 @@ export const release = (command: cli.IReleaseCommand): Promise => { isInitial: command.initial, rollout: command.initial ? undefined : command.rollout, appVersion: command.appStoreVersion, + buildNumber: command.buildNumber, }; return doRelease(command, updateMetadata); @@ -1378,19 +1374,23 @@ export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => // } // } - const appVersionPromise: Promise = command.appStoreVersion - ? Q(command.appStoreVersion) - : getReactNativeProjectAppVersion(command, projectName); + const versionInfoPromise: Promise = (command.appStoreVersion && command.buildNumber) + ? Q({ appVersion: command.appStoreVersion, buildNumber: command.buildNumber }) + : getReactNativeProjectVersionInfo(command, projectName).then((detected) => ({ + appVersion: command.appStoreVersion || detected.appVersion, + buildNumber: command.buildNumber || detected.buildNumber, + })); if (!sourcemapOutputFolder.endsWith(".map") && !command.sourcemapOutput) { await createEmptyTempReleaseFolder(sourcemapOutputFolder); } - return appVersionPromise; + return versionInfoPromise; }) - .then((appVersion: string) => { + .then(({ appVersion, buildNumber }: ProjectVersionInfo) => { throwForInvalidSemverRange(appVersion); releaseCommand.appStoreVersion = appVersion; + releaseCommand.buildNumber = buildNumber; return createEmptyTempReleaseFolder(outputFolder); }) @@ -1519,20 +1519,24 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = } } - const appVersionPromise: Promise = command.appStoreVersion - ? Q(command.appStoreVersion) - : getReactNativeProjectAppVersion(command, projectName); + const versionInfoPromise: Promise = (command.appStoreVersion && command.buildNumber) + ? Q({ appVersion: command.appStoreVersion, buildNumber: command.buildNumber }) + : getReactNativeProjectVersionInfo(command, projectName).then((detected) => ({ + appVersion: command.appStoreVersion || detected.appVersion, + buildNumber: command.buildNumber || detected.buildNumber, + })); if (!sourcemapOutputFolder.endsWith(".map") && !command.sourcemapOutput) { // create tmp dir only if no dir was given by user. User must crete a directory if --sourcemapOutput is passes await createEmptyTempReleaseFolder(sourcemapOutputFolder); } - return appVersionPromise; + return versionInfoPromise; }) - .then((appVersion: string) => { + .then(({ appVersion, buildNumber }: ProjectVersionInfo) => { throwForInvalidSemverRange(appVersion); releaseCommand.appStoreVersion = appVersion; + releaseCommand.buildNumber = buildNumber; return createEmptyTempReleaseFolder(outputFolder); }) @@ -1618,10 +1622,7 @@ export const releaseNative = (command: cli.IReleaseNativeCommand): Promise if (platform === "ios" && !targetBinaryPathNormalised.endsWith(".ipa")) { throw new Error("For iOS platform, target binary must be an .ipa file."); } - if ( - platform === "android" && - !(targetBinaryPathNormalised.endsWith(".apk") || targetBinaryPathNormalised.endsWith(".aab")) - ) { + if (platform === "android" && !(targetBinaryPathNormalised.endsWith(".apk") || targetBinaryPathNormalised.endsWith(".aab"))) { throw new Error("For Android platform, target binary must be an .apk or .aab file."); } @@ -1642,35 +1643,48 @@ export const releaseNative = (command: cli.IReleaseNativeCommand): Promise await extractIPA(targetBinaryPath, extractFolder); const metadataZip = await extractMetadataFromIOS(extractFolder, outputFolder); const buildVersion = await getIosVersion(extractFolder); - releaseCommandPartial = { package: metadataZip, appStoreVersion: buildVersion?.version }; + releaseCommandPartial = { + package: metadataZip, + appStoreVersion: buildVersion?.version, + buildNumber: buildVersion?.build, + }; } else { if (targetBinaryPathNormalised.endsWith(".apk")) { log(chalk.cyan(`\nExtracting APK/ARR file:\n`)); await extractAPK(targetBinaryPath, extractFolder); const reader = await ApkReader.open(targetBinaryPath); - const { versionName: appStoreVersion } = await reader.readManifest(); + const { versionName: appStoreVersion, versionCode } = await reader.readManifest(); const metadataZip = await extractMetadataFromAndroid(extractFolder, outputFolder); - releaseCommandPartial = { package: metadataZip, appStoreVersion }; + releaseCommandPartial = { + package: metadataZip, + appStoreVersion, + buildNumber: versionCode?.toString(), + }; } else if (targetBinaryPathNormalised.endsWith(".aab")) { log(chalk.cyan(`\nExtracting AAB file:\n`)); await extractAAB(targetBinaryPath, extractFolder); - const { versionName: appStoreVersion } = await aabParser.parseAabManifest(targetBinaryPath); + const { versionName: appStoreVersion, versionCode } = await aabParser.parseAabManifest(targetBinaryPath); const metadataZip = await extractMetadataFromAndroid(`${extractFolder}/base`, outputFolder); // base folder is nested in AAB - releaseCommandPartial = { package: metadataZip, appStoreVersion }; + releaseCommandPartial = { + package: metadataZip, + appStoreVersion, + buildNumber: versionCode?.toString(), + }; } else { throw new Error("For Android platform, target binary must be an .apk or .aab file."); } } - const { package: metadataZip, appStoreVersion } = releaseCommandPartial; + const { package: metadataZip, appStoreVersion, buildNumber: detectedBuildNumber } = releaseCommandPartial; // Use the zip file as package for release const releaseCommand: cli.IReleaseReactCommand = { type: cli.CommandType.release, appName: command.appName, deploymentName: command.deploymentName, appStoreVersion: command.appStoreVersion || appStoreVersion, + buildNumber: command.buildNumber || detectedBuildNumber, description: command.description, disabled: command.disabled, mandatory: command.mandatory, @@ -1712,6 +1726,7 @@ const releaseReactNative = (command: cli.IReleaseReactCommand): Promise => outputDir: command.outputDir, rollout: command.initial ? undefined : command.rollout, appVersion: command.appStoreVersion, + buildNumber: command.buildNumber, }; return doRelease(command, updateMetadata); @@ -1782,6 +1797,7 @@ const doNativeRelease = (releaseCommand: cli.IReleaseReactCommand): Promise { return checkValidReleaseOptions(argv); }); @@ -860,6 +868,14 @@ yargs description: "Option that gets passed to react-native bundler. Can be specified multiple times.", type: "array", }) + .option("buildNumber", { + alias: "bn", + default: null, + demand: false, + description: + "Build number for this release. If omitted, auto-detected from \"android.defaultConfig.versionCode\" in build.gradle (Android) or \"CFBundleVersion\" in Info.plist (iOS). Override this when the value is set dynamically (e.g. on CI via environment variables).", + type: "string", + }) .check((argv: any, aliases: { [aliases: string]: string }): any => { return checkValidReleaseOptions(argv); }); @@ -1052,6 +1068,14 @@ yargs description: "Option that gets passed to react-native bundler. Can be specified multiple times.", type: "array", }) + .option("buildNumber", { + alias: "bn", + default: null, + demand: false, + description: + "Build number for this release. If omitted, auto-detected from \"android.defaultConfig.versionCode\" in build.gradle (Android) or \"CFBundleVersion\" in Info.plist (iOS). Override this when the value is set dynamically (e.g. on CI via environment variables).", + type: "string", + }) .check((argv: any) => { return checkValidReleaseOptions(argv); }); @@ -1084,6 +1108,14 @@ yargs description: "Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3).", type: "string", }) + .option("buildNumber", { + alias: "bn", + default: null, + demand: false, + description: + "Build number for this release. If omitted, auto-detected from \"android.defaultConfig.versionCode\" in build.gradle (Android) or \"CFBundleVersion\" in Info.plist (iOS). Override this when the value is set dynamically (e.g. on CI via environment variables).", + type: "string", + }) .check((argv: any, aliases: { [aliases: string]: string }): any => { return checkValidReleaseOptions(argv); }); @@ -1450,6 +1482,7 @@ export function createCommand(): cli.ICommand { releaseCommand.appName = arg1; releaseCommand.package = arg2; releaseCommand.appStoreVersion = arg3; + releaseCommand.buildNumber = argv["buildNumber"] as any; releaseCommand.deploymentName = argv["deploymentName"] as any; releaseCommand.description = argv["description"] ? backslash(argv["description"]) : ""; releaseCommand.disabled = argv["disabled"] as any; @@ -1469,6 +1502,7 @@ export function createCommand(): cli.ICommand { 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; @@ -1505,6 +1539,7 @@ export function createCommand(): cli.ICommand { 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; @@ -1542,6 +1577,7 @@ export function createCommand(): cli.ICommand { releaseBinaryCommand.targetBinary = arg3; releaseBinaryCommand.deploymentName = argv["deploymentName"] as any; releaseBinaryCommand.appStoreVersion = argv["targetBinaryVersion"] as any; + releaseBinaryCommand.buildNumber = argv["buildNumber"] as any; releaseBinaryCommand.initial = true; releaseBinaryCommand.disabled = true; releaseBinaryCommand.mandatory = false; diff --git a/script/types/cli.ts b/script/types/cli.ts index 4f6934b..a154b71 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -184,6 +184,7 @@ export interface IRegisterCommand extends ICommand { export interface IReleaseBaseCommand extends ICommand, IPackageInfo { appName: string; appStoreVersion: string; + buildNumber?: string; deploymentName: string; noDuplicateReleaseError?: boolean; privateKeyPath?: string; diff --git a/script/types/rest-definitions.ts b/script/types/rest-definitions.ts index de02f86..b4ddb2c 100644 --- a/script/types/rest-definitions.ts +++ b/script/types/rest-definitions.ts @@ -46,6 +46,7 @@ export interface DownloadReport { /*inout*/ export interface PackageInfo { appVersion?: string; + buildNumber?: string; description?: string; isDisabled?: boolean; isMandatory?: boolean; From adf7fa4d27b3682d505e0cf3a36e4fffc130a6a5 Mon Sep 17 00:00:00 2001 From: Revopush Date: Sun, 12 Apr 2026 02:25:34 +0300 Subject: [PATCH 4/6] delete unused utils --- script/react-native-utils.ts | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 12d3c5d..8cfb801 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -493,33 +493,3 @@ export function getReactNativeVersion(): string { ); } } - -function getRevopushCodePushVersion(): string | null { - try { - const result = childProcess.spawnSync("node", ["--print", "require('@revopush/react-native-code-push/package.json').version"]); - if (result.status !== 0 || !result.stdout) { - return null; - } - return result.stdout.toString().trim(); - } catch { - return null; - } -} - -const BUILD_NUMBER_MIN_VERSION = "2.0.0"; - -function isBuildNumberSupported(): boolean { - const version = getRevopushCodePushVersion(); - if (!version) { - return false; - } - const coerced = coerce(version); - return coerced ? gte(coerced, BUILD_NUMBER_MIN_VERSION) : false; -} - -export function buildAppVersion(version: string, buildNumber: string | number | undefined): string { - if (buildNumber && isBuildNumberSupported()) { - return `${version}-${buildNumber}`; - } - return version; -} From 7d99de933280819da69e1bd0957ac36cc27c4ece Mon Sep 17 00:00:00 2001 From: Revopush Date: Tue, 14 Apr 2026 23:04:55 +0300 Subject: [PATCH 5/6] make build number optional for release-react and release-expo --- script/command-executor.ts | 10 ++++++++-- script/command-parser.ts | 8 ++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/script/command-executor.ts b/script/command-executor.ts index 5d62feb..5e77c0a 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -1374,11 +1374,14 @@ export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => // } // } + // 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 versionInfoPromise: Promise = (command.appStoreVersion && command.buildNumber) ? Q({ appVersion: command.appStoreVersion, buildNumber: command.buildNumber }) : getReactNativeProjectVersionInfo(command, projectName).then((detected) => ({ appVersion: command.appStoreVersion || detected.appVersion, - buildNumber: command.buildNumber || detected.buildNumber, + buildNumber: command.buildNumber, })); if (!sourcemapOutputFolder.endsWith(".map") && !command.sourcemapOutput) { @@ -1519,11 +1522,14 @@ export const releaseReact = (command: cli.IReleaseReactCommand): Promise = } } + // For release-react, 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 versionInfoPromise: Promise = (command.appStoreVersion && command.buildNumber) ? Q({ appVersion: command.appStoreVersion, buildNumber: command.buildNumber }) : getReactNativeProjectVersionInfo(command, projectName).then((detected) => ({ appVersion: command.appStoreVersion || detected.appVersion, - buildNumber: command.buildNumber || detected.buildNumber, + buildNumber: command.buildNumber, })); if (!sourcemapOutputFolder.endsWith(".map") && !command.sourcemapOutput) { diff --git a/script/command-parser.ts b/script/command-parser.ts index 3436e4b..0f33295 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -671,7 +671,7 @@ yargs default: null, demand: false, description: - "Build number for this release. If omitted, auto-detected from \"android.defaultConfig.versionCode\" in build.gradle (Android) or \"CFBundleVersion\" in Info.plist (iOS). Override this when the value is set dynamically (e.g. on CI via environment variables).", + "Targets this release to clients running a specific native binary build — CFBundleVersion on iOS or versionCode on Android. If omitted, the release is a wildcard and is delivered to all clients regardless of their build number.", type: "string", }) .check((argv: any, aliases: { [aliases: string]: string }): any => { @@ -873,7 +873,7 @@ yargs default: null, demand: false, description: - "Build number for this release. If omitted, auto-detected from \"android.defaultConfig.versionCode\" in build.gradle (Android) or \"CFBundleVersion\" in Info.plist (iOS). Override this when the value is set dynamically (e.g. on CI via environment variables).", + "Targets this release to clients running a specific native binary build — CFBundleVersion on iOS or versionCode on Android. If omitted, the release is a wildcard and is delivered to all clients regardless of their build number.", type: "string", }) .check((argv: any, aliases: { [aliases: string]: string }): any => { @@ -1073,7 +1073,7 @@ yargs default: null, demand: false, description: - "Build number for this release. If omitted, auto-detected from \"android.defaultConfig.versionCode\" in build.gradle (Android) or \"CFBundleVersion\" in Info.plist (iOS). Override this when the value is set dynamically (e.g. on CI via environment variables).", + "Targets this release to clients running a specific native binary build — CFBundleVersion on iOS or versionCode on Android. If omitted, the release is a wildcard and is delivered to all clients regardless of their build number.", type: "string", }) .check((argv: any) => { @@ -1113,7 +1113,7 @@ yargs default: null, demand: false, description: - "Build number for this release. If omitted, auto-detected from \"android.defaultConfig.versionCode\" in build.gradle (Android) or \"CFBundleVersion\" in Info.plist (iOS). Override this when the value is set dynamically (e.g. on CI via environment variables).", + "Targets this release to clients running a specific native binary build — CFBundleVersion on iOS or versionCode on Android. If omitted, auto-detected from the target binary (IPA/APK/AAB). Override when the value is set dynamically (e.g. on CI).", type: "string", }) .check((argv: any, aliases: { [aliases: string]: string }): any => { From e611d14da399a87f583407c8fb8d799916e80e64 Mon Sep 17 00:00:00 2001 From: Revopush Date: Mon, 27 Apr 2026 19:51:24 +0300 Subject: [PATCH 6/6] add build number update --- script/command-executor.ts | 26 +++++++++++++++----------- script/command-parser.ts | 16 +++++++++++++--- script/management-sdk.ts | 5 +++-- script/react-native-utils.ts | 2 +- script/types/cli.ts | 1 + script/types/rest-definitions.ts | 2 +- 6 files changed, 34 insertions(+), 18 deletions(-) diff --git a/script/command-executor.ts b/script/command-executor.ts index 5e77c0a..18ec301 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -1198,21 +1198,25 @@ function patch(command: cli.IPatchCommand): Promise { isMandatory: command.mandatory, isDisabled: command.disabled, rollout: command.rollout, + buildNumber: command.buildNumber, // undefined = skip, null = reset to wildcard, string = retarget }; - for (const updateProperty in packageInfo) { - if ((packageInfo)[updateProperty] !== null) { - return sdk.patchRelease(command.appName, command.deploymentName, command.label, packageInfo).then((): void => { - log( - `Successfully updated the "${command.label ? command.label : `latest`}" release of "${command.appName}" app's "${ - command.deploymentName - }" deployment.` - ); - }); - } + // Standard fields use null as "not provided"; buildNumber uses undefined (null means reset). + // Check both to avoid treating an unset buildNumber (undefined) as a valid update. + const hasUpdate = + Object.values(packageInfo).some((v) => v !== null && v !== undefined) || command.buildNumber !== undefined; + + if (!hasUpdate) { + throw new Error("At least one property must be specified to patch a release."); } - throw new Error("At least one property must be specified to patch a release."); + return sdk.patchRelease(command.appName, command.deploymentName, command.label, packageInfo).then((): void => { + log( + `Successfully updated the "${command.label ? command.label : `latest`}" release of "${command.appName}" app's "${ + command.deploymentName + }" deployment.` + ); + }); } export const release = (command: cli.IReleaseCommand): Promise => { diff --git a/script/command-parser.ts b/script/command-parser.ts index 0f33295..fe1334e 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -520,6 +520,14 @@ yargs description: "Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3).", type: "string", }) + .option("buildNumber", { + alias: "bn", + default: undefined, + demand: false, + description: + 'Retarget this release to a specific native build (e.g. "100"). Pass an empty string ("") to reset to wildcard (all builds).', + type: "string", + }) .check((argv: any, aliases: { [aliases: string]: string }): any => { return isValidRollout(argv); }); @@ -671,7 +679,7 @@ yargs default: null, demand: false, description: - "Targets this release to clients running a specific native binary build — CFBundleVersion on iOS or versionCode on Android. If omitted, the release is a wildcard and is delivered to all clients regardless of their build number.", + "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, aliases: { [aliases: string]: string }): any => { @@ -873,7 +881,7 @@ yargs default: null, demand: false, description: - "Targets this release to clients running a specific native binary build — CFBundleVersion on iOS or versionCode on Android. If omitted, the release is a wildcard and is delivered to all clients regardless of their build number.", + "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, aliases: { [aliases: string]: string }): any => { @@ -1073,7 +1081,7 @@ yargs default: null, demand: false, description: - "Targets this release to clients running a specific native binary build — CFBundleVersion on iOS or versionCode on Android. If omitted, the release is a wildcard and is delivered to all clients regardless of their build number.", + "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) => { @@ -1443,6 +1451,8 @@ export function createCommand(): cli.ICommand { patchCommand.mandatory = argv["mandatory"] as any; patchCommand.rollout = getRolloutValue(argv["rollout"] as any); patchCommand.appStoreVersion = argv["targetBinaryVersion"] as any; + // undefined = not provided (skip); null = reset to wildcard (empty string ""); string = retarget to that build. + patchCommand.buildNumber = argv["buildNumber"] !== undefined ? (argv["buildNumber"] as string) || null : undefined; } break; diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 927d7c6..a358ede 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -275,8 +275,9 @@ class AccountManager { return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}`])).then((res: JsonResponse) => res.body.deployment); } - public getBaseRelease(appName: string, deploymentName: string, appVerison: string): Promise { - return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}/basebundle?appVersion=${appVerison}`])).then( + public getBaseRelease(appName: string, deploymentName: string, appVersion: string, buildNumber?: string): Promise { + const qs = buildNumber ? `?appVersion=${appVersion}&buildNumber=${buildNumber}` : `?appVersion=${appVersion}`; + return this.get(urlEncode([`/apps/${appName}/deployments/${deploymentName}/basebundle${qs}`])).then( (res: JsonResponse) => res.body.basebundle ); } diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 8cfb801..fe7b9d8 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -54,7 +54,7 @@ export async function takeHermesBaseBytecode( outputFolder: string, bundleName: string ): Promise { - const { bundleBlobUrl } = await sdk.getBaseRelease(command.appName, command.deploymentName, command.appStoreVersion); + const { bundleBlobUrl } = await sdk.getBaseRelease(command.appName, command.deploymentName, command.appStoreVersion, command.buildNumber); if (!bundleBlobUrl) { return null; } diff --git a/script/types/cli.ts b/script/types/cli.ts index a154b71..cd7fdfb 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -165,6 +165,7 @@ export interface IPackageInfo { export interface IPatchCommand extends ICommand, IPackageInfo { appName: string; appStoreVersion?: string; + buildNumber?: string | null; deploymentName: string; label: string; } diff --git a/script/types/rest-definitions.ts b/script/types/rest-definitions.ts index b4ddb2c..7aa80cf 100644 --- a/script/types/rest-definitions.ts +++ b/script/types/rest-definitions.ts @@ -46,7 +46,7 @@ export interface DownloadReport { /*inout*/ export interface PackageInfo { appVersion?: string; - buildNumber?: string; + buildNumber?: string | null; description?: string; isDisabled?: boolean; isMandatory?: boolean;