From 1122331832f3eea748bd7681a3b19d729011dfaa Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 15:12:59 +0900 Subject: [PATCH 1/9] feat(cli): build the patch tools with a command instead of a script path Building hdiffz and hpatchz meant running node_modules/@bravemobile/react-native-code-push/scripts/binary-patch/build-hdiffpatch.sh by hand - a path into the package's own layout - and overriding where it installs, because the script's default is the package root, which sits below the project where the CLI never looks. `npx code-push build-patch-tools` runs the shipped script itself and installs where `release` will look: `HDIFFPATCH_TOOLS_DIR` when it is set, and otherwise `.hdiffpatch-tools` in the working directory. `--tools-dir` overrides both and `--force` passes through. --- .../buildPatchTools.test.ts | 83 +++++++++++++++++++ .../buildPatchToolsCommand/buildPatchTools.ts | 35 ++++++++ cli/commands/buildPatchToolsCommand/index.ts | 37 +++++++++ cli/index.ts | 5 ++ cli/utils/binaryPatch.ts | 4 +- 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts create mode 100644 cli/commands/buildPatchToolsCommand/buildPatchTools.ts create mode 100644 cli/commands/buildPatchToolsCommand/index.ts diff --git a/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts b/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts new file mode 100644 index 000000000..a2a17f3d9 --- /dev/null +++ b/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts @@ -0,0 +1,83 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { buildPatchTools } 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", () => { + expect(() => + buildPatchTools({ buildScriptPath: SHIPPED_BUILD_SCRIPT, toolsDir: INSTALLED_TOOLS_DIR, force: false }), + ).not.toThrow(); + }); +}); diff --git a/cli/commands/buildPatchToolsCommand/buildPatchTools.ts b/cli/commands/buildPatchToolsCommand/buildPatchTools.ts new file mode 100644 index 000000000..cd5980946 --- /dev/null +++ b/cli/commands/buildPatchToolsCommand/buildPatchTools.ts @@ -0,0 +1,35 @@ +import { spawnSync } from "child_process"; +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}`); + } +} diff --git a/cli/commands/buildPatchToolsCommand/index.ts b/cli/commands/buildPatchToolsCommand/index.ts new file mode 100644 index 000000000..572eede06 --- /dev/null +++ b/cli/commands/buildPatchToolsCommand/index.ts @@ -0,0 +1,37 @@ +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 } from "./buildPatchTools.js"; + +type Options = { + toolsDir: string; + force: 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 ', 'directory to install the tools into', DEFAULT_TOOLS_DIR) + .option('--force', 'rebuild even when the tools are already installed', false) + .action((options: Options) => { + buildPatchTools({ + buildScriptPath: BUILD_SCRIPT_PATH, + toolsDir: path.resolve(options.toolsDir), + force: options.force, + }); + }); diff --git a/cli/index.ts b/cli/index.ts index 18db6f452..c2939e152 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -34,6 +34,11 @@ import "./commands/showHistoryCommand/index.js"; */ import "./commands/initCommand/index.js"; +/** + * npx code-push build-patch-tools + */ +import "./commands/buildPatchToolsCommand/index.js"; + shell.set("-e"); shell.set("+v"); diff --git a/cli/utils/binaryPatch.ts b/cli/utils/binaryPatch.ts index aec7852b8..37a8e7d7f 100644 --- a/cli/utils/binaryPatch.ts +++ b/cli/utils/binaryPatch.ts @@ -37,8 +37,8 @@ export type BinaryPatchTool = 'hdiffz' | 'hpatchz'; const HDIFFZ_OPTIONS = ['-f', '-m-6', '-c-zstd-21-24']; const HPATCHZ_OPTIONS = ['-f', '-m']; -const TOOLS_DIR_ENV_NAME = 'HDIFFPATCH_TOOLS_DIR'; -const TOOLS_DIR_NAME = '.hdiffpatch-tools'; +export const TOOLS_DIR_ENV_NAME = 'HDIFFPATCH_TOOLS_DIR'; +export const TOOLS_DIR_NAME = '.hdiffpatch-tools'; const BUILD_SCRIPT_PATH = 'scripts/binary-patch/build-hdiffpatch.sh'; /** From bcea95a619499ea1792c28b9b99333cb1d296955 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 15:12:59 +0900 Subject: [PATCH 2/9] fix(cli): point a release missing the patch tools at the command that builds them The error named `scripts/binary-patch/build-hdiffpatch.sh`, a path relative to the package root that does not run from a project directory. --- cli/utils/binaryPatch.test.ts | 2 +- cli/utils/binaryPatch.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/utils/binaryPatch.test.ts b/cli/utils/binaryPatch.test.ts index e3a95e33c..ae9df0d01 100644 --- a/cli/utils/binaryPatch.test.ts +++ b/cli/utils/binaryPatch.test.ts @@ -101,7 +101,7 @@ describe("generatePatch/applyPatch", () => { const previous = process.env.HDIFFPATCH_TOOLS_DIR; process.env.HDIFFPATCH_TOOLS_DIR = workPath("no-such-dir"); try { - expect(() => resolveBinaryPatchTool("hdiffz")).toThrow(/build-hdiffpatch\.sh/); + expect(() => resolveBinaryPatchTool("hdiffz")).toThrow(/npx code-push build-patch-tools/); } finally { if (previous === undefined) { delete process.env.HDIFFPATCH_TOOLS_DIR; diff --git a/cli/utils/binaryPatch.ts b/cli/utils/binaryPatch.ts index 37a8e7d7f..f604de819 100644 --- a/cli/utils/binaryPatch.ts +++ b/cli/utils/binaryPatch.ts @@ -39,7 +39,7 @@ const HPATCHZ_OPTIONS = ['-f', '-m']; export const TOOLS_DIR_ENV_NAME = 'HDIFFPATCH_TOOLS_DIR'; export const TOOLS_DIR_NAME = '.hdiffpatch-tools'; -const BUILD_SCRIPT_PATH = 'scripts/binary-patch/build-hdiffpatch.sh'; +const BUILD_COMMAND = 'npx code-push build-patch-tools'; /** * Finds the hdiffz/hpatchz executable, looking at `HDIFFPATCH_TOOLS_DIR` first and @@ -56,7 +56,7 @@ export function resolveBinaryPatchTool(tool: BinaryPatchTool): string { } throw new Error( `${TOOLS_DIR_ENV_NAME} is set to '${configuredDir}' but it does not contain '${tool}'. ` + - `Build the tools with '${BUILD_SCRIPT_PATH}'.`, + `Build the tools with '${BUILD_COMMAND}'.`, ); } @@ -75,7 +75,7 @@ export function resolveBinaryPatchTool(tool: BinaryPatchTool): string { throw new Error( `'${tool}' not found in any '${TOOLS_DIR_NAME}' directory at or above '${process.cwd()}'. ` + - `Build it with '${BUILD_SCRIPT_PATH}', or set ${TOOLS_DIR_ENV_NAME} to a directory that contains it.`, + `Build it with '${BUILD_COMMAND}', or set ${TOOLS_DIR_ENV_NAME} to a directory that contains it.`, ); } From 0ef90d0f5f478d37ebd45d428706bb539b0db40f Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 15:12:59 +0900 Subject: [PATCH 3/9] docs(cli): document build-patch-tools in place of the script path The prerequisites paragraph no longer has to explain that the script installs where the CLI does not look, or how to point HDIFFPATCH_TOOLS_DIR into node_modules. --- cli/README.ko.md | 55 +++++++++++++++++++++++++++++++++------------- cli/README.md | 57 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 80 insertions(+), 32 deletions(-) diff --git a/cli/README.ko.md b/cli/README.ko.md index 3f8fa2c60..dfac83bb4 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -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 번들보다 작지 않을 때 @@ -224,6 +212,43 @@ 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 ` | 도구를 설치할 디렉토리 | `HDIFFPATCH_TOOLS_DIR`가 설정돼 있으면 그 값, 아니면 작업 디렉토리의 `.hdiffpatch-tools` | +| `--force` | 도구가 이미 설치돼 있어도 다시 빌드 | `false` | + +도구는 패키지 의존성으로 설치되지 않고 고정된 upstream 소스에서 빌드되므로, `git`, C/C++ +툴체인(`make`, `cc`, `c++`), 네트워크 연결이 필요합니다. 머신마다 한 번만 실행하면 됩니다. +설치 디렉토리에 두 도구가 이미 있으면 아무 일도 하지 않습니다. 설치된 도구의 버전은 확인하지 +않으므로, 이 패키지를 다른 HDiffPatch 버전을 고정한 버전으로 올렸다면 `--force`로 다시 +빌드하세요. + +기본 설치 디렉토리는 `release`가 상위 디렉토리로 올라가기 전에 가장 먼저 찾아보는 곳입니다. +프로젝트의 `.gitignore`에 `.hdiffpatch-tools/`를 추가하세요. `HDIFFPATCH_TOOLS_DIR`를 설정하면 +설치와 탐색이 모두 그 디렉토리로 옮겨갑니다. 미리 빌드해 둔 CI 이미지나 프로젝트 밖의 공용 +설치를 사용할 때 이 방법을 씁니다. + +**예시:** + +```bash +# CI 이미지가 재사용하는 공용 위치에 빌드 +npx code-push build-patch-tools --tools-dir /opt/hdiffpatch-tools + +# 고정된 HDiffPatch 버전이 바뀌었거나 설치가 깨졌을 때 다시 빌드 +npx code-push build-patch-tools --force +``` + +--- + ### `create-history` 바이너리 버전에 대한 새 릴리스 히스토리 항목을 생성합니다. 앱스토어에 새 바이너리를 출시할 때마다 한 번씩 실행하세요. diff --git a/cli/README.md b/cli/README.md index d95ea8571..5ba960504 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 @@ -229,6 +216,42 @@ 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 ` | 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` | + +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. + +```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 +``` + +--- + ### `create-history` Creates a release history entry for a binary version. Run this once per binary version you ship to the app store. From bcbe43638185636e13997c1c047998b1fd24b2b8 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 15:21:17 +0900 Subject: [PATCH 4/9] feat(cli): print a hash of the patch tool build script for CI cache keys A CI cache of the installed tools is keyed by a checksum of the build script, which is what the script pins the sources and build flags in. Computing it meant naming the script's path inside node_modules - the path the command exists to hide. `--print-hash` prints the SHA-256 of the shipped script and exits without building. --- .../buildPatchTools.test.ts | 27 ++++++++++++++++++- .../buildPatchToolsCommand/buildPatchTools.ts | 11 ++++++++ cli/commands/buildPatchToolsCommand/index.ts | 9 ++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts b/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts index a2a17f3d9..cb7fd2db7 100644 --- a/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts +++ b/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts @@ -2,7 +2,7 @@ import fs from "fs"; import os from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; -import { buildPatchTools } from "./buildPatchTools.js"; +import { buildPatchTools, hashBuildScript } from "./buildPatchTools.js"; /** * The command is a thin wrapper around the build script this package ships. What it has @@ -81,3 +81,28 @@ describe("buildPatchTools", () => { ).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)); + }); +}); diff --git a/cli/commands/buildPatchToolsCommand/buildPatchTools.ts b/cli/commands/buildPatchToolsCommand/buildPatchTools.ts index cd5980946..f5e8ec353 100644 --- a/cli/commands/buildPatchToolsCommand/buildPatchTools.ts +++ b/cli/commands/buildPatchToolsCommand/buildPatchTools.ts @@ -1,4 +1,6 @@ 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"; @@ -33,3 +35,12 @@ export function buildPatchTools({ buildScriptPath, toolsDir, force }: BuildPatch 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'); +} diff --git a/cli/commands/buildPatchToolsCommand/index.ts b/cli/commands/buildPatchToolsCommand/index.ts index 572eede06..d7d2f5274 100644 --- a/cli/commands/buildPatchToolsCommand/index.ts +++ b/cli/commands/buildPatchToolsCommand/index.ts @@ -2,11 +2,12 @@ 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 } from "./buildPatchTools.js"; +import { buildPatchTools, hashBuildScript } from "./buildPatchTools.js"; type Options = { toolsDir: string; force: boolean; + printHash: boolean; } /** @@ -28,7 +29,13 @@ 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 ', '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), From d6230a91022811c036ffbcdbdb798d0ec9339224 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 15:21:17 +0900 Subject: [PATCH 5/9] docs(cli): document --print-hash --- cli/README.ko.md | 10 ++++++++++ cli/README.md | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/cli/README.ko.md b/cli/README.ko.md index dfac83bb4..0ee364cf9 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -225,6 +225,7 @@ npx code-push build-patch-tools [options] |------|------|--------| | `--tools-dir ` | 도구를 설치할 디렉토리 | `HDIFFPATCH_TOOLS_DIR`가 설정돼 있으면 그 값, 아니면 작업 디렉토리의 `.hdiffpatch-tools` | | `--force` | 도구가 이미 설치돼 있어도 다시 빌드 | `false` | +| `--print-hash` | 빌드하지 않고 빌드 스크립트의 해시만 출력. 아래 설명 참고 | `false` | 도구는 패키지 의존성으로 설치되지 않고 고정된 upstream 소스에서 빌드되므로, `git`, C/C++ 툴체인(`make`, `cc`, `c++`), 네트워크 연결이 필요합니다. 머신마다 한 번만 실행하면 됩니다. @@ -237,6 +238,12 @@ npx code-push build-patch-tools [options] 설치와 탐색이 모두 그 디렉토리로 옮겨갑니다. 미리 빌드해 둔 CI 이미지나 프로젝트 밖의 공용 설치를 사용할 때 이 방법을 씁니다. +설치 디렉토리를 CI 캐시에 넣으려면 빌드 결과가 달라질 때 함께 바뀌는 키가 필요합니다. +`--print-hash`가 그 값을 출력합니다. 빌드 스크립트의 SHA-256인데, 스크립트가 소스 버전과 빌드 +플래그를 고정하고 있어서 그것을 바꾼 패키지 버전에서만 값이 달라지고 나머지 버전에서는 같게 +유지됩니다. CI의 checksum이 읽을 수 있는 파일에 써 두고, 스크립트가 알지 못하는 머신 +아키텍처와 함께 키를 구성하세요. + **예시:** ```bash @@ -245,6 +252,9 @@ 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 ``` --- diff --git a/cli/README.md b/cli/README.md index 5ba960504..17143fba2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -229,6 +229,7 @@ npx code-push build-patch-tools [options] |--------|-------------|---------| | `--tools-dir ` | 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 @@ -242,12 +243,22 @@ parent directories; add `.hdiffpatch-tools/` to the project's `.gitignore`. Sett 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, so it changes with the version of this package that changes +them and stays the same across the versions that do not. 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 ``` --- From 279dd7bb02c5c84ad11c64e8f27e2a6b21f499f4 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 16:19:52 +0900 Subject: [PATCH 6/9] ci: run the compiled CLI once in the unit test workflow jest runs babel in CJS mode and cannot load the modules that use `import.meta`, so nothing exercised the compiled CLI before publish - the relative path `build-patch-tools` resolves its script by could break without a test noticing. Building the CLI and starting one command proves it resolves its own files, on every push. --- .github/workflows/unit-test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 06889f1ce..9f9e9e671 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -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 From a9d3276427f6766e1f348d8199208824bbd13f24 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 16:19:53 +0900 Subject: [PATCH 7/9] docs(cli): say the patch tool hash follows the script, not the package version --- cli/README.ko.md | 8 ++++---- cli/README.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/README.ko.md b/cli/README.ko.md index 0ee364cf9..6a99936c3 100644 --- a/cli/README.ko.md +++ b/cli/README.ko.md @@ -239,10 +239,10 @@ npx code-push build-patch-tools [options] 설치를 사용할 때 이 방법을 씁니다. 설치 디렉토리를 CI 캐시에 넣으려면 빌드 결과가 달라질 때 함께 바뀌는 키가 필요합니다. -`--print-hash`가 그 값을 출력합니다. 빌드 스크립트의 SHA-256인데, 스크립트가 소스 버전과 빌드 -플래그를 고정하고 있어서 그것을 바꾼 패키지 버전에서만 값이 달라지고 나머지 버전에서는 같게 -유지됩니다. CI의 checksum이 읽을 수 있는 파일에 써 두고, 스크립트가 알지 못하는 머신 -아키텍처와 함께 키를 구성하세요. +`--print-hash`가 그 값을 출력합니다. 소스 버전과 빌드 플래그를 고정하고 있는 빌드 스크립트의 +SHA-256입니다. 스크립트가 바뀌면 주석만 바뀌어도 값이 달라지고, 같은 스크립트를 담은 패키지 +버전 사이에서는 같게 유지됩니다. CI의 checksum이 읽을 수 있는 파일에 써 두고, 스크립트가 알지 +못하는 머신 아키텍처와 함께 키를 구성하세요. **예시:** diff --git a/cli/README.md b/cli/README.md index 17143fba2..03e5ecc3f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -245,10 +245,10 @@ 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, so it changes with the version of this package that changes -them and stays the same across the versions that do not. Write it to a file the CI's -checksum can read, and combine it with the machine architecture, which the script knows -nothing about. +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 From 72563c1b8ed0046ee6b56e73ba92674196cbdbd8 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 16:19:53 +0900 Subject: [PATCH 8/9] test(cli): fail fast when the patch tools are not installed before the shipped script runs Without the install the shipped script clones and compiles for minutes, and `spawnSync` holds the worker past any jest timeout. Also reads `HDIFFPATCH_TOOLS_DIR` with `||`, as the CLI and `jest.globalSetup.ts` do. --- .../buildPatchToolsCommand/buildPatchTools.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts b/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts index cb7fd2db7..26cd7b86b 100644 --- a/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts +++ b/cli/commands/buildPatchToolsCommand/buildPatchTools.test.ts @@ -15,7 +15,7 @@ import { buildPatchTools, hashBuildScript } from "./buildPatchTools.js"; 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"); +const INSTALLED_TOOLS_DIR = process.env.HDIFFPATCH_TOOLS_DIR || path.join(REPO_ROOT, ".hdiffpatch-tools"); let workDir: string; @@ -76,6 +76,16 @@ describe("buildPatchTools", () => { }); 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(); From afa2f21b4908052f420a9862ba2626e62d811d88 Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Fri, 28 Aug 2026 16:19:53 +0900 Subject: [PATCH 9/9] docs(cli): note in the tool lookup that build-patch-tools installs for it --- cli/utils/binaryPatch.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/utils/binaryPatch.ts b/cli/utils/binaryPatch.ts index f604de819..36bbe489d 100644 --- a/cli/utils/binaryPatch.ts +++ b/cli/utils/binaryPatch.ts @@ -46,6 +46,9 @@ const BUILD_COMMAND = 'npx code-push build-patch-tools'; * then at a `.hdiffpatch-tools` directory in the working directory or any directory * above it. The tools are built from source rather than installed as a package * dependency, so the error explains how to get them. + * + * `build-patch-tools` (`cli/commands/buildPatchToolsCommand/index.ts`) installs into the + * first place this looks; a change to the lookup order has to be mirrored there. */ export function resolveBinaryPatchTool(tool: BinaryPatchTool): string { const configuredDir = process.env[TOOLS_DIR_ENV_NAME];