From 581b2082af008e885eb5988d812bb61ee9eb8429 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Mon, 10 Aug 2026 21:48:46 +0900 Subject: [PATCH 1/3] feat: export embedded bundle from native builds A binary patch is computed against the JS bundle inside the app binary, so releasing one means holding on to exactly the bytes that build embedded. Producing that file was left to whoever ran the build; these hooks produce it. An `apply from:` Gradle script gives every variant that bundles JS a task that copies the bundle out right after it is compiled, and an Xcode build phase script does the same for iOS. Both read the bundle from the build rather than a fixed path, and write a `binary-patch-base.json` record beside it - the same record the `bundle` command writes, plus the binary version, build number and commit that only a native build knows. Builds that embed no bundle export nothing. --- README.md | 77 ++++++++++++++++++++ android/codepush-export.gradle | 112 ++++++++++++++++++++++++++++++ package.json | 1 + scripts/export-embedded-bundle.sh | 76 ++++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 android/codepush-export.gradle create mode 100755 scripts/export-embedded-bundle.sh diff --git a/README.md b/README.md index 814284bb3..53d33af3b 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,83 @@ module.exports = Config; ``` +### 6. Export the Embedded Bundle (Optional) + +Only needed if you want to release **binary patch updates** (`release --binary-bundle-path`). +A binary patch is the difference between the update and the JS bundle that is already +inside the installed app, so releasing one means holding on to that bundle: the exact +bytes the build you shipped to the store embedded. + +The library ships a hook for each platform that copies the freshly compiled bundle out of +the build, together with a `binary-patch-base.json` record describing it (bundle SHA-256, +binary version, build number, and the commit when the build can work it out). Builds that +embed no bundle - a debuggable Android variant, a Debug iOS build, or any build run with +`SKIP_BUNDLING` - export nothing and are left untouched. + +**Android** - apply the Gradle script in your app module's `android/app/build.gradle`: + +```groovy +apply plugin: "com.android.application" +apply plugin: "com.facebook.react" + +react { + // ... +} + +apply from: "../../node_modules/@bravemobile/react-native-code-push/android/codepush-export.gradle" +``` + +Every variant that bundles JS then exports to `android/app/build/codepush/embedded-bundle//` +after it is bundled. Pass `-PcodePushExportDir=` (or set `ext.codePushExportDir`) to +export somewhere else; the `` directory is appended either way. + +**iOS** - in Xcode, add a **Run Script** phase to your app target, **below** "Bundle React +Native code and images", with: + +```bash +"$SRCROOT/../node_modules/@bravemobile/react-native-code-push/scripts/export-embedded-bundle.sh" +``` + +The export lands in `$BUILD_DIR/codepush/embedded-bundle/$CONFIGURATION-$PLATFORM_NAME/`. +Set the `CODEPUSH_EXPORT_DIR` environment variable to export somewhere else; the +`$CONFIGURATION-$PLATFORM_NAME` directory is appended either way. + +**Keep the export with the binary.** Whatever builds your store binary should archive the +export next to it, keyed by the binary version, so that later releases can find it: + +```bash +# Android, after ./gradlew :app:assembleRelease +aws s3 cp --recursive \ + android/app/build/codepush/embedded-bundle/release \ + "s3://your-bucket/binaries/android/$BINARY_VERSION/" + +# iOS - point the export at a path the pipeline knows, since $BUILD_DIR only exists +# inside the build +export CODEPUSH_EXPORT_DIR="$PWD/codepush-export" +xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Release archive # ... +aws s3 cp --recursive \ + "$CODEPUSH_EXPORT_DIR/Release-iphoneos" \ + "s3://your-bucket/binaries/ios/$BINARY_VERSION/" +``` + +Releasing later is then a download and a path: + +```bash +aws s3 cp --recursive "s3://your-bucket/binaries/android/1.0.0/" ./binary/ +npx code-push release -b 1.0.0 -v 1.0.1 -p android \ + --binary-bundle-path ./binary/index.android.bundle +``` + +Because the record travels next to the bundle, `release` re-checks it: a base bundle that +no longer hashes to what the record describes, or one exported from a binary version other +than `--binary-version`, fails the release before anything is built or uploaded. A base +bundle with no record beside it releases exactly as it did before. + +> [!NOTE] +> Applying these hooks automatically through the Expo config plugin (`app.plugin.js`) is +> not implemented yet; Expo projects with a `android`/`ios` directory can wire them up as above. + + ## ๐Ÿš€ CLI Tool Usage > [!TIP] diff --git a/android/codepush-export.gradle b/android/codepush-export.gradle new file mode 100644 index 000000000..122d7fe48 --- /dev/null +++ b/android/codepush-export.gradle @@ -0,0 +1,112 @@ +/** + * Exports the JS bundle a release build embeds in the app, so a later CodePush release + * can compute a binary patch against exactly the bytes the store binary ships. + * + * Apply it from the app module's build.gradle, below the React Native plugin: + * + * apply from: "../../node_modules/@bravemobile/react-native-code-push/android/codepush-export.gradle" + * + * Every variant that bundles JS gets a `codePushExportBundle` task that runs + * right after `createBundleJsAndAssets`. Variants the React Native plugin lists + * in `debuggableVariants` never bundle - they have no bundle task - so they get no export + * task and the build behaves exactly as it did without this script. + * + * Each export writes the bundle and a `binary-patch-base.json` record next to it, under + * `/codepush/embedded-bundle//`. Set the `codePushExportDir` Gradle + * property to export somewhere else; the `` directory is appended to it either + * way, so an `assemble` that builds several variants never has them overwrite each other. + */ + +import groovy.json.JsonOutput + +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +/** + * Name and field names of the record are a contract shared with the CodePush CLI + * (`cli/functions/makeBinaryPatchBundle.ts`) and the iOS export script + * (`scripts/export-embedded-bundle.sh`). Keep the three in step. + */ +def exportRecordName = 'binary-patch-base.json' + +def exportRootDir = project.hasProperty('codePushExportDir') + ? project.file(project.property('codePushExportDir')) + : new File(project.layout.buildDirectory.get().asFile, 'codepush/embedded-bundle') + +// The variant API is the only place the version of the artifact being built is known. +def variantOutputs = [:] +androidComponents.onVariants(androidComponents.selector().all()) { variant -> + variantOutputs[variant.name] = variant.outputs.find { it.outputType.toString() == 'SINGLE' } ?: variant.outputs.first() +} + +// Runs after the Android plugin has created the variants and React Native has registered +// its bundle tasks, so the tasks to hook onto are all there to be found. +project.afterEvaluate { + def gitDir = project.rootDir + + variantOutputs.each { variantName, variantOutput -> + def capitalizedName = variantName.capitalize() + + // The React Native plugin registers this task only for variants that bundle JS, so + // a missing task means the variant embeds no bundle and there is nothing to export. + def bundleTask = project.tasks.findByName("createBundle${capitalizedName}JsAndAssets") + if (bundleTask == null) { + return + } + + // Taken from the bundle task's own output properties rather than a fixed path, so a + // custom `bundleAssetName` or a new React Native output layout still resolves. + def bundleFile = bundleTask.jsBundleDir.file(bundleTask.bundleAssetName) + def exportDir = new File(exportRootDir, variantName) + def binaryVersion = variantOutput.versionName + def buildNumber = variantOutput.versionCode + + def exportTask = project.tasks.register("codePushExport${capitalizedName}Bundle") { task -> + task.group = 'codepush' + task.description = "Exports the ${variantName} JS bundle embedded in the app, with the record describing it, to ${exportDir}." + task.inputs.file(bundleFile) + // The versions are part of what the record says, so bumping one has to rewrite + // it even when the bundle itself came out unchanged. + task.inputs.property('binaryVersion', binaryVersion) + task.inputs.property('buildNumber', buildNumber) + task.outputs.dir(exportDir) + task.doLast { + def embeddedBundle = bundleFile.get().asFile + exportDir.mkdirs() + def exportedBundle = new File(exportDir, embeddedBundle.name) + Files.copy(embeddedBundle.toPath(), exportedBundle.toPath(), StandardCopyOption.REPLACE_EXISTING) + + def digest = MessageDigest.getInstance('SHA-256') + exportedBundle.eachByte(64 * 1024) { buffer, length -> digest.update(buffer, 0, length) } + + def baseRecord = [baseBundleHash: digest.digest().encodeHex().toString()] + if (binaryVersion.getOrNull() != null) { + baseRecord.binaryVersion = binaryVersion.get() + } + if (buildNumber.getOrNull() != null) { + baseRecord.buildNumber = buildNumber.get().toString() + } + + // A build outside a git checkout still exports a usable record, just without + // the commit, so a missing or failing git is never a build failure. + try { + def git = new ProcessBuilder('git', 'rev-parse', 'HEAD') + .directory(gitDir) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + def gitSha = git.inputStream.getText('UTF-8').trim() + if (git.waitFor() == 0 && !gitSha.isEmpty()) { + baseRecord.gitSha = gitSha + } + } catch (Exception ignored) { + } + + new File(exportDir, exportRecordName).write(JsonOutput.prettyPrint(JsonOutput.toJson(baseRecord)), 'UTF-8') + logger.lifecycle("CodePush: exported the embedded ${variantName} bundle to ${exportedBundle}") + } + } + + bundleTask.finalizedBy(exportTask) + } +} diff --git a/package.json b/package.json index 8bacd7954..ab9b01a6a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "cli/dist", "cli/**/*.ts", "!cli/node_modules", + "scripts/export-embedded-bundle.sh", "expo", "typings", "*.podspec", diff --git a/scripts/export-embedded-bundle.sh b/scripts/export-embedded-bundle.sh new file mode 100755 index 000000000..971be8461 --- /dev/null +++ b/scripts/export-embedded-bundle.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# +# Exports the JS bundle an Xcode build embeds in the app, so a later CodePush release can +# compute a binary patch against exactly the bytes the store binary ships. +# +# Add it as a "Run Script" build phase placed after "Bundle React Native code and images": +# +# "$SRCROOT/../node_modules/@bravemobile/react-native-code-push/scripts/export-embedded-bundle.sh" +# +# The bundle and a `binary-patch-base.json` record describing it land in +# `$BUILD_DIR/codepush/embedded-bundle/$CONFIGURATION-$PLATFORM_NAME/`. Set +# CODEPUSH_EXPORT_DIR to export somewhere else; the `$CONFIGURATION-$PLATFORM_NAME` +# directory is appended to it either way, so builds of different configurations never +# overwrite each other. +# +# Builds that embed no bundle - Debug for the simulator, or any build run with +# SKIP_BUNDLING - have nothing to export, and the script exits without doing anything. + +set -euo pipefail + +if [[ -z "${CONFIGURATION_BUILD_DIR:-}" || -z "${UNLOCALIZED_RESOURCES_FOLDER_PATH:-}" || -z "${BUILD_DIR:-}" || -z "${CONFIGURATION:-}" || -z "${PLATFORM_NAME:-}" ]]; then + echo "error: export-embedded-bundle.sh must run as an Xcode build phase; the build settings it needs are not set." >&2 + exit 1 +fi + +# The name React Native gives the bundle it writes into the app, which BUNDLE_NAME renames. +embedded_bundle="$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/${BUNDLE_NAME:-main}.jsbundle" + +if [[ ! -f "$embedded_bundle" ]]; then + exit 0 +fi + +export_dir="${CODEPUSH_EXPORT_DIR:-$BUILD_DIR/codepush/embedded-bundle}/$CONFIGURATION-$PLATFORM_NAME" +mkdir -p "$export_dir" +cp "$embedded_bundle" "$export_dir/" + +exported_bundle="$export_dir/$(basename "$embedded_bundle")" +bundle_hash="$(shasum -a 256 "$exported_bundle" | awk '{ print $1 }')" + +# Read from the built product rather than the source Info.plist, where the versions are +# still unexpanded build settings. +binary_version="" +build_number="" +info_plist="$CONFIGURATION_BUILD_DIR/${INFOPLIST_PATH:-}" +if [[ -n "${INFOPLIST_PATH:-}" && -f "$info_plist" ]]; then + binary_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$info_plist" 2>/dev/null || true)" + build_number="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$info_plist" 2>/dev/null || true)" +fi + +# A build outside a git checkout still exports a usable record, just without the commit. +git_sha="$(git -C "${PROJECT_DIR:-$PWD}" rev-parse HEAD 2>/dev/null || true)" + +# Record name and field names are a contract shared with the CodePush CLI +# (cli/functions/makeBinaryPatchBundle.ts) and the Android export script +# (android/codepush-export.gradle). Keep the three in step. +record_json="" + +append_field() { + local name="$1" value="$2" + if [[ -z "$value" ]]; then + return 0 + fi + if [[ -n "$record_json" ]]; then + record_json+=","$'\n' + fi + record_json+=" \"$name\": \"$value\"" +} + +append_field baseBundleHash "$bundle_hash" +append_field binaryVersion "$binary_version" +append_field buildNumber "$build_number" +append_field gitSha "$git_sha" + +printf '{\n%s\n}\n' "$record_json" > "$export_dir/binary-patch-base.json" + +echo "CodePush: exported the embedded bundle to $exported_bundle" From 45a9c81b170b71244df31e17e70b2f372c82b270 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Mon, 10 Aug 2026 21:48:57 +0900 Subject: [PATCH 2/3] feat(cli): verify the base bundle against its exported record The base bundle is the one input a release cannot verify on its own: hand it the bundle of a different build and the release still succeeds, only for the patch to be unappliable on every device. When a record exported by a build hook sits next to the base bundle, `release` now reads it: a base file that no longer hashes to what the record describes, or one exported from a binary version other than `--binary-version`, stops the release before anything is built or uploaded. A base bundle with no record beside it releases as before, and a record that cannot be read only warns. --- cli/README.ko.md | 7 ++ cli/README.md | 8 ++ cli/commands/releaseCommand/release.test.ts | 101 +++++++++++++++++++- cli/commands/releaseCommand/release.ts | 61 ++++++++++++ cli/functions/makeBinaryPatchBundle.ts | 19 ++++ 5 files changed, 195 insertions(+), 1 deletion(-) diff --git a/cli/README.ko.md b/cli/README.ko.md index a9028c55d..090dedaaa 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -128,6 +128,13 @@ patch๋Š” ๋Œ€์ฒดํ•˜๋ ค๋Š” archive๋ณด๋‹ค ์ž‘์„ ๋•Œ๋งŒ ๋ฐฐํฌํ•  ๊ฐ€์น˜๊ฐ€ ์žˆ์Šต ๊ธฐ๋ณธ๊ฐ’ `skip`์€ ๊ฒฝ๊ณ ๋ฅผ ๋‚จ๊ธฐ๊ณ  ์š”์•ฝ์— skip ์‚ฌ์‹ค์„ ๋ช…์‹œํ•œ ๋’ค full ๋ฒˆ๋“ค๋งŒ ๋ฐฐํฌํ•˜๋ฉฐ, `fail`์€ ์–ด๋–ค ์—…๋กœ๋“œ๋„ ์‹œ์ž‘ํ•˜๊ธฐ ์ „์— ๋ฆด๋ฆฌ์Šค๋ฅผ ์‹คํŒจ์‹œํ‚ค๊ณ  ๋ฆด๋ฆฌ์Šค ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. +`--binary-bundle-path`๊ฐ€ ๊ฐ€๋ฆฌํ‚ค๋Š” ๋ฒˆ๋“ค์€ ๋ฆด๋ฆฌ์Šค๊ฐ€ ์Šค์Šค๋กœ ๊ฒ€์ฆํ•  ์ˆ˜ ์—†๋Š” ์œ ์ผํ•œ ์ž…๋ ฅ์ด๋ฏ€๋กœ, +[build ํ›…](../README.md#6-export-the-embedded-bundle-optional)์€ exportํ•˜๋Š” ๋ฒˆ๋“ค ์˜†์— +`binary-patch-base.json` ๊ธฐ๋ก์„ ํ•จ๊ป˜ ๋‚จ๊น๋‹ˆ๋‹ค. ์ด ๊ธฐ๋ก์ด ์žˆ์œผ๋ฉด base ๋ฒˆ๋“ค์˜ ์‹ค์ œ SHA-256์ด +๊ธฐ๋ก๊ณผ ๋‹ค๋ฅด๊ฑฐ๋‚˜ `--binary-version`์ด ์•„๋‹Œ ๋‹ค๋ฅธ ๋ฐ”์ด๋„ˆ๋ฆฌ ๋ฒ„์ „์—์„œ export๋œ ๋ฒˆ๋“ค์ผ ๋•Œ, ๋นŒ๋“œ๋‚˜ +์—…๋กœ๋“œ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ ์ „์— ๋ฆด๋ฆฌ์Šค๋ฅผ ์‹คํŒจ์‹œํ‚ต๋‹ˆ๋‹ค. ๊ธฐ๋ก์ด ์—†๋Š” base ๋ฒˆ๋“ค์€ ๊ธฐ์กด๊ณผ ๋™์ผํ•˜๊ฒŒ +๋™์ž‘ํ•˜๊ณ , ์ฝ์„ ์ˆ˜ ์—†๋Š” ๊ธฐ๋ก์€ ๊ฒฝ๊ณ ๋งŒ ๋‚จ๊น๋‹ˆ๋‹ค. + **์˜ˆ์‹œ:** ```bash diff --git a/cli/README.md b/cli/README.md index 272c9faff..d48870def 100644 --- a/cli/README.md +++ b/cli/README.md @@ -129,6 +129,14 @@ comes out the same size or larger: `skip` (the default) logs a warning, notes th the summary and releases the full bundle alone, while `fail` stops the release before anything is uploaded and leaves the release history untouched. +The bundle `--binary-bundle-path` points at is the one input a release cannot verify on +its own, so the [build hooks](../README.md#6-export-the-embedded-bundle-optional) leave a +`binary-patch-base.json` record next to every bundle they export. When that record is +there, the release fails before anything is built or uploaded if the base bundle no longer +hashes to what the record describes, or if it was exported from a binary version other +than `--binary-version`. A base bundle with no record beside it releases exactly as before, +and a record that cannot be read only warns. + ```bash # Standard iOS release npx code-push release -b 1.0.0 -v 1.0.1 -p ios diff --git a/cli/commands/releaseCommand/release.test.ts b/cli/commands/releaseCommand/release.test.ts index a273b684d..8dc901152 100644 --- a/cli/commands/releaseCommand/release.test.ts +++ b/cli/commands/releaseCommand/release.test.ts @@ -6,6 +6,7 @@ import { release } from "./release.js"; import { makeCodePushBundle } from "../../functions/makeCodePushBundle.js"; import { BINARY_PATCH_ARCHIVE_SUFFIX, + BINARY_PATCH_BASE_RECORD_NAME, BINARY_PATCH_MANIFEST_NAME, hashBundleFile, writeBinaryPatchBaseRecord, @@ -124,10 +125,12 @@ type ReleaseOverrides = { skipCleanup?: boolean; uploadFailsFor?: (filePath: string) => boolean; onOversizedPatch?: OversizedPatchPolicy; + /** Passed in when the case has to read the uploads of a release that threw. */ + uploads?: Uploads; }; async function runRelease(staged: StagedBundle, overrides: ReleaseOverrides = {}) { - const uploads: Uploads = []; + const uploads: Uploads = overrides.uploads ?? []; const history = historyStore(); await release( @@ -409,6 +412,102 @@ describe("release --skip-bundle --binary-bundle-path", () => { }); }); +/** + * The build hooks export the bundle of a native build with a record describing it, so the + * base bundle a release is handed can be checked against the build it came out of. + */ +describe("release --binary-bundle-path with an exported base bundle record", () => { + /** A base bundle laid out the way a build hook leaves it: the bundle, and its record beside it. */ + function stageExportedBaseBundle(caseName: string, record?: string): string { + const exportDir = fs.mkdtempSync(path.join(workDir, `${caseName}-export-`)); + const baseBundlePath = path.join(exportDir, 'main.jsbundle'); + fs.copyFileSync(baseFixture, baseBundlePath); + + if (record !== undefined) { + fs.writeFileSync(path.join(exportDir, BINARY_PATCH_BASE_RECORD_NAME), record); + } + + return baseBundlePath; + } + + function exportedRecord(overrides: Record = {}): string { + return JSON.stringify({ + baseBundleHash: hashBundleFile(baseFixture), + binaryVersion: BINARY_VERSION, + buildNumber: '42', + gitSha: 'a'.repeat(40), + ...overrides, + }, null, 2); + } + + it("releases both artifacts when the record describes this bundle and this binary version", async () => { + const staged = await stageBundleOutput("record-match"); + const baseBundlePath = stageExportedBaseBundle("record-match", exportedRecord()); + + const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath }); + + expect(uploads).toHaveLength(2); + expect(logs.filter((line) => line.startsWith('warn:'))).toEqual([]); + }); + + it("fails before anything is built when the base bundle is not the file the record describes", async () => { + const staged = await stageBundleOutput("record-hash-mismatch"); + const baseBundlePath = stageExportedBaseBundle("record-hash-mismatch", exportedRecord({ baseBundleHash: 'f'.repeat(64) })); + + const uploads: Uploads = []; + await expect(runRelease(staged, { binaryBundlePath: baseBundlePath, uploads })).rejects.toThrow( + /does not match the record exported next to it/, + ); + + expect(uploads).toEqual([]); + }); + + it("fails when the base bundle was exported from a different binary version", async () => { + const staged = await stageBundleOutput("record-version-mismatch"); + const baseBundlePath = stageExportedBaseBundle("record-version-mismatch", exportedRecord({ binaryVersion: '1.0.0' })); + + const uploads: Uploads = []; + await expect(runRelease(staged, { binaryBundlePath: baseBundlePath, uploads })).rejects.toThrow( + new RegExp(`exported from binary version 1\\.0\\.0, but this release targets ${BINARY_VERSION}`), + ); + + expect(uploads).toEqual([]); + }); + + it.each([ + ['is not JSON at all', 'not json at all'], + ['holds nothing to read a record out of', 'null'], + ])("warns and releases when the record %s", async (caseName, contents) => { + const staged = await stageBundleOutput("record-unreadable"); + const baseBundlePath = stageExportedBaseBundle(`record-unreadable-${caseName.replace(/\W+/g, '-')}`, contents); + + const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath }); + + expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(/not a readable binary patch base record/); + expect(uploads).toHaveLength(2); + }); + + it("warns and releases when the record leaves out the hash it is checked against", async () => { + const staged = await stageBundleOutput("record-no-hash"); + const baseBundlePath = stageExportedBaseBundle("record-no-hash", JSON.stringify({ binaryVersion: '1.0.0' })); + + const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath }); + + expect(logs.filter((line) => line.startsWith('warn:')).join('\n')).toMatch(/not a readable binary patch base record/); + expect(uploads).toHaveLength(2); + }); + + it("releases exactly as before when the base bundle has no record beside it", async () => { + const staged = await stageBundleOutput("record-absent"); + const baseBundlePath = stageExportedBaseBundle("record-absent"); + + const { uploads } = await runRelease(staged, { binaryBundlePath: baseBundlePath }); + + expect(uploads).toHaveLength(2); + expect(logs.filter((line) => line.startsWith('warn:'))).toEqual([]); + }); +}); + /** * A patch is only worth publishing when it is smaller than the archive it replaces. The * CLI runs unattended, so `--on-oversized-patch` decides what happens when it is not. diff --git a/cli/commands/releaseCommand/release.ts b/cli/commands/releaseCommand/release.ts index 99dcbec89..1afa6e790 100644 --- a/cli/commands/releaseCommand/release.ts +++ b/cli/commands/releaseCommand/release.ts @@ -7,6 +7,7 @@ import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js"; import { unzip } from "../../utils/unzip.js"; import { BINARY_PATCH_ARCHIVE_SUFFIX, + BINARY_PATCH_BASE_RECORD_NAME, DEFAULT_OVERSIZED_PATCH_POLICY, extractCodePushBundleContents, formatBinaryPatchSummary, @@ -14,6 +15,7 @@ import { isPatchArchiveOversized, makeBinaryPatchBundle, readBinaryPatchBaseRecord, + type BinaryPatchBaseRecord, type BinaryPatchBundle, type OversizedPatchPolicy, } from "../../functions/makeBinaryPatchBundle.js"; @@ -41,6 +43,12 @@ export async function release( baseBundlePath?: string, onOversizedPatch: OversizedPatchPolicy = DEFAULT_OVERSIZED_PATCH_POLICY, ): Promise { + if (baseBundlePath) { + // Checked before the bundler runs, so the wrong base bundle costs a second rather + // than a full build. + verifyExportedBaseBundleRecord(baseBundlePath, binaryVersion); + } + const codePushBundle = skipBundle ? null : await bundleCodePush(framework, platform, outputPath, entryFile, jsBundleName, bundleDirectory, outputMetroDir, baseBundlePath); @@ -221,6 +229,59 @@ async function makeBinaryPatchArtifact({ } } +/** + * Cross-checks the base bundle against the record a build hook exported next to it. + * + * The base bundle is the one input a release cannot verify on its own: pass the bundle of + * a different build and everything still succeeds, only for the patch to be unappliable + * on every device. The record says which bytes and which binary the export came from, so + * when it is there, the two mistakes that produce a broken release - a base file that was + * replaced after it was exported, and a base exported from a different binary version - + * stop the release before anything is built or uploaded. + * + * A base bundle produced some other way has no record next to it, and releases exactly as + * it did before. A record that cannot be read only warns: it is a cross-check, and losing + * it must never be able to fail a release that is otherwise fine. + */ +function verifyExportedBaseBundleRecord(baseBundlePath: string, binaryVersion: string): void { + const recordPath = path.join(path.dirname(path.resolve(baseBundlePath)), BINARY_PATCH_BASE_RECORD_NAME); + + let contents: string; + try { + contents = fs.readFileSync(recordPath, 'utf8'); + } catch { + return; + } + + let record: Partial = {}; + try { + record = (JSON.parse(contents) as Partial | null) ?? {}; + } catch { + // Left empty, which is reported below as a record that says nothing to check. + } + if (typeof record.baseBundleHash !== 'string') { + console.warn( + `warn: "${recordPath}" is not a readable binary patch base record, so the base bundle was released without cross-checking it.`, + ); + return; + } + + const baseBundleHash = hashBundleFile(baseBundlePath); + if (record.baseBundleHash !== baseBundleHash) { + throw new Error( + `The base bundle "${baseBundlePath}" does not match the record exported next to it: the record describes ${record.baseBundleHash}, ` + + `but the file hashes to ${baseBundleHash}. Export the bundle again from the build that produced the binary.`, + ); + } + + if (typeof record.binaryVersion === 'string' && record.binaryVersion !== binaryVersion) { + throw new Error( + `The base bundle "${baseBundlePath}" was exported from binary version ${record.binaryVersion}, but this release targets ${binaryVersion} ` + + '(--binary-version). Release against the bundle of the binary being targeted.', + ); + } +} + /** * The bundle being released may have been compiled by an earlier `bundle` run against a * different base. The patch stays valid - it is always computed against the base given diff --git a/cli/functions/makeBinaryPatchBundle.ts b/cli/functions/makeBinaryPatchBundle.ts index 1f7b4e6c6..70fd729bd 100644 --- a/cli/functions/makeBinaryPatchBundle.ts +++ b/cli/functions/makeBinaryPatchBundle.ts @@ -36,6 +36,11 @@ export const BINARY_PATCH_ARCHIVE_SUFFIX = '-patch.zip'; * Record the `bundle` command leaves in the output root - outside the update contents, * so it never reaches the archive - to say which base bundle the JS bundle was * compiled against. A later `release` compares it with the base it was given. + * + * The build hooks (`android/codepush-export.gradle`, `scripts/export-embedded-bundle.sh`) + * write a record of the same name next to the bundle they export from a native build, + * which is what makes that bundle self-describing when it is later passed to + * `release --binary-bundle-path`. */ export const BINARY_PATCH_BASE_RECORD_NAME = 'binary-patch-base.json'; @@ -61,8 +66,22 @@ export type BinaryPatchBundle = { manifest: BinaryPatchManifest; }; +/** + * Contents of `binary-patch-base.json`. The `bundle` command writes the hash alone; a + * build hook, which knows which binary the bundle it exported went into, adds the rest. + * + * The field names are a contract shared with `android/codepush-export.gradle` and + * `scripts/export-embedded-bundle.sh`, which spell them out as literals. Keep the three + * in step. + */ export type BinaryPatchBaseRecord = { baseBundleHash: string; + /** Marketing version of the binary the bundle shipped in: `versionName` / `CFBundleShortVersionString`. */ + binaryVersion?: string; + /** Build number of that binary: `versionCode` / `CFBundleVersion`. */ + buildNumber?: string; + /** Commit the binary was built from, present only when the build could work it out. */ + gitSha?: string; }; /** SHA-256 of a single file's bytes, which is what the manifest records. */ From dbf0587a72c56fec76269134991abe4d075efa69 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Mon, 10 Aug 2026 21:48:57 +0900 Subject: [PATCH 3/3] chore(example): export the embedded bundle from RN0840 builds Applies both hooks to the example app, which is what proves they copy the bundle the binary actually ships: the exported file hashes the same as the one inside the APK and the built .app. --- Examples/RN0840/android/app/build.gradle | 6 ++++++ .../RN0840/ios/RN0840.xcodeproj/project.pbxproj | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Examples/RN0840/android/app/build.gradle b/Examples/RN0840/android/app/build.gradle index 6bd3538ea..e36fa0952 100644 --- a/Examples/RN0840/android/app/build.gradle +++ b/Examples/RN0840/android/app/build.gradle @@ -54,6 +54,12 @@ react { autolinkLibrariesWithApp() } +/** + * Exports the JS bundle each bundling variant embeds, so a CodePush release can patch + * against the bundle that is actually inside the binary. + */ +apply from: "../../node_modules/@bravemobile/react-native-code-push/android/codepush-export.gradle" + /** * Set this to true to Run Proguard on Release builds to minify the Java bytecode. */ diff --git a/Examples/RN0840/ios/RN0840.xcodeproj/project.pbxproj b/Examples/RN0840/ios/RN0840.xcodeproj/project.pbxproj index 2f11d019e..037164755 100644 --- a/Examples/RN0840/ios/RN0840.xcodeproj/project.pbxproj +++ b/Examples/RN0840/ios/RN0840.xcodeproj/project.pbxproj @@ -112,6 +112,7 @@ 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + CD9E0000000000000000E001 /* Export the embedded CodePush bundle */, 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, ); @@ -224,6 +225,20 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + CD9E0000000000000000E001 /* Export the embedded CodePush bundle */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Export the embedded CodePush bundle"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$SRCROOT/../node_modules/@bravemobile/react-native-code-push/scripts/export-embedded-bundle.sh\"\n"; + }; E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647;