diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87cdaab..d0aae71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,8 @@ name: Build and Release on: + pull_request: + branches: [main] push: tags: - 'v*' @@ -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 }} @@ -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 @@ -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: | @@ -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: | @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 175ade5..0034a08 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/build.zig b/build.zig index 062fab4..bc05759 100644 --- a/build.zig +++ b/build.zig @@ -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); } diff --git a/package.json b/package.json index bf0ca07..9ea652b 100644 --- a/package.json +++ b/package.json @@ -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.", diff --git a/scripts/build-target.js b/scripts/build-target.js new file mode 100644 index 0000000..0834f20 --- /dev/null +++ b/scripts/build-target.js @@ -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}`; +} diff --git a/scripts/build-target.test.js b/scripts/build-target.test.js new file mode 100644 index 0000000..77c3b46 --- /dev/null +++ b/scripts/build-target.test.js @@ -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/); +}); diff --git a/scripts/setup.js b/scripts/setup.js index 01da9e1..4927fe4 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -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'; @@ -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; } @@ -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']; diff --git a/scripts/test-release-binaries.js b/scripts/test-release-binaries.js new file mode 100644 index 0000000..84208a4 --- /dev/null +++ b/scripts/test-release-binaries.js @@ -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 }); +} diff --git a/scripts/upload-release-r2.js b/scripts/upload-release-r2.js index 49eed1b..c409763 100644 --- a/scripts/upload-release-r2.js +++ b/scripts/upload-release-r2.js @@ -16,6 +16,7 @@ const TARGETS = { "darwin-x64", "linux-arm64", "linux-x64", + "win32-arm64", "win32-x64", ], "zig-zstd": [