fix(ci): always source-build agent-native and bundle to napi/ root

The Zig NAPI provisioner had a silent failure mode that affected any
matrix entry without a matching ZSeven-W/agent prebuilt: the source-
build fallback dropped `agent_napi.node` at `zig-out/napi/...`, but
electron-builder only ships `packages/agent-native/napi/`. The addon
was therefore absent from the produced .exe / .dmg / .AppImage, and
every chat call died at the dynamic `@zseven-w/agent-native` import.

- Drop the prebuilt-download path; always build from source on the
  runner (mlugg/setup-zig is already provisioned for every workflow)
- Always copy the built binary into `napi/agent_napi.node` so
  electron-builder packages it
- Honor `ZIG_TARGET` to cross-compile (mac-x64 on arm64 runners now
  produces an x86_64 binary instead of a wrong-arch arm64 one)
- Add `OPENPENCIL_REQUIRE_AGENT_NATIVE=1` strict mode plus a
  dedicated "Verify agent-native binary" step in build-electron.yml
  so missing binaries fail the workflow loudly
- Add `OPENPENCIL_SKIP_AGENT_NATIVE=1` for publish-cli.yml, which
  never ships the addon and shouldn't pay for the build
This commit is contained in:
Kayshen-X 2026-04-26 19:20:48 +08:00
parent f61ca2072b
commit 5e6b7475a0
3 changed files with 79 additions and 121 deletions

View file

@ -22,15 +22,19 @@ jobs:
- os: macos-latest - os: macos-latest
platform: mac-arm64 platform: mac-arm64
build_args: --mac --arm64 build_args: --mac --arm64
zig_target: aarch64-macos
- os: macos-latest - os: macos-latest
platform: mac-x64 platform: mac-x64
build_args: --mac --x64 build_args: --mac --x64
zig_target: x86_64-macos
- os: windows-latest - os: windows-latest
platform: win platform: win
build_args: --win build_args: --win
zig_target: x86_64-windows
- os: ubuntu-latest - os: ubuntu-latest
platform: linux platform: linux
build_args: --linux build_args: --linux
zig_target: x86_64-linux
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -50,8 +54,20 @@ jobs:
version: 0.15.2 version: 0.15.2
- name: Install dependencies - name: Install dependencies
env:
OPENPENCIL_REQUIRE_AGENT_NATIVE: '1'
ZIG_TARGET: ${{ matrix.zig_target }}
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Verify agent-native binary
shell: bash
run: |
if [ ! -f packages/agent-native/napi/agent_napi.node ]; then
echo "::error::packages/agent-native/napi/agent_napi.node missing — electron-builder would ship without it."
exit 1
fi
ls -la packages/agent-native/napi/agent_napi.node
- name: Build web (electron target) - name: Build web (electron target)
run: bun --bun run build run: bun --bun run build
env: env:

View file

@ -34,6 +34,8 @@ jobs:
version: 0.15.2 version: 0.15.2
- name: Install dependencies - name: Install dependencies
env:
OPENPENCIL_SKIP_AGENT_NATIVE: '1'
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Get version - name: Get version

View file

@ -1,165 +1,105 @@
#!/usr/bin/env node #!/usr/bin/env node
// Ensures the Zig NAPI addon binary exists. // Provisions the Zig NAPI addon binary by building it from source.
// //
// Strategy (fastest to slowest): // We always build from source on the host so the resulting `agent_napi.node`
// 1. Already built / bundled — use it. // matches the runner's platform/arch. Earlier revisions also tried to download
// 2. Download prebuilt from the ZSeven-W/agent release whose tag points at // a prebuilt from a sibling release repo, but that path was racy: when the
// the submodule's currently checked-out commit. This means CI and local // prebuilt was missing for the current submodule SHA the build fell through
// installs never need Zig installed, as long as whoever bumped the // to source compilation, deposited the binary at `zig-out/napi/...`, and
// submodule also tagged + published a matching release. // electron-builder (which only ships `packages/agent-native/napi/`) silently
// 3. Build from source with local Zig (slow but authoritative). // shipped without the addon — every chat request then died at the dynamic
// `@zseven-w/agent-native` import.
// //
// Failing all of those is non-fatal — the postinstall wrapper swallows exit // Build prerequisite: Zig 0.15+ on PATH. CI workflows install it via
// codes so `bun install` never breaks. Tests / runtime will surface a clear // `mlugg/setup-zig`; local devs install once via their package manager.
// "could not locate agent_napi.node" error instead, with instructions. //
// Set OPENPENCIL_REQUIRE_AGENT_NATIVE=1 to fail the install when the build
// can't run (electron CI uses this to surface missing prerequisites early).
//
// Set OPENPENCIL_SKIP_AGENT_NATIVE=1 to no-op the script entirely. Useful for
// workflows (npm publish, lint-only CI) that never load the addon at runtime
// and would otherwise pay for a Zig build on every install.
//
// Set ZIG_TARGET to cross-compile for a non-host triple (e.g. on a macOS arm64
// runner build for x86_64-macos with `ZIG_TARGET=x86_64-macos`). Without it
// the build follows the host arch — fine for native runs, wrong when the
// runner doesn't match the artifact you intend to ship.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const REPO = 'ZSeven-W/agent';
const NAPI_DIR = path.join(__dirname, '..', 'packages', 'agent-native', 'napi');
const AGENT_DIR = path.join(__dirname, '..', 'packages', 'agent-native'); const AGENT_DIR = path.join(__dirname, '..', 'packages', 'agent-native');
const NAPI_DIR = path.join(AGENT_DIR, 'napi');
const ZIG_OUT = path.join(AGENT_DIR, 'zig-out', 'napi', 'agent_napi.node'); const ZIG_OUT = path.join(AGENT_DIR, 'zig-out', 'napi', 'agent_napi.node');
const BUNDLED = path.join(NAPI_DIR, 'agent_napi.node'); const BUNDLED = path.join(NAPI_DIR, 'agent_napi.node');
const STRICT = process.env.OPENPENCIL_REQUIRE_AGENT_NATIVE === '1';
function log(msg) { function log(msg) {
console.log(`[agent-native] ${msg}`); console.log(`[agent-native] ${msg}`);
} }
function assetNameForHost() { function fail(msg) {
const p = process.platform; // 'darwin' | 'linux' | 'win32' log(msg);
const a = process.arch; // 'arm64' | 'x64' return STRICT ? 1 : 0;
const os = p === 'darwin' ? 'macos' : p === 'win32' ? 'windows' : 'linux';
return `agent_napi-${os}-${a}.node`;
} }
function readSubmoduleSha() { function bundleBinary() {
try {
return execSync('git rev-parse HEAD', {
cwd: AGENT_DIR,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
}
function ghJson(url) {
const headers = ['-H', 'Accept: application/vnd.github+json'];
if (process.env.GITHUB_TOKEN) {
headers.push('-H', `Authorization: Bearer ${process.env.GITHUB_TOKEN}`);
}
const raw = execSync(`curl -sLf ${headers.join(' ')} "${url}"`, { encoding: 'utf8' });
return JSON.parse(raw);
}
function findMatchingRelease(submoduleSha) {
if (!submoduleSha) return null;
// Walk tags (paginated) until we find one whose commit SHA matches the
// submodule pointer. Tags are listed newest first, so for a freshly bumped
// submodule we almost always hit on the first page.
for (let page = 1; page <= 3; page += 1) {
let tags;
try {
tags = ghJson(`https://api.github.com/repos/${REPO}/tags?per_page=30&page=${page}`);
} catch {
return null;
}
if (!Array.isArray(tags) || tags.length === 0) return null;
for (const t of tags) {
if (t?.commit?.sha === submoduleSha) return t.name;
}
}
return null;
}
function downloadPrebuilt(tagName) {
let release;
try {
release = ghJson(`https://api.github.com/repos/${REPO}/releases/tags/${tagName}`);
} catch (err) {
log(`No release for tag ${tagName}: ${err.message}`);
return false;
}
const assetName = assetNameForHost();
const asset = release.assets?.find((a) => a.name === assetName);
if (!asset) {
log(
`Release ${tagName} has no asset ${assetName} (built: ${(release.assets ?? []).map((a) => a.name).join(', ') || 'none'}).`,
);
return false;
}
log(`Downloading ${assetName} from release ${tagName}`);
try {
fs.mkdirSync(NAPI_DIR, { recursive: true }); fs.mkdirSync(NAPI_DIR, { recursive: true });
execSync(`curl -sLf -o "${BUNDLED}" "${asset.browser_download_url}"`, { stdio: 'inherit' }); fs.copyFileSync(ZIG_OUT, BUNDLED);
} catch (err) { log(`Bundled binary at ${BUNDLED}.`);
log(`Download failed: ${err.message}`);
return false;
}
return fs.existsSync(BUNDLED);
} }
function buildFromSource() { function buildFromSource() {
try { try {
execSync('zig version', { stdio: 'ignore' }); execSync('zig version', { stdio: 'ignore' });
} catch { } catch {
log('Zig not installed; cannot build from source.'); return fail(
return false; 'Zig not installed (need 0.15+). Skipping. Install Zig and re-run `bun run agent:build`.',
);
} }
log('Building NAPI addon from source (zig build napi)…'); const target = process.env.ZIG_TARGET?.trim();
const targetFlag = target ? ` -Dtarget=${target}` : '';
log(`Building NAPI addon (zig build napi -Doptimize=ReleaseFast${targetFlag})…`);
try { try {
execSync('zig build napi -Doptimize=ReleaseFast', { execSync(`zig build napi -Doptimize=ReleaseFast${targetFlag}`, {
cwd: AGENT_DIR, cwd: AGENT_DIR,
stdio: 'inherit', stdio: 'inherit',
}); });
} catch (err) { } catch (err) {
log(`Source build failed: ${err.message}`); return fail(`Zig build failed: ${err.message}`);
return false;
} }
return fs.existsSync(ZIG_OUT); if (!fs.existsSync(ZIG_OUT)) {
return fail(`Zig build produced no output at ${ZIG_OUT}.`);
}
bundleBinary();
return 0;
} }
function main() { function main() {
// 1. Already have it? if (process.env.OPENPENCIL_SKIP_AGENT_NATIVE === '1') {
if (fs.existsSync(ZIG_OUT) || fs.existsSync(BUNDLED)) { log('OPENPENCIL_SKIP_AGENT_NATIVE=1, skipping native binary provisioning.');
log('Binary already present, skipping.');
return 0; return 0;
} }
// Submodule initialized?
if (!fs.existsSync(path.join(NAPI_DIR, 'package.json'))) { if (!fs.existsSync(path.join(NAPI_DIR, 'package.json'))) {
log('Submodule not initialized; run `git submodule update --init`. Skipping.'); return fail('Submodule not initialized; run `git submodule update --init`. Skipping.');
}
// Fast path: binary already in place. Make sure both lookup locations are
// populated so electron-builder (`napi/`) and the runtime loader (which
// checks `zig-out/` first) both find it without re-running the build.
if (fs.existsSync(BUNDLED)) {
log('Binary already present, skipping rebuild.');
return 0;
}
if (fs.existsSync(ZIG_OUT)) {
log('Binary already built; copying into napi/ for electron-builder.');
bundleBinary();
return 0; return 0;
} }
// 2. Prebuilt release matching submodule SHA? return buildFromSource();
const sha = readSubmoduleSha();
if (sha) {
const tag = findMatchingRelease(sha);
if (tag) {
if (downloadPrebuilt(tag)) {
log(`Prebuilt ready at ${BUNDLED}.`);
return 0;
}
} else {
log(`No release tag matches submodule ${sha.slice(0, 7)}.`);
}
}
// 3. Source build fallback.
if (buildFromSource()) {
log(`Built at ${ZIG_OUT}.`);
return 0;
}
log('Could not provision agent_napi.node. Tests / runtime will fail loudly until resolved.');
log(
'Options: bump + tag the agent submodule, or install Zig 0.15+ and run `bun run agent:build`.',
);
return 0; // Non-fatal: let the wrapper keep install green, real error surfaces at test time.
} }
process.exit(main()); process.exit(main());