Skip to content

feat(cli): generate platform binary patch artifacts - #150

Draft
floyd-soomgo wants to merge 5 commits into
feature/binary-patch-codec-contractfrom
feature/binary-patch-cli-artifacts
Draft

feat(cli): generate platform binary patch artifacts#150
floyd-soomgo wants to merge 5 commits into
feature/binary-patch-codec-contractfrom
feature/binary-patch-cli-artifacts

Conversation

@floyd-soomgo

Copy link
Copy Markdown
Member

Summary

Second PR of the binary differential OTA series, building on the codec contract from the previous PR (stacked on feature/binary-patch-codec-contract — merge that first).

This PR makes the CLI produce and upload two artifacts per platform release: the existing full archive, plus a patch archive that clients can apply against the Hermes bytecode bundle embedded in the app binary. Everything downstream of artifact generation is intentionally untouched: the patch URL is printed but not yet written into the release history — propagating it through the release history JSON, typings, and the JS runtime is the next PR, and the native appliers come after that. A release without the new option behaves exactly as before.

Usage

npx code-push release \
  --platform android \
  --binary-version 1.0.0 \
  --app-version 1.0.12 \
  --binary-bundle-path <android-build-artifact>/index.android.bundle

--binary-bundle-path must point at the exact bundle bytes shipped in the store binary (extracted from the APK/IPA or preserved from the release build). Without it, nothing changes.

Changes

  • cli/functions/makeBinaryPatchBundle.ts — builds the patch archive from the full-archive contents: the target bundle is replaced by <bundlePath>.patch (same relative location) plus a root manifest codepush-binary-patch.json (formatVersion / algorithm imported from the codec-contract constants, bundlePath, patchFile, baseBundleHash, targetBundleHash, targetBundleSize). Assets and all other files ship unchanged, so a client that applies the patch reconstructs a folder with the same packageHash as the full archive — pinned by a round-trip test. Archive name: <packageHash>-patch.zip.
  • Hermes -base-bytecode alignment — when a base bundle is provided, the target compile passes -base-bytecode <base> so the new bytecode is laid out for minimal diffs (patches shrink ~3-5x in our measurements). Support is detected from hermesc --help; unsupported compilers get a warning and a plain compile, while a compile failure with the flag fails the release (it usually means the base file is not valid bytecode).
  • bundle --binary-bundle-path — applies the same alignment and records binary-patch-base.json ({"baseBundleHash"}) at the output root, deliberately outside the CodePush contents so it never affects packageHash (tested).
  • release --skip-bundle --binary-bundle-path — patches the archive actually being released (it unpacks the bundle from that archive rather than trusting leftover build contents), errors clearly when no bundle is found, and warns when the base differs from the one recorded at bundle time (the patch stays valid; only the alignment benefit is lost).
    • Size summary + oversized-patch policy — full vs patch archive sizes and savings (1 − patch/full) are printed before any upload. If the patch archive is not smaller than the full archive, --on-oversized-patch decides (non-interactive, CI-friendly): skip (default) warns, marks the
      skip in the summary, and releases the full bundle only; fail aborts the release before anything is uploaded. The skipped patch archive is left in the output directory for inspection.
  • Upload atomicity — full archive first, then patch, both through the existing bundleUploader; if either upload fails the release fails and the release history is not updated. Temp directories are cleaned on success and failure.
  • Fixes found along the way
    • makeCodePushBundle resolved absolute output directories under the CWD (latent path bug, caught by the new tests).
    • release's -j, --js-bundle-name was read from the wrong commander attribute and never took effect; it now works and has a command-level regression test.
    • Test-suite tool provisioning moved to a jest globalSetup, fixing cold-checkout CI (this is what turned CI green on this branch).

Note for config authors

Both artifacts go through bundleUploader(filePath, platform, identifier). The patch archive arrives as a second call for the same platform/identifier — a config that derives its storage key from platform/identifier alone (ignoring the file name) would overwrite the full archive with the patch. Derive keys from the file name (as the sample configs do).

Test plan

npm run --workspace cli test   # 52 tests — real hdiffz round-trips, upload-failure atomicity, cleanup assertions
npm run typecheck
npm run build:cli

CI runs the same suite on a cold checkout (tools are built once in jest global setup).

An absolute --output-path produced an absolute bundle directory, which the
'./' prefix turned into a path below the current working directory.
`bundle` and `release` accept --binary-bundle-path, the JS bundle of the
target binary. With it, `release` publishes two artifacts per platform: the
full bundle named after its packageHash, and `<packageHash>-patch.zip`, which
carries the target bundle only as a patch against the binary's bundle plus a
`codepush-binary-patch.json` manifest. Every other file is copied unchanged,
so applying the patch and dropping the two patch-only files reproduces the
full contents byte for byte - and therefore the same packageHash.

The Hermes compilation is aligned with the base bundle through
`-base-bytecode` when the app's own compiler advertises the flag, which is
what keeps the patch small. A compiler without the flag only warns; a
compilation that fails with it fails the release, since the base is then
wrong.

Both archive sizes and the saving are printed before anything is uploaded,
and the full bundle is uploaded before the patch. A failed upload of either
leaves the release history untouched. Carrying the patch URL in the release
history is deliberately not part of this change.
The release action read `options.bundleName`, but commander stores the
`-j, --js-bundle-name` flag as `options.jsBundleName`, so a custom JS bundle
name never reached `release()` and the platform default was always used.

Optionality is now expressed in the types instead of asserted away, so "not
given" cannot pass for a name again.
The suites that generate real patches each built the tools in their own
`beforeAll`, so a suite without that hook - the release flow suite - failed on
a machine with no `.hdiffpatch-tools` yet, and two workers could run the same
build at the same time.

A jest global setup builds them once before any worker starts, which also
removes the duplicated helper from the two suites that had one.
A patch is only worth publishing when it is smaller than the archive it
replaces, and the CLI runs unattended in CI, so what happens otherwise is
decided up front instead of being left to whoever reads the summary.

`skip`, the default, warns, records the skip in the summary and releases the
full bundle alone. `fail` stops the release before either artifact is
uploaded, so the release history stays untouched. Equal sizes count as
oversized: a patch that saves nothing still costs a client an extra download
and an apply step.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant