Skip to content

feat(maker-zip): binary delta support for macOS auto-updates - #4417

Open
erickzhao wants to merge 8 commits into
mainfrom
feat/maker-zip-mac-delta-updates
Open

erickzhao wants to merge 8 commits into
mainfrom
feat/maker-zip-mac-delta-updates

Conversation

@erickzhao

@erickzhao erickzhao commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds Squirrel.Mac binary delta updates to @electron-forge/maker-zip, and a new macOS-only package, @electron-forge/binary-delta, that ships Sparkle's BinaryDelta tool.

When macUpdateDelta is enabled, the ZIP maker builds a delta from the release that RELEASES.json currently lists to the one being made. It then records that delta in the manifest so Squirrel.Mac can download a small patch instead of the full ZIP.

Changes

@electron-forge/maker-zip

  • Manifest integrity (always on for darwin): every updateTo entry in RELEASES.json now carries the ZIP's sha256 (lowercase hex) and size.
  • macUpdateDelta: boolean | { binaryDeltaPath?, strict? } — when enabled on a darwin target:
    1. Downloads the previous release's ZIP from the manifest and verifies it against the recorded sha256/size.
    2. Extracts it with ditto.
    3. Checks both apps for group- or other-writable files. ShipIt strips go-w when it installs an update, so a delta built from writable files wouldn't apply.
    4. Reads the previous app's CFBundleVersion with plutil to use as from_version.
    5. Runs BinaryDelta create --version 4 --compression lzma. Squirrel.Mac applies formats 3 and 4 with any compression except bzip2.
    6. Self-checks: BinaryDelta apply onto the previous app, then codesign --verify --deep --strict on the patched result.
    7. Writes <name>-<prev>-to-<version>.delta next to the ZIP and adds updateTo.delta = { from_version, url, sha256, size }. The .delta file is returned as a make artifact, so publishers upload it.
  • Skips with a warning when the host isn't macOS, when the manifest has no current release, or when the current release is the same version.
  • On failure: if creating or verifying the delta fails, the partial delta is removed and a warning is logged, so the release still ships without a delta. With strict: true the make throws instead.
  • Where BinaryDelta comes from: binaryDeltaPath if set; otherwise @electron-forge/binary-delta, which is an optional dependency. If that package isn't installed or its binary isn't executable, the error explains why.

@electron-forge/binary-delta (new, os: ["darwin"])

  • Exports binaryDeltaPath and SPARKLE_VERSION (2.9.5).
  • Its prepack script downloads Sparkle-2.9.5.tar.xz and checks it against a pinned SHA-256. It then extracts only bin/BinaryDelta (chmod 755) and Sparkle's license, which ship in the tarball. Users download nothing at install time.
  • Setting ELECTRON_FORGE_SKIP_BINARY_DELTA_FETCH skips the download. The Verdaccio test publish sets it, since those tests never make deltas.

Repo tooling

  • tools/gen-tsconfigs.ts now includes optionalDependencies in project references.
  • yarn.config.cjs allows the new optional dependency.
  • knip.json gains a workspace entry for the new package.

Docs

  • docs/config/makers/zip.md:
    • Fixed the RELEASES.json example (title, ZIP name, sha256/size).
    • New "Delta updates (macOS)" section covering the config, a packageAfterCopy hook that makes the bundle non-group/other-writable before signing, and limitations. Only one delta is created per release, from the immediately previous version, and a macOS host is required.
  • docs/config/publishers/s3.mdx links to the delta section.
  • The maker-zip README describes the manifest and delta options.

Notes for reviewers

  • First publish: @electron-forge/binary-delta is a new package name, so someone with publish rights must publish it to npm by hand the first time.
  • Checksum source: Sparkle doesn't publish a digest for the archive. The pinned SHA-256 was computed from the GitHub release asset for tag 2.9.5 (commit 79bc9e8).
  • Not yet tested on a real Mac: the full flow needs a manual end-to-end check. Unit tests mock ditto, plutil, BinaryDelta and codesign. To check it by hand:
    1. Publish two signed versions with macUpdateDelta: true.
    2. Confirm the second manifest has a delta entry and that the self-check passed.
    3. Confirm an app running the first version downloads the .delta and relaunches as the second version.

Testing

  • packages/maker/zip/spec/MakerZip.spec.ts covers:

    • sha256/size in the manifest, and the delta entry's contents.
    • The exact BinaryDelta create/apply and codesign arguments.
    • from_version read from the plist.
    • The skip cases: non-macOS host, no previous release, same version.
    • BinaryDelta failure, with and without strict.
    • Group-writable files.
    • A previous ZIP whose SHA-256 doesn't match.
    • Using the packaged BinaryDelta, and a missing packaged binary.
  • File modes are controlled through an lstat spy, so the tests pass on Windows as well.

  • packages/utils/binary-delta/spec/fetch-binary-delta.spec.ts covers the binary path, a SHA-256 mismatch and a download HTTP error.

  • I have read the contribution documentation for this project.

  • I agree to follow the code of conduct that this project follows, as appropriate.

  • The changes are appropriately documented (if applicable).

  • The changes have sufficient test coverage (if applicable).

  • The testsuite passes successfully on my local machine (if applicable).

🤖 Generated with Claude Code

https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj

Squirrel.Mac now verifies the optional `sha256` (lowercase hex) and
`size` (bytes) of the update ZIP when they are present in `updateTo`.
Always write them for new entries; older Electron versions ignore the
extra keys.

Also fix the `macUpdateReleaseNotes` TSDoc, which referred to a
nonexistent `squirrelMacManifestBaseUrl` option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj
Add a `macUpdateDelta` option that, when making for darwin on a macOS
host with `macUpdateManifestBaseUrl` set, creates a Sparkle BinaryDelta
patch from the manifest's current release to the new one and offers it
as `updateTo.delta` in RELEASES.json. Squirrel.Mac (Squirrel/Squirrel.Mac#328,
electron/electron#52820) downloads it instead of the ZIP when the running
app's CFBundleVersion matches `from_version`, and falls back to the ZIP
on any failure. Older Electron versions ignore the key.

The maker downloads the previous ZIP (verifying its recorded sha256 and
size), extracts it with ditto, reads CFBundleVersion from its Info.plist
for `from_version`, refuses to continue if either app has group- or
other-writable files (Squirrel.Mac strips those on install, so the delta
would only work once), then runs `BinaryDelta create --version 4
--compression lzma` and self-checks the patch with `BinaryDelta apply`
and `codesign --verify --deep --strict`. The `.delta` file is written
next to the ZIP and returned as an artifact so publishers upload it
beside the ZIP.

BinaryDelta comes from `binaryDeltaPath`, or else from the Sparkle 2.9.5
release archive (the version Squirrel.Mac pins), verified against a
pinned SHA-256 and cached in ~/Library/Caches/electron-forge.

Failures warn and publish without a delta unless `strict` is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj
Describe `macUpdateDelta` in the ZIP maker docs: how the delta is made,
the RELEASES.json `delta` entry, how to strip group/other write
permissions before signing with a `packageAfterCopy` hook, and the
limitations. Show the new `sha256`/`size` fields in the example
manifest, and fix that example's code block title (it is RELEASES.json,
not forge.config.js) and ZIP file name.

Link to the new section from the S3 auto-update guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj
@erickzhao
erickzhao requested a review from a team as a code owner September 23, 2026 23:07
@erickzhao erickzhao changed the title Add binary delta support for macOS auto-updates feat(maker-zip): binary delta support for macOS auto-updates Sep 23, 2026
The delta tests run the real group/other-writable preflight against
temporary app bundles. Windows has no POSIX permission bits (Node
reports writable files as 0o666 and chmod cannot clear them), so the
preflight rejected every bundle and the tests failed. Delta creation
only runs on macOS hosts, so skip these tests on Windows; Linux and
macOS still cover them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj
Instead of skipping the macUpdateDelta tests on Windows hosts, report
the file modes the group/other-writable preflight sees from the test
(0o755/0o644, or 0o664 for paths marked group-writable) rather than
the host file system. Windows has no POSIX permission bits and reports
every writable file as 0o666, which made the preflight reject every
test bundle. The tests now pass with Windows-like modes on disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the confirmed inline finding, I also checked two other candidate issues and ruled them out: fetching the previous release's URL from the remote RELEASES.json in makeMacDelta isn't a new trust boundary, since that manifest already controls the real update URLs/checksums Squirrel.Mac itself trusts; and the catch block's fs.rm(deltaPath, { force: true }) on a failed retry only removes a locally re-buildable build artifact (not the already-published delta), so it isn't a real data-loss bug on its own.

Extended reasoning...

The change adds a new mac-delta.ts module that downloads and shells out to Sparkle's BinaryDelta and codesign, verifies checksums of downloaded content, and wires this into MakerZIP.make()/makeMacDelta; it touches external-binary invocation, remote downloads, and filesystem permission checks, which is meaningfully security- and correctness-sensitive surface. A confirmed race condition in the shared BinaryDelta cache under concurrent multi-arch make() is already being posted inline, which alone means a human should look; I additionally traced the remote-URL trust boundary and the delta-file cleanup-on-failure path from the diff and found both bounded/low-severity rather than independently blocking.

Comment thread packages/maker/zip/src/mac-delta.ts Outdated
@MarshallOfSound

Copy link
Copy Markdown
Member

I think we should publish the BinaryDelta executable in a macOS only npm package 🤔 I don't like adding dynamic download stuff, especially to github releases. Can we make that package?

`forge make` runs the makers for each architecture concurrently in one
process, so with an empty cache several MakerZIP instances could
download Sparkle at once and copy BinaryDelta into the same
`BinaryDelta.<pid>.partial` file. Interleaved writes could corrupt it,
and the corrupt binary would then stay cached.

Share one in-flight download per cache path within the process (forgotten
if it fails, so a later make retries), and give each partial file a
unique name so separate processes cannot collide either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR again and found no new bugs. The concurrent-download race I flagged in the earlier version looks fixed by e83dca4 (memoizes one in-flight download promise per cache path, and copies to a UUID-named partial file before renaming, so two same-process makers or two hosts can no longer clobber each other's copy). Given this still touches security-sensitive surface — downloading and executing an external binary (Sparkle's BinaryDelta) and verifying code signatures — a human look remains worthwhile before merging.

  • Verified the race fix directly in mac-delta.ts: getBinaryDelta() now shares one promise per cachedPath via binaryDeltaDownloads, and downloadBinaryDelta() writes to ${cachedPath}.${randomUUID()}.partial instead of a pid-based name.
  • Checked download integrity: downloadFile/digestFile validate SHA-256 and size before the Sparkle archive or previous release ZIP is trusted.
  • Checked delta validation: createBinaryDelta applies the new delta back to a copy of the old app and runs codesign --verify before the delta is published.
Extended reasoning...

This is a second pass on a PR adding binary-delta support for Squirrel.Mac auto-updates in packages/maker/zip (mac-delta.ts, Config.ts, MakerZIP.ts, plus ~400 lines of new tests). It touches security-relevant surface: downloading and executing an external binary (Sparkle's BinaryDelta) fetched over the network, and verifying code signatures on the resulting patched app. A prior run of this reviewer flagged a real concurrency bug (concurrent same-process makers could corrupt the cached BinaryDelta binary); commit e83dca4 fixes it with a per-cache-path in-flight-download map and a UUID-named partial file, which I re-verified by reading the current code. No new bugs were found this run, but given the size, security-sensitive surface, and that a third-party comment (MarshallOfSound, content not visible to this reviewer) landed in the same window as the fix, a human should still take a final look.

@erickzhao

Copy link
Copy Markdown
Member Author

I think we should publish the BinaryDelta executable in a macOS only npm package 🤔 I don't like adding dynamic download stuff, especially to github releases. Can we make that package?

Sure, that makes sense. I can do it as a new @electron-forge/ package here and keep it contained all within the monorepo?

@erickzhao
erickzhao marked this pull request as draft September 23, 2026 23:51
Add @electron-forge/binary-delta, which exports the path to the
BinaryDelta executable from Sparkle 2.9.5 (the Sparkle release that
Squirrel.Mac pins). The package only installs on macOS (`os: darwin`).

Its prepack script downloads the Sparkle release archive, checks it
against a pinned SHA-256, and copies bin/BinaryDelta and Sparkle's
LICENSE into the package, so the binary is fetched once at publish time
and never on users' machines, and never lives in git.

The Verdaccio test setup publishes every package locally and never
makes deltas, so it skips the download with
ELECTRON_FORGE_SKIP_BINARY_DELTA_FETCH.

Also generate TypeScript project references for optionalDependencies,
so that makers can depend on the package optionally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj
Instead of downloading Sparkle from GitHub releases and caching
BinaryDelta in ~/Library/Caches at build time, depend optionally on
@electron-forge/binary-delta, which npm installs on macOS. This drops
the runtime download, the cache and the concurrent-download handling.

`binaryDeltaPath` still overrides it. If the package or its binary is
missing, the delta fails with a clear message (and the release ships
without it unless `strict` is set).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwQrz6fWPnhLV1spvJA8Sj

Copy link
Copy Markdown
Member Author

@MarshallOfSound done in ea239b6 and f3c50cb: BinaryDelta now ships in a new macOS-only package, @electron-forge/binary-delta (packages/utils/binary-delta).

  • The package only installs on macOS ("os": ["darwin"]). It exports binaryDeltaPath and SPARKLE_VERSION (2.9.5) and includes Sparkle's license as SPARKLE-LICENSE, as its MIT and bsdiff BSD-2 terms require.
  • Its prepack script downloads the Sparkle 2.9.5 release archive, checks it against a pinned SHA-256, and copies in bin/BinaryDelta, so the binary is fetched once at publish time. It isn't committed to git, and users' machines never download it.
  • maker-zip lists the package in optionalDependencies and no longer downloads or caches anything at build time. binaryDeltaPath still overrides it. If the package or binary is missing, the maker warns and ships without a delta (or throws with strict).
  • The Verdaccio test setup sets ELECTRON_FORGE_SKIP_BINARY_DELTA_FETCH so CI doesn't download Sparkle on every run.
  • tools/gen-tsconfigs.ts now generates TypeScript references for optionalDependencies, and yarn.config.cjs exempts the new package from the version-consistency check, as it already does for other optional deps.

Per CONTRIBUTING, a Forger will need to publish the first version of @electron-forge/binary-delta by hand before the release workflow can manage it.


Generated by Claude Code

@erickzhao
erickzhao marked this pull request as ready for review September 24, 2026 23:58

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The concurrent-download race I flagged in an earlier pass on this PR no longer applies: BinaryDelta is now shipped as a prebuilt binary in the new @ electron-forge/binary-delta package (fetched once at publish time, verified against a pinned SHA-256) rather than downloaded at build time, so getBinaryDelta() just resolves an already-installed path with no shared mutable download/rename path to race on. Because this is still a large feature touching code-signing verification, binary patching, and a new package's publish pipeline, a human look would still be worthwhile.

  • Checked mac-delta.ts's getBinaryDelta/createBinaryDelta: no runtime download, delta creation is followed by an apply-and-codesign self-check.
  • Checked fetch-binary-delta.ts: Sparkle archive is downloaded and SHA-256-verified before extracting the BinaryDelta binary at pack time.
  • Checked MakerZIP.ts's delta flow: previous-ZIP checksum verification, group/other-writable file checks, and strict vs. non-strict failure handling.
Extended reasoning...

This PR adds macOS binary-delta ZIP update support: a new @ electron-forge/binary-delta package that ships Sparkle's BinaryDelta binary (fetched and SHA-256-verified at publish time), and a mac-delta.ts module in maker-zip that downloads/verifies the previous release, checks file permissions, creates the delta, and validates it via apply-and-codesign. Security-sensitive surface: external binary download with checksum verification and code-signature verification, no injection/auth exposure. Since my last review the design was changed (in response to a race condition I flagged and a maintainer comment) from a runtime download with an in-process cache to shipping a prebuilt binary via the package registry, which structurally removes the concurrency race; no other findings were reported this round. Given the size and security-adjacent nature of the change, human review is still worthwhile even though no bugs were found.

This branch has not been deployed

No deployments
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.

3 participants