From 9d5c8a2a42ff9c05ae207a8cd70c48b7cef546fd Mon Sep 17 00:00:00 2001 From: Floyd Kim Date: Tue, 18 Nov 2025 16:15:38 +0900 Subject: [PATCH] feat(CLI): add --hash-calc option to release command for computing hash of existing bundle file --- cli/commands/releaseCommand/index.ts | 8 +++++ cli/commands/releaseCommand/release.ts | 32 +++++++++++++++++- cli/package.json | 6 ++-- cli/utils/unzip.ts | 46 ++++++++++++++++++++++++++ package-lock.json | 42 +++++++++++++++++++++++ package.json | 4 ++- 6 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 cli/utils/unzip.ts diff --git a/cli/commands/releaseCommand/index.ts b/cli/commands/releaseCommand/index.ts index d3cc6c6e..a8d1d540 100644 --- a/cli/commands/releaseCommand/index.ts +++ b/cli/commands/releaseCommand/index.ts @@ -19,6 +19,7 @@ type Options = { skipBundle: boolean; skipCleanup: boolean; outputBundleDir: string; + hashCalc?: boolean; } program.command('release') @@ -36,6 +37,7 @@ program.command('release') .option('--enable ', 'make the release to be enabled', parseBoolean, true) .option('--rollout ', 'rollout percentage (0-100)', parseFloat) .option('--skip-bundle ', 'skip bundle process', parseBoolean, false) + .option('--hash-calc ', 'calculates the bundle file hash used for packageHash in the release history (Requires setting --skip-bundle to true)', parseBoolean) .option('--skip-cleanup ', 'skip cleanup process', parseBoolean, false) .option('--output-bundle-dir ', 'name of directory containing the bundle file created by the "bundle" command', OUTPUT_BUNDLE_DIR) .action(async (options: Options) => { @@ -46,6 +48,11 @@ program.command('release') process.exit(1); } + if (options.hashCalc && !options.skipBundle) { + console.error('--hash-calc option can be used only when --skip-bundle is set to true.'); + process.exit(1); + } + await release( config.bundleUploader, config.getReleaseHistory, @@ -64,6 +71,7 @@ program.command('release') options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, + options.hashCalc, ) console.log('🚀 Release completed.') diff --git a/cli/commands/releaseCommand/release.ts b/cli/commands/releaseCommand/release.ts index abe45cb2..724a61a3 100644 --- a/cli/commands/releaseCommand/release.ts +++ b/cli/commands/releaseCommand/release.ts @@ -3,6 +3,8 @@ import path from "path"; import { bundleCodePush } from "../bundleCommand/bundleCodePush.js"; import { addToReleaseHistory } from "./addToReleaseHistory.js"; import type { CliConfigInterface } from "../../../typings/react-native-code-push.d.ts"; +import { generatePackageHashFromDirectory } from "../../utils/hash-utils.js"; +import { unzip } from "../../utils/unzip.js"; export async function release( bundleUploader: CliConfigInterface['bundleUploader'], @@ -22,12 +24,21 @@ export async function release( skipBundle: boolean, skipCleanup: boolean, bundleDirectory: string, + hashCalc?: boolean, ): Promise { const bundleFileName = skipBundle ? readBundleFileNameFrom(bundleDirectory) : await bundleCodePush(framework, platform, outputPath, entryFile, jsBundleName, bundleDirectory); const bundleFilePath = `${bundleDirectory}/${bundleFileName}`; + const packageHash = await (() => { + if (skipBundle && hashCalc) { + return calcHashFromBundleFile(bundleFilePath); + } + // If not using --skip-bundle, the bundleFileName represents package hash already. + return bundleFileName; + })(); + const downloadUrl = await (async () => { try { const { downloadUrl } = await bundleUploader(bundleFilePath, platform, identifier); @@ -42,7 +53,7 @@ export async function release( appVersion, binaryVersion, downloadUrl, - bundleFileName, + packageHash, getReleaseHistory, setReleaseHistory, platform, @@ -70,3 +81,22 @@ function readBundleFileNameFrom(bundleDirectory: string): string { const bundleFilePath = path.join(bundleDirectory, files[0]); return path.basename(bundleFilePath); } + +async function calcHashFromBundleFile(bundleFilePath: string): Promise { + const tempDir = path.resolve(path.join(path.dirname(bundleFilePath), 'temp_contents_for_hash_calc')); + const zipFilePath = path.resolve(bundleFilePath); + + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + fs.mkdirSync(tempDir, { recursive: true }); + + try { + await unzip(zipFilePath, tempDir); + const hash = await generatePackageHashFromDirectory(tempDir, tempDir); + console.log(`log: Calculated package hash from existing bundle file: ${hash}`); + return hash; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/cli/package.json b/cli/package.json index 11dd352c..428a8584 100644 --- a/cli/package.json +++ b/cli/package.json @@ -14,7 +14,8 @@ "commander": "^12.1.0", "shelljs": "^0.10.0", "xcode": "^3.0.1", - "yazl": "^3.3.1" + "yazl": "^3.3.1", + "yauzl": "^3.2.0" }, "peerDependencies": { "ts-node": ">=10" @@ -28,6 +29,7 @@ "node": ">=18" }, "devDependencies": { - "@types/yazl": "^3.3.0" + "@types/yazl": "^3.3.0", + "@types/yauzl": "^2.10.3" } } diff --git a/cli/utils/unzip.ts b/cli/utils/unzip.ts new file mode 100644 index 00000000..637960b9 --- /dev/null +++ b/cli/utils/unzip.ts @@ -0,0 +1,46 @@ +import fs from "fs"; +import path from "path"; +import yauzl from "yauzl"; + +export function unzip(zipPath: string, outputDir: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zipFile) => { + if (err) return reject(err); + + zipFile.readEntry(); + + zipFile.on("entry", (entry) => { + const fullPath = path.join(outputDir, entry.fileName); + + // Handle directory entry + if (/\/$/.test(entry.fileName)) { + fs.mkdir(fullPath, { recursive: true }, (err) => { + if (err) return reject(err); + zipFile.readEntry(); + }); + return; + } + + // Handle file entry + zipFile.openReadStream(entry, (err, readStream) => { + if (err) return reject(err); + + fs.mkdir(path.dirname(fullPath), { recursive: true }, (err) => { + if (err) return reject(err); + + const writeStream = fs.createWriteStream(fullPath); + readStream.pipe(writeStream); + + // Continue to the next entry after writing + writeStream.on("close", () => { + zipFile.readEntry(); + }); + }); + }); + }); + + zipFile.on("end", resolve); + zipFile.on("error", reject); + }); + }); +} diff --git a/package-lock.json b/package-lock.json index a0f17a37..8a74b808 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "semver": "^7.3.5", "shelljs": "^0.10.0", "xcode": "^3.0.1", + "yauzl": "^3.2.0", "yazl": "^3.3.1" }, "bin": { @@ -34,6 +35,7 @@ "@types/q": "^1.5.4", "@types/semver": "^7.5.8", "@types/shelljs": "^0.8.15", + "@types/yauzl": "^2.10.3", "archiver": "latest", "babel-jest": "^29.7.0", "body-parser": "latest", @@ -72,9 +74,11 @@ "commander": "^12.1.0", "shelljs": "^0.10.0", "xcode": "^3.0.1", + "yauzl": "^3.2.0", "yazl": "^3.3.1" }, "devDependencies": { + "@types/yauzl": "^2.10.3", "@types/yazl": "^3.3.0" }, "engines": { @@ -4628,6 +4632,16 @@ "version": "21.0.3", "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yazl": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.0.tgz", @@ -11814,6 +11828,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -14716,6 +14736,28 @@ "node": ">=10" } }, + "node_modules/yauzl": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", + "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/yazl": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", diff --git a/package.json b/package.json index bd2a225f..858fc687 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "semver": "^7.3.5", "shelljs": "^0.10.0", "xcode": "^3.0.1", - "yazl": "^3.3.1" + "yazl": "^3.3.1", + "yauzl": "^3.2.0" }, "peerDependencies": { "expo": ">=50.0.0", @@ -104,6 +105,7 @@ "@types/q": "^1.5.4", "@types/semver": "^7.5.8", "@types/shelljs": "^0.8.15", + "@types/yauzl": "^2.10.3", "archiver": "latest", "babel-jest": "^29.7.0", "body-parser": "latest",