Skip to content
Closed
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ website/.astro/
website/dist/
*.log
.DS_Store

# Benchmark fixtures — large downloaded binaries the bench script pulls
# on demand. Never committed.
bench/*.gz
bench/*.patch
bench/sentry-linux-x64
bench/sentry-linux-x64.applied
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to `binpatch` are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Documentation

- Reposition homepage to lead with "any binary" framing (Electron apps, CLIs,
agents, game updaters) instead of CLI-only. Hero now features a measured
download comparison chart for getsentry/cli 0.38.0 → 0.39.0 (31.83 MB full
gzipped vs 2.58 MB patch = 92% saved). Numbers come from the new
`bench/sentry-cli-bench.mjs` reproducible benchmark, which downloads the
real upstream artifacts and verifies the SHA-256 of the reconstructed
binary.
- Add "View as Markdown" link in the page footer. Each page now exposes its
raw markdown source at `/<slug>.md` — implemented via a Starlight
component override and an Astro API endpoint, both base-path aware so
PR previews keep working.

## [0.3.1] - 2026-07-27

- Guard chain discovery against malformed/incomparable version tags (no longer
Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
Reusable binary delta-update engine. Apply a **TRDIFF10 / bsdiff+zstd** patch
chain to a binary, discover chains from a pluggable source (OCI/GHCR tags or
GitHub Release assets), and generate + publish patches via a composite GitHub
Action. Pure Node, zero product coupling.
Action. Pure Node, zero product coupling — works for Electron apps, CLIs,
agents, and any single-file binary artifact.

```sh
npm install binpatch
Expand All @@ -17,11 +18,13 @@ npm install binpatch

## Why

Every time you `mycli update`, you pull the **entire binary again** — even when
the new release changed a few hundred kilobytes of a 100&nbsp;MB file. That's
bandwidth and patience burned on bytes that didn't move. A binary delta (bsdiff)
between consecutive builds is typically **0.05–0.1%** of the full size, so that
100&nbsp;MB download becomes a ~190&nbsp;KB patch.
Every time your binary updates itself, your users pull the **entire file
again** — even when the new release changed a few hundred kilobytes of a
100&nbsp;MB Electron app, a 50&nbsp;MB CLI, or a 200&nbsp;MB game updater.
That's bandwidth and patience burned on bytes that didn't move. A binary
delta (bsdiff) between consecutive builds is typically **0.05–0.1%** of
the full size — see the [home page graph](https://binpatch.p.byk.im/) for
real measurements on getsentry/cli.

The hard part isn't making the patch — it's the **two halves** that most
projects hand-roll separately (and get wrong):
Expand All @@ -31,10 +34,10 @@ projects hand-roll separately (and get wrong):
safely (integrity check, size cap, progress).

`binpatch` gives you **both** as one MIT-licensed TypeScript library plus a
drop-in GitHub Action. It's the apply/discovery core extracted from
Powers self-updates in production for shipped CLI binaries you may
already be using. Battle-tested reliability — minus the years of accumulated
fixes you'd otherwise have to write yourself.
drop-in GitHub Action. Powers self-updates in production for shipped
binaries you may already be using (including [getsentry/cli](https://github.com/getsentry/cli)).
Battle-tested reliability — minus the years of accumulated fixes you'd
otherwise have to write yourself.

## Scope

Expand Down
170 changes: 170 additions & 0 deletions bench/sentry-cli-bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env -S node --no-warnings
// SPDX-License-Identifier: MIT
//
// Reproducible benchmark: measure full gzipped download vs binpatch delta
// download for the real Sentry CLI (getsentry/cli) — Node SEA binaries.
//
// What it does:
// 1. Downloads `sentry-linux-x64.gz` and `sentry-linux-x64.patch` for a
// series of adjacent released version pairs from getsentry/cli.
// 2. Each `.patch` is the published binpatch TRDIFF10/bsdiff+zstd — exactly
// what a self-updating binary would pull.
// 3. Measures: gzipped full size, patch size, binpatch apply time, and
// SHA-256-verifies that the applied patch matches the upstream binary.
//
// Default mode iterates 8 adjacent release pairs (0.29.0 → 0.39.0) and
// reports the per-pair ratio plus an aggregate (median, mean, min, max).
// Set FROM/TO env vars to benchmark a single pair instead.
//
// Run with: node bench/sentry-cli-bench.mjs
// Requires: Node >= 22 (uses node:zlib.gunzipSync), internet access,
// binpatch's dist/ already built (`pnpm run build` at the repo root).

import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { gunzipSync } from "node:zlib";

import { applyPatchChainInMemory } from "../dist/index.js";

const ORG = "getsentry";
const REPO = "cli";
const ASSET = "sentry-linux-x64";

const DEFAULT_PAIRS = [
["0.29.0", "0.30.0"],
["0.30.0", "0.31.0"],
["0.32.0", "0.33.0"],
["0.33.0", "0.34.0"],
["0.35.0", "0.36.0"],
["0.36.0", "0.37.0"],
["0.37.0", "0.38.0"],
["0.38.0", "0.39.0"],
];

const singleMode = process.env.FROM && process.env.TO;
const pairs = singleMode
? [[process.env.FROM, process.env.TO]]
: DEFAULT_PAIRS.map(([from, to]) => [from, to]);

const c = (s) => `\x1b[36m${s}\x1b[0m`;
const g = (s) => `\x1b[32m${s}\x1b[0m`;
const y = (s) => `\x1b[33m${s}\x1b[0m`;
const b = (s) => `\x1b[1m${s}\x1b[0m`;

const url = (version, ext) =>
`https://github.com/${ORG}/${REPO}/releases/download/${version}/${ASSET}${ext}`;

async function fetchTo(path, u) {
const r = await fetch(u, { redirect: "follow" });
if (!r.ok) throw new Error(`${r.status} ${u}`);
await writeFile(path, new Uint8Array(await r.arrayBuffer()));
}

async function measurePair(from, to) {
const [gzBytes, patchBytes] = await Promise.all([
readFile(`${ASSET}-${to}.gz`),
readFile(`${ASSET}-${to}.patch`),
]);
const oldGzBytes = await readFile(`${ASSET}-${from}.gz`);
const oldRaw = gunzipSync(oldGzBytes);
await writeFile(`${ASSET}-${from}`, oldRaw);

const dest = `${ASSET}-${to}.applied`;
const t0 = performance.now();
const sha = await applyPatchChainInMemory(
`${ASSET}-${from}`,
[patchBytes],
dest,
() => {},
);
const applyMs = performance.now() - t0;

// Verify by re-decompressing the published `.gz` and comparing SHAs.
const upstreamSha = createHash("sha256").update(gunzipSync(gzBytes)).digest("hex");
const verified = sha === upstreamSha;

await writeFile(`${ASSET}-${from}.gz.sha`, `${upstreamSha}\n`);
await writeFile(`${ASSET}-${to}.applied.sha`, `${sha}\n`);

return {
from,
to,
gzBytes: gzBytes.length,
patchBytes: patchBytes.length,
ratio: patchBytes.length / gzBytes.length,
applyMs: Math.round(applyMs),
verified,
};
}

function fmtMb(b) {
return (b / 1024 / 1024).toFixed(2);
}

function pct(n) {
return `${(n * 100).toFixed(1)}%`;
}

console.log(b(`\n getsentry/cli — ${pairs.length} adjacent release pair(s)\n`));

const tTotal = performance.now();
const needsFetch = pairs.flatMap(([from, to]) => [
fetchTo(`${ASSET}-${to}.gz`, url(to, ".gz")),
fetchTo(`${ASSET}-${to}.patch`, url(to, ".patch")),
fetchTo(`${ASSET}-${from}.gz`, url(from, ".gz")),
]);
await Promise.all(needsFetch);
console.log(c(" ✓ downloaded"));

const results = [];
for (const [from, to] of pairs) {
try {
const r = await measurePair(from, to);
results.push(r);
console.log(
` ${from} → ${to} ` +
`gz=${y(fmtMb(r.gzBytes) + " MB")} ` +
`patch=${g(fmtMb(r.patchBytes) + " MB")} ` +
`ratio=${g(pct(r.ratio))} ` +
`apply=${r.applyMs}ms ` +
`${r.verified ? g("✓") : y("✗")}`,
);
} catch (e) {
console.log(` ${from} → ${to} ${y("SKIP")} (${e.message})`);
}
}

const ratios = results.map((r) => r.ratio);
const sortRatios = [...ratios].sort((a, b) => a - b);
const median = sortRatios[Math.floor(sortRatios.length / 2)];
const mean = ratios.reduce((a, b) => a + b, 0) / ratios.length;
const min = Math.min(...ratios);
const max = Math.max(...ratios);
const avgGz = results.reduce((a, r) => a + r.gzBytes, 0) / results.length;
const avgPatch = results.reduce((a, r) => a + r.patchBytes, 0) / results.length;

console.log(b(`\n Summary across ${results.length} release pair(s)\n`));
console.log(` median ratio ${g(pct(median))} (typical patch size)`);
console.log(` mean ratio ${g(pct(mean))}`);
console.log(` range ${y(pct(min))} — ${y(pct(max))}`);
console.log(` avg gz full ${y(fmtMb(avgGz) + " MB")}`);
console.log(` avg patch ${g(fmtMb(avgPatch) + " MB")}`);
console.log(` total wall ${((performance.now() - tTotal) / 1000).toFixed(2)} s\n`);

const out = {
pairs: results,
aggregate: {
count: results.length,
medianRatio: median,
meanRatio: mean,
minRatio: min,
maxRatio: max,
avgFullBytes: Math.round(avgGz),
avgPatchBytes: Math.round(avgPatch),
},
};
console.log(b(" emitted JSON (for graph generation):"));
console.log(JSON.stringify(out, null, 2));

const anyFailed = results.some((r) => !r.verified);
process.exit(anyFailed ? 1 : 0);
3 changes: 3 additions & 0 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ export default defineConfig({
},
],
customCss: ["./src/custom.css"],
components: {
Footer: "./src/components/Footer.astro",
},
}),
],
});
58 changes: 36 additions & 22 deletions website/public/flow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading