Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,10 @@ jobs:

- name: Run Jest
run: npm run jest -- --watchman=false

# jest runs babel in CJS mode and cannot load the modules that use `import.meta`, so
# the compiled CLI is started once here to prove it resolves its own files.
- name: Build and run the compiled CLI
run: |
npm run build:cli
node bin/code-push.js build-patch-tools --print-hash > /dev/null
65 changes: 50 additions & 15 deletions cli/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,26 +137,14 @@ CMake가 필요하지만 React Native 프로젝트라면 대개 이미 갖추고
#### 사전 준비: patch 생성 도구 빌드

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

```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"
npx code-push build-patch-tools
```

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

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

Expand Down Expand Up @@ -224,6 +212,53 @@ npx code-push release -b 1.0.0 -v 1.0.1 -p ios --binary-bundle-path ./binary/mai

---

### `build-patch-tools`

`release --binary-bundle-path`가 binary patch를 생성하고 검증할 때 쓰는 HDiffPatch 도구
`hdiffz`와 `hpatchz`를 소스에서 빌드해, `release`가 찾는 위치에 설치합니다.

```bash
npx code-push build-patch-tools [options]
```

| 옵션 | 설명 | 기본값 |
|------|------|--------|
| `--tools-dir <path>` | 도구를 설치할 디렉토리 | `HDIFFPATCH_TOOLS_DIR`가 설정돼 있으면 그 값, 아니면 작업 디렉토리의 `.hdiffpatch-tools` |
| `--force` | 도구가 이미 설치돼 있어도 다시 빌드 | `false` |
| `--print-hash` | 빌드하지 않고 빌드 스크립트의 해시만 출력. 아래 설명 참고 | `false` |

도구는 패키지 의존성으로 설치되지 않고 고정된 upstream 소스에서 빌드되므로, `git`, C/C++
툴체인(`make`, `cc`, `c++`), 네트워크 연결이 필요합니다. 머신마다 한 번만 실행하면 됩니다.
설치 디렉토리에 두 도구가 이미 있으면 아무 일도 하지 않습니다. 설치된 도구의 버전은 확인하지
않으므로, 이 패키지를 다른 HDiffPatch 버전을 고정한 버전으로 올렸다면 `--force`로 다시
빌드하세요.

기본 설치 디렉토리는 `release`가 상위 디렉토리로 올라가기 전에 가장 먼저 찾아보는 곳입니다.
프로젝트의 `.gitignore`에 `.hdiffpatch-tools/`를 추가하세요. `HDIFFPATCH_TOOLS_DIR`를 설정하면
설치와 탐색이 모두 그 디렉토리로 옮겨갑니다. 미리 빌드해 둔 CI 이미지나 프로젝트 밖의 공용
설치를 사용할 때 이 방법을 씁니다.

설치 디렉토리를 CI 캐시에 넣으려면 빌드 결과가 달라질 때 함께 바뀌는 키가 필요합니다.
`--print-hash`가 그 값을 출력합니다. 소스 버전과 빌드 플래그를 고정하고 있는 빌드 스크립트의
SHA-256입니다. 스크립트가 바뀌면 주석만 바뀌어도 값이 달라지고, 같은 스크립트를 담은 패키지
버전 사이에서는 같게 유지됩니다. CI의 checksum이 읽을 수 있는 파일에 써 두고, 스크립트가 알지
못하는 머신 아키텍처와 함께 키를 구성하세요.

**예시:**

```bash
# CI 이미지가 재사용하는 공용 위치에 빌드
npx code-push build-patch-tools --tools-dir /opt/hdiffpatch-tools

# 고정된 HDiffPatch 버전이 바뀌었거나 설치가 깨졌을 때 다시 빌드
npx code-push build-patch-tools --force

# .hdiffpatch-tools를 CI 캐시에 넣을 때 쓸 키를 파일로 남김
npx code-push build-patch-tools --print-hash > .hdiffpatch-tools.hash
```

---

### `create-history`

바이너리 버전에 대한 새 릴리스 히스토리 항목을 생성합니다. 앱스토어에 새 바이너리를 출시할 때마다 한 번씩 실행하세요.
Expand Down
68 changes: 51 additions & 17 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,28 +136,15 @@ them with nothing to add.

#### 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:
Producing a patch needs HDiffPatch's `hdiffz`, which is not installed as a package
dependency. Build it once per machine with [`build-patch-tools`](#build-patch-tools):

```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"
npx code-push build-patch-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.
fails with that command in the message before anything is uploaded.

#### Oversized patches

Expand Down Expand Up @@ -229,6 +216,53 @@ npx code-push release -b 1.0.0 -v 1.0.1 -p ios --binary-bundle-path ./binary/mai

---

### `build-patch-tools`

Builds `hdiffz` and `hpatchz`, the HDiffPatch tools that `release --binary-bundle-path`
generates and verifies binary patches with, and installs them where `release` looks for them.

```bash
npx code-push build-patch-tools [options]
```

| Option | Description | Default |
|--------|-------------|---------|
| `--tools-dir <path>` | Directory to install the tools into | `HDIFFPATCH_TOOLS_DIR` if set, else `.hdiffpatch-tools` in the working directory |
| `--force` | Rebuild even when the tools are already installed | `false` |
| `--print-hash` | Print a hash of the build script instead of building. See below | `false` |

The tools are built from pinned upstream sources rather than installed as a package
dependency, so the build needs `git`, a C/C++ toolchain (`make`, `cc`, `c++`) and network
access. It runs once per machine: the command does nothing when both tools are already in
the install directory. It does not check which version they are, so after upgrading this
package to one that pins a different HDiffPatch, rebuild with `--force`.

The default install directory is the first place `release` looks, before it walks up the
parent directories; add `.hdiffpatch-tools/` to the project's `.gitignore`. Setting
`HDIFFPATCH_TOOLS_DIR` moves both the install and the lookup to that directory, which is how
a CI image that builds the tools ahead of time, or a shared install outside the project, is
used.

A CI cache of the install directory needs a key that changes when the build would produce
different tools. `--print-hash` prints one: a SHA-256 of the build script, which pins the
sources and the build flags. It changes whenever the script changes, comments included, and
stays the same across versions of this package that ship the same script. Write it to a
file the CI's checksum can read, and combine it with the machine architecture, which the
script knows nothing about.

```bash
# Build into a shared location a CI image reuses
npx code-push build-patch-tools --tools-dir /opt/hdiffpatch-tools

# Rebuild, for a newly pinned HDiffPatch or a broken install
npx code-push build-patch-tools --force

# Key a CI cache of .hdiffpatch-tools by what the build would produce
npx code-push build-patch-tools --print-hash > .hdiffpatch-tools.hash
```

---

### `create-history`

Creates a release history entry for a binary version. Run this once per binary version you ship to the app store.
Expand Down
118 changes: 118 additions & 0 deletions cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
import { buildPatchTools, hashBuildScript } from "./buildPatchTools.js";

/**
* The command is a thin wrapper around the build script this package ships. What it has
* to get right is what it hands the script: the directory the CLI will later look in, and
* `--force` when asked. A script that records what it was handed proves both without
* cloning and compiling HDiffPatch. The shipped script is run once at the end, against an
* install it already finds complete, to show the wrapper speaks its actual interface.
*/

const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const SHIPPED_BUILD_SCRIPT = path.join(REPO_ROOT, "scripts", "binary-patch", "build-hdiffpatch.sh");
/** Where `jest.globalSetup.ts` built the tools for this run. */
const INSTALLED_TOOLS_DIR = process.env.HDIFFPATCH_TOOLS_DIR || path.join(REPO_ROOT, ".hdiffpatch-tools");

let workDir: string;

beforeEach(() => {
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "build-patch-tools-"));
});

afterEach(() => {
fs.rmSync(workDir, { recursive: true, force: true });
});

function writeScript(body: string): string {
const scriptPath = path.join(workDir, "build.sh");
fs.writeFileSync(scriptPath, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
return scriptPath;
}

/** A script that writes the install directory it was given, then each argument, one per line. */
function writeRecordingScript(): string {
return writeScript('printf \'%s\\n\' "$HDIFFPATCH_TOOLS_DIR" "$@" > "$HDIFFPATCH_TOOLS_DIR/invocation"');
}

function readInvocation(toolsDir: string): string[] {
return fs.readFileSync(path.join(toolsDir, "invocation"), "utf8").trimEnd().split("\n");
}

describe("buildPatchTools", () => {
it("runs the build script against the tools directory it was given, without forcing a rebuild", () => {
const toolsDir = path.join(workDir, "tools");
fs.mkdirSync(toolsDir);

buildPatchTools({ buildScriptPath: writeRecordingScript(), toolsDir, force: false });

expect(readInvocation(toolsDir)).toEqual([toolsDir]);
});

it("asks the build script to rebuild when forced", () => {
const toolsDir = path.join(workDir, "tools");
fs.mkdirSync(toolsDir);

buildPatchTools({ buildScriptPath: writeRecordingScript(), toolsDir, force: true });

expect(readInvocation(toolsDir)).toEqual([toolsDir, "--force"]);
});

it("fails with the exit code when the build script fails", () => {
const buildScriptPath = writeScript("exit 3");

expect(() => buildPatchTools({ buildScriptPath, toolsDir: workDir, force: false })).toThrow(/exit code 3/);
});

it("fails naming the script when it cannot be started", () => {
const buildScriptPath = path.join(workDir, "no-such-script.sh");

expect(() => buildPatchTools({ buildScriptPath, toolsDir: workDir, force: false })).toThrow(
/no-such-script\.sh/,
);
});

it("succeeds against an install the shipped script already finds complete", () => {
// Without an install the shipped script clones and compiles for minutes, and
// `spawnSync` holds the worker past any jest timeout. `jest.globalSetup.ts`
// provides the install; this says so when it did not.
const missing = ["hdiffz", "hpatchz"].filter((tool) => !fs.existsSync(path.join(INSTALLED_TOOLS_DIR, tool)));
if (missing.length > 0) {
throw new Error(
`${missing.join(", ")} not installed in ${INSTALLED_TOOLS_DIR}: jest.globalSetup.ts should have built the tools first`,
);
}

expect(() =>
buildPatchTools({ buildScriptPath: SHIPPED_BUILD_SCRIPT, toolsDir: INSTALLED_TOOLS_DIR, force: false }),
).not.toThrow();
});
});

/**
* A CI cache of the installed tools is keyed by this hash, so it has to stay the same for as
* long as the script would build the same tools, and change as soon as it would not.
*/
describe("hashBuildScript", () => {
it("hashes two scripts with the same contents to the same hex digest", () => {
const first = path.join(workDir, "first.sh");
const second = path.join(workDir, "second.sh");
fs.writeFileSync(first, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.3\n");
fs.writeFileSync(second, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.3\n");

expect(hashBuildScript(first)).toBe(hashBuildScript(second));
expect(hashBuildScript(first)).toMatch(/^[0-9a-f]{64}$/);
});

it("hashes a script whose pinned version changed to a different value", () => {
const before = path.join(workDir, "before.sh");
const after = path.join(workDir, "after.sh");
fs.writeFileSync(before, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.3\n");
fs.writeFileSync(after, "#!/bin/sh\nHDIFFPATCH_TAG=v5.1.4\n");

expect(hashBuildScript(after)).not.toBe(hashBuildScript(before));
});
});
46 changes: 46 additions & 0 deletions cli/commands/buildPatchToolsCommand/buildPatchTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { spawnSync } from "child_process";
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { TOOLS_DIR_ENV_NAME } from "../../utils/binaryPatch.js";

interface BuildPatchToolsOptions {
/** The `build-hdiffpatch.sh` this package ships. */
buildScriptPath: string;
/** Where the script installs `hdiffz` and `hpatchz`. It has to be a place the CLI looks in. */
toolsDir: string;
/** Rebuild even when both tools are already there. */
force: boolean;
}

/**
* Runs the build script with the install directory the caller chose.
*
* The script's output goes straight to the terminal: a first build clones and compiles
* HDiffPatch, which takes minutes, and a silent wait would look like a hang. So when the
* script fails, its own message has already been shown, and the error raised here only
* has to say that it did.
*/
export function buildPatchTools({ buildScriptPath, toolsDir, force }: BuildPatchToolsOptions): void {
const result = spawnSync(buildScriptPath, force ? ['--force'] : [], {
stdio: 'inherit',
env: { ...process.env, [TOOLS_DIR_ENV_NAME]: toolsDir },
});

if (result.error) {
throw new Error(`failed to run ${buildScriptPath}: ${result.error.message}`);
}
if (result.status !== 0) {
const reason = result.status === null ? `signal ${result.signal}` : `exit code ${result.status}`;
throw new Error(`${path.basename(buildScriptPath)} failed with ${reason}`);
}
}

/**
* SHA-256 of the build script's bytes, for keying a CI cache of the installed tools. The
* script pins the sources and the build flags, so it changes whenever the tools it would
* build do.
*/
export function hashBuildScript(buildScriptPath: string): string {
return crypto.createHash('sha256').update(fs.readFileSync(buildScriptPath)).digest('hex');
}
44 changes: 44 additions & 0 deletions cli/commands/buildPatchToolsCommand/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import path from "path";
import { fileURLToPath } from "url";
import { program } from "commander";
import { TOOLS_DIR_ENV_NAME, TOOLS_DIR_NAME } from "../../utils/binaryPatch.js";
import { buildPatchTools, hashBuildScript } from "./buildPatchTools.js";

type Options = {
toolsDir: string;
force: boolean;
printHash: boolean;
}

/**
* The CLI runs compiled, from `cli/dist/commands/buildPatchToolsCommand/`, which puts the
* package root - and the script shipped under it - four levels up.
*/
const BUILD_SCRIPT_PATH = fileURLToPath(
new URL("../../../../scripts/binary-patch/build-hdiffpatch.sh", import.meta.url),
);

/**
* Installs where `release` will look: `HDIFFPATCH_TOOLS_DIR` when it is set, and otherwise
* a `.hdiffpatch-tools` directory in the working directory, the first place the lookup
* checks before walking up.
*/
const DEFAULT_TOOLS_DIR = process.env[TOOLS_DIR_ENV_NAME] || path.resolve(process.cwd(), TOOLS_DIR_NAME);

program.command('build-patch-tools')
.description('Builds hdiffz and hpatchz from source and installs them where `release --binary-bundle-path` looks for them.\nThe build clones the pinned HDiffPatch sources, so it needs git, make, a C/C++ compiler and network access. It does nothing when the tools are already installed.')
.option('--tools-dir <path>', 'directory to install the tools into', DEFAULT_TOOLS_DIR)
.option('--force', 'rebuild even when the tools are already installed', false)
.option('--print-hash', 'print a hash of the build script, for keying a CI cache of the tools, and exit without building', false)
.action((options: Options) => {
if (options.printHash) {
console.log(hashBuildScript(BUILD_SCRIPT_PATH));
return;
}

buildPatchTools({
buildScriptPath: BUILD_SCRIPT_PATH,
toolsDir: path.resolve(options.toolsDir),
force: options.force,
});
});
Loading