Skip to content
Open
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
49 changes: 49 additions & 0 deletions cli/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,16 @@ npx code-push bundle [options]
| `-b, --bundle-name <string>` | 번들 파일 이름 | `main.jsbundle` (iOS) / `index.android.bundle` (Android) |
| `--output-bundle-dir <string>` | 번들 출력 디렉토리 이름 | `bundleOutput` |
| `--output-metro-dir <string>` | Hermes 컴파일 전 Metro JS 번들과 소스맵을 복사할 디렉토리 | — |
| `--binary-bundle-path <string>` | 대상 바이너리에 포함된 JS 번들 경로. Hermes 컴파일을 이 번들에 정렬하고, binary patch base로 기록합니다 | — |

**예시:**

```bash
# Android용 번들 생성 (커스텀 엔트리 파일)
npx code-push bundle -p android -e index.js

# 바이너리에 포함된 JS 번들에 정렬하여 번들 생성
npx code-push bundle -p android --binary-bundle-path ./binary/index.android.bundle
```

---
Expand Down Expand Up @@ -103,6 +107,45 @@ npx code-push release [options]
| `--skip-cleanup <bool>` | 출력 디렉토리 정리 건너뛰기 | `false` |
| `--output-bundle-dir <string>` | 번들 출력 디렉토리 이름 | `bundleOutput` |
| `--output-metro-dir <string>` | Hermes 컴파일 전 Metro JS 번들과 소스맵을 복사할 디렉토리 | — |
| `--binary-bundle-path <string>` | 대상 바이너리에 포함된 JS 번들 경로. 이 번들에 대한 binary patch 번들을 함께 배포하고, Hermes 컴파일을 이 번들에 정렬합니다 | — |
| `--on-oversized-patch <policy>` | patch 번들이 full 번들보다 작지 않을 때의 동작: `skip`은 full 번들만 배포하고, `fail`은 업로드 전에 릴리스를 중단합니다 | `skip` |

`--binary-bundle-path`를 사용하면 플랫폼별로 두 개의 artifact를 업로드합니다. `packageHash`
이름의 full 번들과, 바이너리에 포함된 번들과의 차이만 담은 `<packageHash>-patch.zip` patch
번들입니다. patch 번들에는 업데이트 복원 방법을 담은 `codepush-binary-patch.json` manifest가
포함되어, patch를 적용하면 full 번들과 동일한 `packageHash`가 됩니다. 두 artifact의 크기와
절감량은 업로드 전에 출력됩니다.

#### 사전 준비: patch 생성 도구 빌드

patch 생성에는 HDiffPatch의 `hdiffz`가 필요합니다. 패키지 의존성으로 설치되지
않으므로, 이 패키지가 함께 배포하는 스크립트로 머신마다 한 번 빌드합니다.

```bash
./node_modules/@bravemobile/react-native-code-push/scripts/binary-patch/build-hdiffpatch.sh
```

스크립트는 고정된 upstream 소스를 clone해서 컴파일하므로 `git`, C/C++ 툴체인(`make`, `cc`,
`c++`), 네트워크 연결이 필요합니다. 이미 빌드되어 있으면 아무 일도 하지 않고, `--force`를
주면 다시 빌드합니다. `hdiffz`와 `hpatchz`는 스크립트가 속한 패키지 루트의
`.hdiffpatch-tools/` 디렉토리에 설치되며, CLI는 작업 디렉토리와 그 상위 디렉토리들에서
`.hdiffpatch-tools/` 디렉토리를 찾습니다. `node_modules` 안의 설치 위치는 프로젝트보다 상위가
아니라 하위이므로, 두 실행 파일이 있는 디렉토리를 `HDIFFPATCH_TOOLS_DIR`로 지정하세요. 미리
빌드해 둔 CI 이미지나 프로젝트 밖의 공용 설치를 사용할 때도 같은 방법을 씁니다.

```bash
export HDIFFPATCH_TOOLS_DIR="$PWD/node_modules/@bravemobile/react-native-code-push/.hdiffpatch-tools"
```

도구가 필요한 것은 `--binary-bundle-path`를 사용하는 릴리스뿐이며, 도구를 찾지 못하면
업로드를 시작하기 전에 빌드 명령을 안내하는 메시지와 함께 실패합니다.

#### patch가 full 번들보다 작지 않을 때

patch는 대체하려는 archive보다 작을 때만 배포할 가치가 있습니다. CLI는 사용자에게 묻지
않으므로, patch 크기가 full 이상일 때의 동작을 `--on-oversized-patch`로 미리 정합니다.
기본값 `skip`은 경고를 남기고 요약에 skip 사실을 명시한 뒤 full 번들만 배포하며, `fail`은
어떤 업로드도 시작하기 전에 릴리스를 실패시키고 릴리스 히스토리를 변경하지 않습니다.

**예시:**

Expand All @@ -121,6 +164,12 @@ npx code-push release -b 1.0.0 -v 1.0.1 -i staging

# 번들링 건너뛰기 (기존 번들 재사용)
npx code-push release -b 1.0.0 -v 1.0.2 --skip-bundle true --hash-calc true

# full 번들과 바이너리 번들에 대한 binary patch를 함께 배포
npx code-push release -b 1.0.0 -v 1.0.1 -p ios --binary-bundle-path ./binary/main.jsbundle

# 동일하지만, patch가 더 작지 않으면 릴리스를 실패시킴
npx code-push release -b 1.0.0 -v 1.0.1 -p ios --binary-bundle-path ./binary/main.jsbundle --on-oversized-patch fail
```

---
Expand Down
52 changes: 52 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ npx code-push bundle [options]
| `-b, --bundle-name <string>` | Bundle file name | `main.jsbundle` (iOS) / `index.android.bundle` (Android) |
| `--output-bundle-dir <string>` | Directory name for the bundle output | `bundleOutput` |
| `--output-metro-dir <string>` | Directory to copy Metro JS bundle and sourcemap before Hermes compilation | — |
| `--binary-bundle-path <string>` | JS bundle of the target binary. Aligns the Hermes compilation with it and records it as the binary patch base | — |

```bash
# Bundle for Android with a custom entry file
npx code-push bundle -p android -e index.js

# Bundle aligned with the JS bundle shipped in the binary
npx code-push bundle -p android --binary-bundle-path ./binary/index.android.bundle
```

---
Expand Down Expand Up @@ -101,6 +105,48 @@ npx code-push release [options]
| `--skip-cleanup <bool>` | Skip output directory cleanup | `false` |
| `--output-bundle-dir <string>` | Bundle output directory name | `bundleOutput` |
| `--output-metro-dir <string>` | Directory to copy Metro JS bundle and sourcemap before Hermes compilation | — |
| `--binary-bundle-path <string>` | JS bundle of the target binary. Releases an additional binary patch bundle against it, and aligns the Hermes compilation with it | — |
| `--on-oversized-patch <policy>` | What to do when the patch bundle is not smaller than the full bundle: `skip` releases the full bundle only, `fail` stops the release before any upload | `skip` |

With `--binary-bundle-path`, the release uploads two artifacts per platform: the full
bundle named after its `packageHash`, and a patch bundle named `<packageHash>-patch.zip`
that carries only the difference from the bundle inside the binary. The patch bundle
holds a `codepush-binary-patch.json` manifest describing how to rebuild the update, so
applying it yields the same `packageHash` as the full bundle. Both sizes and the saving
are printed before either artifact is uploaded.

#### Prerequisites: building the patch generator

Producing a patch needs HDiffPatch's `hdiffz`, which is not installed as
a package dependency. Build it once per machine with the script this package ships:

```bash
./node_modules/@bravemobile/react-native-code-push/scripts/binary-patch/build-hdiffpatch.sh
```

The script clones the pinned upstream sources and compiles them, so it needs `git`, a C/C++
toolchain (`make`, `cc`, `c++`) and network access. It does nothing when the tools are
already in place; `--force` rebuilds them. It installs `hdiffz` and `hpatchz` into a
`.hdiffpatch-tools/` directory at the root of the package it lives in, and the CLI looks for
a `.hdiffpatch-tools/` directory in the working directory and every directory above it.
Under `node_modules` that install sits below the project rather than above it, so point
`HDIFFPATCH_TOOLS_DIR` at the directory holding the two executables - which is also how a CI
image that builds them ahead of time, or a shared install outside the project, is used:

```bash
export HDIFFPATCH_TOOLS_DIR="$PWD/node_modules/@bravemobile/react-native-code-push/.hdiffpatch-tools"
```

Only releases that pass `--binary-bundle-path` need the tools, and one that cannot find them
fails with the build command in the message before anything is uploaded.

#### Oversized patches

A patch is only worth publishing when it is smaller than the archive it replaces. The CLI
never prompts, so `--on-oversized-patch` decides in advance what happens when the patch
comes out the same size or larger: `skip` (the default) logs a warning, notes the skip in
the summary and releases the full bundle alone, while `fail` stops the release before
anything is uploaded and leaves the release history untouched.

```bash
# Standard iOS release
Expand All @@ -117,6 +163,12 @@ npx code-push release -b 1.0.0 -v 1.0.1 -i staging

# Reuse an existing bundle
npx code-push release -b 1.0.0 -v 1.0.2 --skip-bundle true --hash-calc true

# Release a full bundle and a binary patch against the bundle in the binary
npx code-push release -b 1.0.0 -v 1.0.1 -p ios --binary-bundle-path ./binary/main.jsbundle

# Same, but fail the release if the patch does not come out smaller
npx code-push release -b 1.0.0 -v 1.0.1 -p ios --binary-bundle-path ./binary/main.jsbundle --on-oversized-patch fail
```

---
Expand Down
46 changes: 39 additions & 7 deletions cli/commands/bundleCommand/bundleCodePush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,49 @@ import { prepareToBundleJS } from "../../functions/prepareToBundleJS.js";
import { runReactNativeBundleCommand } from "../../functions/runReactNativeBundleCommand.js";
import { runExpoBundleCommand } from "../../functions/runExpoBundleCommand.js";
import { getReactTempDir } from "../../functions/getReactTempDir.js";
import { runHermesEmitBinaryCommand } from "../../functions/runHermesEmitBinaryCommand.js";
import { resolveBaseBytecodeHermesFlags, runHermesEmitBinaryCommand } from "../../functions/runHermesEmitBinaryCommand.js";
import { makeCodePushBundle } from "../../functions/makeCodePushBundle.js";
import { hashBundleFile, writeBinaryPatchBaseRecord } from "../../functions/makeBinaryPatchBundle.js";
import { ROOT_OUTPUT_DIR, ENTRY_FILE } from "../../constant.js";

export type CodePushBundleResult = {
/** CodePush bundle file name (equals to packageHash) */
bundleFileName: string;
/** Directory holding the files that were packed into the CodePush bundle file */
contentsPath: string;
/** JS bundle file name inside the contents directory */
jsBundleName: string;
};

/**
* @return {Promise<string>} CodePush bundle file name (equals to packageHash)
* JS bundle file name react-native writes, which is also the name the app looks for
* inside an update, so it has to be decided the same way everywhere.
*/
export function resolveJsBundleName(platform: 'ios' | 'android', jsBundleName?: string): string {
const DEFAULT_JS_BUNDLE_NAME = platform === 'ios' ? 'main.jsbundle' : 'index.android.bundle';
return jsBundleName || DEFAULT_JS_BUNDLE_NAME;
}

/**
* @param baseBundlePath {string} JS bundle from the target binary. When given, the compilation is aligned with it and the base is recorded for a later `release`.
* @return {Promise<CodePushBundleResult>} CodePush bundle file name (equals to packageHash) and the contents it was made of
*/
export async function bundleCodePush(
framework: 'expo' | undefined,
platform: 'ios' | 'android' = 'ios',
outputRootPath: string = ROOT_OUTPUT_DIR,
entryFile: string = ENTRY_FILE,
jsBundleName: string, // JS bundle file name (not CodePush bundle file)
jsBundleName: string | undefined, // JS bundle file name (not CodePush bundle file)
bundleDirectory: string, // CodePush bundle output directory
outputMetroDir?: string,
): Promise<string> {
baseBundlePath?: string,
): Promise<CodePushBundleResult> {
if (fs.existsSync(outputRootPath)) {
fs.rmSync(outputRootPath, { recursive: true });
}

const OUTPUT_CONTENT_PATH = `${outputRootPath}/CodePush`;
const DEFAULT_JS_BUNDLE_NAME = platform === 'ios' ? 'main.jsbundle' : 'index.android.bundle';
const _jsBundleName = jsBundleName || DEFAULT_JS_BUNDLE_NAME; // react-native JS bundle output name
const _jsBundleName = resolveJsBundleName(platform, jsBundleName); // react-native JS bundle output name
const SOURCEMAP_OUTPUT = `${outputRootPath}/${_jsBundleName}.map`;

prepareToBundleJS({ deleteDirs: [outputRootPath, getReactTempDir()], makeDir: OUTPUT_CONTENT_PATH });
Expand Down Expand Up @@ -57,13 +77,25 @@ export async function bundleCodePush(
_jsBundleName,
OUTPUT_CONTENT_PATH,
SOURCEMAP_OUTPUT,
baseBundlePath ? resolveBaseBytecodeHermesFlags(baseBundlePath) : [],
);
console.log('log: Hermes compilation complete');

const { bundleFileName: codePushBundleFileName } = await makeCodePushBundle(OUTPUT_CONTENT_PATH, bundleDirectory);
console.log(`log: CodePush bundle created (file path: ./${bundleDirectory}/${codePushBundleFileName})`);

return codePushBundleFileName;
if (baseBundlePath) {
// Written after the bundle file, and to the output root instead of the update
// contents, so recording the base cannot change what was just packed or its hash.
const recordPath = writeBinaryPatchBaseRecord(outputRootPath, hashBundleFile(baseBundlePath));
console.log(`log: Binary patch base recorded (file path: ${recordPath})`);
}

return {
bundleFileName: codePushBundleFileName,
contentsPath: OUTPUT_CONTENT_PATH,
jsBundleName: _jsBundleName,
};
}

function copyMetroOutputsIfNeeded(
Expand Down
10 changes: 9 additions & 1 deletion cli/commands/bundleCommand/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { program, Option } from "commander";
import { bundleCodePush } from "./bundleCodePush.js";
import { resolveBinaryBundlePathOption } from "../../functions/makeBinaryPatchBundle.js";
import { OUTPUT_BUNDLE_DIR, ROOT_OUTPUT_DIR, ENTRY_FILE } from "../../constant.js";

type Options = {
framework: 'expo' | undefined;
platform: 'ios' | 'android';
outputPath: string;
entryFile: string;
bundleName: string;
// Commander derives this from the "-b, --bundle-name" flag.
bundleName?: string;
outputBundleDir: string;
outputMetroDir?: string;
binaryBundlePath?: string;
}

program.command('bundle')
Expand All @@ -21,7 +24,11 @@ program.command('bundle')
.option('-b, --bundle-name <string>', 'bundle file name (default-ios: "main.jsbundle" / default-android: "index.android.bundle")')
.option('--output-metro-dir <string>', 'name of directory to copy the Metro JS bundle and sourcemap before Hermes compilation')
.option('--output-bundle-dir <string>', 'name of directory containing the bundle file created by the "bundle" command', OUTPUT_BUNDLE_DIR)
.option('--binary-bundle-path <string>', 'path to the JS bundle of the target binary. Aligns the Hermes compilation with it and records it as the binary patch base.')
.action((options: Options) => {
// Resolved before bundling so a wrong path fails now rather than after a build.
const baseBundlePath = resolveBinaryBundlePathOption(options.binaryBundlePath);

bundleCodePush(
options.framework,
options.platform,
Expand All @@ -30,5 +37,6 @@ program.command('bundle')
options.bundleName,
`${options.outputPath}/${options.outputBundleDir}`,
options.outputMetroDir,
baseBundlePath,
)
});
118 changes: 118 additions & 0 deletions cli/commands/releaseCommand/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import path from "path";
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";

/**
* Checks the command definition against the arguments it forwards, which is where an
* option name can silently drift: commander derives the name it stores from the flag,
* so reading a differently named property yields `undefined` for every release rather
* than an error, and every downstream default looks as if it had been chosen.
*/

jest.mock("./release.js");
jest.mock("../../utils/fsUtils.js", () => ({
findAndReadConfigFile: () => ({
bundleUploader: async () => ({ downloadUrl: 'https://cdn.example.com/bundle' }),
getReleaseHistory: async () => ({}),
setReleaseHistory: async () => undefined,
}),
}));

/**
* `release()` takes positional arguments; these are the positions this suite asserts on.
*/
const ARG_INDEX = {
platform: 6,
outputPath: 8,
entryFile: 9,
jsBundleName: 10,
skipBundle: 14,
bundleDirectory: 16,
baseBundlePath: 19,
onOversizedPatch: 20,
} as const;

/**
* Parses a `release` invocation against the real command definition. Commander is asked
* to throw instead of exiting, and to keep its diagnostics to itself, so a rejected
* option can be asserted on without ending the worker or the output.
*/
async function parseReleaseCommand(args: string[]): Promise<void> {
const { program } = await import("commander");
await import("./index.js");

const releaseCommand = program.commands.find((command) => command.name() === 'release');
releaseCommand?.exitOverride();
releaseCommand?.configureOutput({ writeErr: () => {} });

await program.parseAsync(['release', ...args], { from: 'user' });
}

async function runReleaseCommand(args: string[]): Promise<unknown[]> {
const { release } = await import("./release.js");

await parseReleaseCommand(args);

const releaseMock = jest.mocked(release);
expect(releaseMock).toHaveBeenCalledTimes(1);
return releaseMock.mock.calls[0];
}

beforeEach(() => {
jest.resetModules();
jest.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

/** Relative to the CLI workspace root, which is where the test runner starts. */
const BASE_BUNDLE_FIXTURE = 'fixtures/binary-patch/base.bundle';

describe("release command options", () => {
it("passes the JS bundle name from -j through to the release", async () => {
const args = await runReleaseCommand([
'-b', '1.0.0',
'-v', '1.0.1',
'-p', 'android',
'-j', 'custom.jsbundle',
'--skip-bundle', 'true',
'--binary-bundle-path', BASE_BUNDLE_FIXTURE,
]);

expect(args[ARG_INDEX.jsBundleName]).toBe('custom.jsbundle');
expect(args[ARG_INDEX.platform]).toBe('android');
expect(args[ARG_INDEX.skipBundle]).toBe(true);
expect(args[ARG_INDEX.baseBundlePath]).toBe(path.resolve(BASE_BUNDLE_FIXTURE));
});

it("leaves the JS bundle name unset when -j is not given, so the platform default applies", async () => {
const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);

expect(args[ARG_INDEX.jsBundleName]).toBeUndefined();
expect(args[ARG_INDEX.baseBundlePath]).toBeUndefined();
expect(args[ARG_INDEX.outputPath]).toBe('build');
expect(args[ARG_INDEX.entryFile]).toBe('index.ts');
expect(args[ARG_INDEX.bundleDirectory]).toBe('build/bundleOutput');
});

it("defaults the oversized patch policy to skipping the patch", async () => {
const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1']);

expect(args[ARG_INDEX.onOversizedPatch]).toBe('skip');
});

it("passes the chosen oversized patch policy through to the release", async () => {
const args = await runReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--on-oversized-patch', 'fail']);

expect(args[ARG_INDEX.onOversizedPatch]).toBe('fail');
});

it("rejects an oversized patch policy it does not know", async () => {
await expect(parseReleaseCommand(['-b', '1.0.0', '-v', '1.0.1', '--on-oversized-patch', 'ask']))
.rejects.toThrow(/--on-oversized-patch.*'ask'.*skip, fail/s);

const { release } = await import("./release.js");
expect(jest.mocked(release)).not.toHaveBeenCalled();
});
});
Loading
Loading