Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Examples/RN0840/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
15 changes: 15 additions & 0 deletions Examples/RN0840/ios/RN0840.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */,
);
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<variant>/`
after it is bundled. Pass `-PcodePushExportDir=<path>` (or set `ext.codePushExportDir`) to
export somewhere else; the `<variant>` 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]
Expand Down
112 changes: 112 additions & 0 deletions android/codepush-export.gradle
Original file line number Diff line number Diff line change
@@ -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 `codePushExport<Variant>Bundle` task that runs
* right after `createBundle<Variant>JsAndAssets`. 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
* `<buildDir>/codepush/embedded-bundle/<variant>/`. Set the `codePushExportDir` Gradle
* property to export somewhere else; the `<variant>` 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)
}
}
7 changes: 7 additions & 0 deletions cli/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 100 additions & 1 deletion cli/commands/releaseCommand/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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, unknown> = {}): 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.
Expand Down
Loading
Loading