Skip to content
Merged
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
67 changes: 62 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Build and Release

on:
pull_request:
branches: [main]
push:
tags:
- 'v*'
Expand Down Expand Up @@ -39,6 +41,13 @@ jobs:
platform: win32
arch: x64
zig-target: x86_64-windows
bspatch-target: x86_64-windows-msvc
- os: windows-2025
platform: win32
arch: arm64
zig-target: aarch64-windows-msvc
bspatch-target: aarch64-windows-msvc
cross: true

runs-on: ${{ matrix.os }}

Expand All @@ -62,6 +71,8 @@ jobs:
- name: Setup MSVC on Windows
if: matrix.platform == 'win32'
uses: ilammy/msvc-dev-cmd@v1
with:
arch: ${{ matrix.arch }}

# libsais is now compiled by `zig cc` inside scripts/setup.js, so MinGW
# is no longer needed on Windows. (Previously `choco install mingw` was
Expand All @@ -70,7 +81,7 @@ jobs:

- name: Setup dependencies
run: |
node scripts/setup.js
node scripts/setup.js --target=${{ matrix.zig-target }}

- name: Build binaries
run: |
Expand All @@ -79,11 +90,27 @@ jobs:
- name: Rebuild bspatch with MSVC ABI (Windows)
if: matrix.platform == 'win32'
run: |
zig build bspatch-only -Doptimize=ReleaseFast -Dtarget=x86_64-windows-msvc
zig build bspatch-only -Doptimize=ReleaseFast -Dtarget=${{ matrix.bspatch-target }} -Dcpu=baseline

- name: Test build target selection
run: node --test scripts/build-target.test.js

- name: Test
if: matrix.cross != true
run: |
zig build test
zig build test -Dtarget=${{ matrix.zig-target }}

- name: Build ARM64 tests
if: matrix.cross == true
run: zig build test-build -Dtarget=${{ matrix.zig-target }}

- name: Upload ARM64 tests
if: matrix.cross == true
uses: actions/upload-artifact@v4
with:
name: arm64-tests-zig-bsdiff
path: zig-out/bin/test.exe
if-no-files-found: error

- name: Package binaries
run: |
Expand All @@ -105,8 +132,37 @@ jobs:
name: zig-bsdiff-${{ matrix.platform }}-${{ matrix.arch }}
path: zig-bsdiff-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz

release:
test-windows-arm64:
needs: build
runs-on: windows-11-arm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: '24'
- uses: actions/download-artifact@v4
with:
name: arm64-tests-zig-bsdiff
path: arm64-tests
- uses: actions/download-artifact@v4
with:
name: zig-bsdiff-win32-arm64
path: arm64-release
- name: Run ARM64 tests and packaged CLIs
shell: pwsh
run: |
& ./arm64-tests/test.exe
if ($LASTEXITCODE -ne 0) { throw 'ARM64 unit tests failed' }
tar -xzf arm64-release/zig-bsdiff-win32-arm64.tar.gz -C arm64-release
if ($LASTEXITCODE -ne 0) { throw 'ARM64 archive extraction failed' }
node scripts/test-release-binaries.js arm64-release arm64
if ($LASTEXITCODE -ne 0) { throw 'ARM64 release smoke test failed' }

release:
if: github.event_name != 'pull_request'
needs: [build, test-windows-arm64]
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -123,6 +179,7 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
pattern: zig-bsdiff-*
path: artifacts

- name: Publish immutable release to R2
Expand All @@ -148,7 +205,7 @@ jobs:
Pre-built binaries for:
- **macOS** (arm64 & x64): `zig-bsdiff-darwin-[arch].tar.gz`
- **Linux** (x64 & arm64): `zig-bsdiff-linux-[arch].tar.gz`
- **Windows** (x64): `zig-bsdiff-win32-x64.tar.gz`
- **Windows** (x64 & arm64): `zig-bsdiff-win32-[arch].tar.gz`

## Usage

Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@ Download pre-built binaries from the [GitHub Releases](https://github.com/blackb
Available for:
- macOS (arm64, x64)
- Linux (arm64, x64)
- Windows (x64)
- Windows (x64, arm64)

Windows ARM64 builds use an x64 Zig 0.16 compiler with an explicit target:
run `node scripts/setup.js --target=aarch64-windows-msvc`, then
`zig build -Dtarget=aarch64-windows-msvc -Doptimize=ReleaseFast -Dcpu=baseline`.
Install the Visual Studio ARM64 C++ build tools and Windows SDK first.
Setup rebuilds libsais when the requested target changes. Use
`zig build test-build -Dtarget=aarch64-windows-msvc` to create a test executable
for an ARM64 machine. Release publication waits for native ARM64 unit tests
and a roundtrip using the packaged bsdiff/bspatch executables.

Extract the tarball for your platform:

Expand Down
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,6 @@ pub fn build(b: *std.Build) void {
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run bsdiff/bspatch roundtrip tests");
test_step.dependOn(&run_tests.step);
const test_build_step = b.step("test-build", "Build tests for execution on the target machine");
test_build_step.dependOn(&b.addInstallArtifact(tests, .{}).step);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zig-bsdiff",
"version": "0.1.23",
"version": "0.1.24",
"description": "Fast binary diff/patch implementation in Zig with zstd compression",
"license": "MIT",
"author": "Blackboard Technologies Inc.",
Expand Down
14 changes: 14 additions & 0 deletions scripts/build-target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function resolveBuildTarget({ args = process.argv.slice(2), env = process.env, platform = process.platform, arch = process.arch } = {}) {
const requested = args.find((arg) => arg.startsWith('--target='))?.slice('--target='.length) ?? env.ZIG_TARGET;
if (requested) {
if (!/^(aarch64|x86_64)-(windows|linux|macos)(?:[.\w-]*)$/.test(requested)) {
throw new Error(`Unsupported build target: ${requested}`);
}
return requested;
}
if (!['arm64', 'x64'].includes(arch)) throw new Error(`Unsupported architecture: ${arch}`);
const cpu = arch === 'arm64' ? 'aarch64' : 'x86_64';
const os = { win32: arch === 'arm64' ? 'windows-msvc' : 'windows-gnu', darwin: 'macos', linux: 'linux-gnu' }[platform];
if (!os) throw new Error(`Unsupported platform: ${platform}`);
return `${cpu}-${os}`;
}
17 changes: 17 additions & 0 deletions scripts/build-target.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveBuildTarget } from './build-target.js';

test('explicit ARM64 target takes precedence over an x64 build host', () => {
assert.equal(resolveBuildTarget({ args: ['--target=aarch64-windows-msvc'], env: {}, platform: 'win32', arch: 'x64' }), 'aarch64-windows-msvc');
});
test('target environment supports cross compilation', () => {
assert.equal(resolveBuildTarget({ args: [], env: { ZIG_TARGET: 'aarch64-windows-msvc' }, platform: 'win32', arch: 'x64' }), 'aarch64-windows-msvc');
});
test('native Windows ARM64 uses MSVC and x64 retains the GNU ABI', () => {
assert.equal(resolveBuildTarget({ args: [], env: {}, platform: 'win32', arch: 'arm64' }), 'aarch64-windows-msvc');
assert.equal(resolveBuildTarget({ args: [], env: {}, platform: 'win32', arch: 'x64' }), 'x86_64-windows-gnu');
});
test('invalid targets fail before invoking the compiler', () => {
assert.throws(() => resolveBuildTarget({ args: ['--target=arm64;bad'], env: {} }), /Unsupported build target/);
});
51 changes: 21 additions & 30 deletions scripts/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
* Based on electrobun and colab's vendoring approach
*/

import { execSync } from 'child_process';
import { existsSync, mkdirSync, rmSync, unlinkSync, renameSync, writeFileSync } from 'fs';
import { execFileSync, execSync } from 'child_process';
import { existsSync, mkdirSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { resolveBuildTarget } from './build-target.js';

const ZIG_VERSION = '0.16.0';
const LIBSAIS_VERSION = '2.8.6';
Expand All @@ -28,9 +29,12 @@ async function initSubmodules() {
async function vendorLibsais() {
const libsaisDir = join(process.cwd(), 'vendors', 'libsais');
const libsaisLib = join(libsaisDir, 'libsais.a');
const targetStamp = join(libsaisDir, '.build-target');
const zigTarget = resolveBuildTarget();
const buildKey = `${zigTarget}:${ZIG_VERSION}:${LIBSAIS_VERSION}`;

// Check if libsais is already compiled
if (existsSync(libsaisLib)) {
if (existsSync(libsaisLib) && existsSync(targetStamp) && readFileSync(targetStamp, 'utf8') === buildKey) {
console.log('✓ libsais already vendored and compiled');
return;
}
Expand Down Expand Up @@ -65,54 +69,41 @@ async function vendorLibsais() {
// - the same baseline x86_64 ISA as `zig build -Dcpu=baseline` produces
// for the surrounding zig code, so the linked binary is uniformly
// baseline-safe.
// The .a is GNU-ABI; on Windows the surrounding `zig build` uses the
// matching `x86_64-windows` GNU target, so linking is clean.
// The archive uses the same target and ABI as the surrounding Zig build.
console.log('Compiling libsais with zig cc...');

const zigBinary = platform === 'win32' ? 'zig.exe' : 'zig';
const zigPath = join(process.cwd(), 'vendors', 'zig', zigBinary);
const wrapperDir = join(process.cwd(), 'src', 'libsais-wrapper');

// Map node's host platform/arch to a zig target triple. The CI matrix
// always runs the build on the same arch as the artifact it produces,
// so targeting the host is correct.
const arch = process.arch;
const zigArch = arch === 'arm64' ? 'aarch64' : 'x86_64';
let zigTarget;
if (platform === 'win32') {
zigTarget = `${zigArch}-windows-gnu`;
} else if (platform === 'darwin') {
zigTarget = `${zigArch}-macos`;
} else if (platform === 'linux') {
zigTarget = `${zigArch}-linux-gnu`;
} else {
throw new Error(`Unsupported platform for libsais build: ${platform}`);
}

const cflags = `-target ${zigTarget} -c -O3 -std=c99`;
// Compile for the artifact, which may differ from the compiler's host.
const cflags = ['-target', zigTarget, '-mcpu=baseline', '-c', '-O3', '-std=c99'];
// Invalidate the old stamp before rebuilding, including failed rebuilds.
if (existsSync(targetStamp)) unlinkSync(targetStamp);

// Compile libsais source files
execSync(
`"${zigPath}" cc ${cflags} ${join(libsaisDir, 'libsais.c')} -o ${join(libsaisDir, 'libsais.o')}`,
execFileSync(zigPath,
['cc', ...cflags, join(libsaisDir, 'libsais.c'), '-o', join(libsaisDir, 'libsais.o')],
{ stdio: 'inherit' }
);
execSync(
`"${zigPath}" cc ${cflags} ${join(libsaisDir, 'libsais64.c')} -o ${join(libsaisDir, 'libsais64.o')}`,
execFileSync(zigPath,
['cc', ...cflags, join(libsaisDir, 'libsais64.c'), '-o', join(libsaisDir, 'libsais64.o')],
{ stdio: 'inherit' }
);

// Compile wrapper with include path to libsais headers
execSync(
`"${zigPath}" cc ${cflags} -I ${libsaisDir} ${join(wrapperDir, 'zig_wrapper.c')} -o ${join(libsaisDir, 'zig_wrapper.o')}`,
execFileSync(zigPath,
['cc', ...cflags, '-I', libsaisDir, join(wrapperDir, 'zig_wrapper.c'), '-o', join(libsaisDir, 'zig_wrapper.o')],
{ stdio: 'inherit' }
);

// Create static library using zig's bundled llvm-ar (works on every
// platform without depending on a system `ar`).
execSync(
`"${zigPath}" ar rcs ${join(libsaisDir, 'libsais.a')} ${join(libsaisDir, 'libsais.o')} ${join(libsaisDir, 'libsais64.o')} ${join(libsaisDir, 'zig_wrapper.o')}`,
execFileSync(zigPath,
['ar', 'rcs', libsaisLib, ...['libsais.o', 'libsais64.o', 'zig_wrapper.o'].map((name) => join(libsaisDir, name))],
{ stdio: 'inherit' }
);
writeFileSync(targetStamp, buildKey);

// Clean up object files
const objFiles = ['libsais.o', 'libsais64.o', 'zig_wrapper.o'];
Expand Down
35 changes: 35 additions & 0 deletions scripts/test-release-binaries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

const directory = resolve(process.argv[2] ?? 'dist');
const arch = process.argv[3] ?? process.arch;
const binaries = ['bsdiff', 'bspatch'].map((name) => join(directory, `${name}${process.platform === 'win32' ? '.exe' : ''}`));
if (process.platform === 'win32') {
assert.equal(process.arch, arch, 'Run release validation in a native target process');
for (const binary of binaries) {
const bytes = readFileSync(binary);
const pe = bytes.readUInt32LE(0x3c);
assert.equal(bytes.readUInt32LE(pe), 0x4550, 'PE signature');
assert.equal(bytes.readUInt16LE(pe + 4), { arm64: 0xaa64, x64: 0x8664 }[arch], `${binary} PE architecture`);
}
}
const temporary = mkdtempSync(join(tmpdir(), 'zig-bsdiff-release-'));
try {
const original = join(temporary, 'original.bin');
const updated = join(temporary, 'updated.bin');
const patch = join(temporary, 'patch.bin');
const restored = join(temporary, 'restored.bin');
const before = Buffer.from('Windows ARM64 binary patch roundtrip\n'.repeat(4096));
const after = Buffer.concat([before.subarray(0, 10000), Buffer.from('modified content'), before.subarray(10500)]);
writeFileSync(original, before);
writeFileSync(updated, after);
execFileSync(binaries[0], [original, updated, patch, '--use-zstd'], { stdio: 'inherit' });
execFileSync(binaries[1], [original, restored, patch], { stdio: 'inherit' });
assert.deepEqual(readFileSync(restored), after);
console.log(`Validated ${arch} release binaries and binary patch roundtrip`);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
1 change: 1 addition & 0 deletions scripts/upload-release-r2.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const TARGETS = {
"darwin-x64",
"linux-arm64",
"linux-x64",
"win32-arm64",
"win32-x64",
],
"zig-zstd": [
Expand Down
Loading