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; 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/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. */ 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"