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

**예시:**

Expand All @@ -121,6 +138,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
25 changes: 25 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,21 @@ 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.

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 +136,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