Skip to content
Open
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
13 changes: 13 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,16 @@ jobs:
with:
ref: ${{ needs.release.outputs.new_tag }}
version: ${{ needs.release.outputs.new_tag }}

# Build + attach the native desktop installers (macOS/Linux signed if secrets
# are set; Windows best-effort). Same in-run pattern as publish-images, because
# the GITHUB_TOKEN-created release does not fire a `release` event.
desktop-apps:
needs: release
if: ${{ needs.release.outputs.new_tag != '' }}
permissions:
contents: write
uses: ./.github/workflows/desktop-apps.yml
with:
tag: ${{ needs.release.outputs.new_tag }}
secrets: inherit
138 changes: 106 additions & 32 deletions .github/workflows/desktop-apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ on:
description: 'Release tag to build + attach to (e.g. v0.3.0)'
required: true
type: string
# Called in-run from auto-release.yml: a GITHUB_TOKEN-created release does NOT
# fire the `release` event, so the apps must be built from within the same run
# that cut the release (same pattern as publish-images.yml).
workflow_call:
inputs:
tag:
description: 'Release tag to build + attach to'
required: true
type: string

concurrency:
group: desktop-apps-${{ github.event.release.tag_name || inputs.tag }}
Expand All @@ -43,8 +52,11 @@ jobs:
fail-fast: false
matrix:
include:
# macOS is Apple Silicon (arm64) only for v1 — see the rationale in
# desktop/electron-builder.yml (extraResources can't be arch-split, and
# Intel runners queue for hours). Intel x64 is a tracked follow-up.
- os: macos-latest
args: --mac
args: --mac --arm64
- os: windows-latest
args: --win
# Linux is best-effort for now: AppImage/deb are built + uploaded but
Expand All @@ -53,12 +65,16 @@ jobs:
- os: ubuntu-latest
args: --linux
runs-on: ${{ matrix.os }}
# Hard cap so a stuck native build can never run for hours.
timeout-minutes: 30
env:
TAG: ${{ github.event.release.tag_name || inputs.tag }}
# Must match Supervisor.KERNEL_PORT in desktop/src/supervisor.ts — the
# web-ui bakes this kernel URL into its routes-manifest at BUILD time, so
# it cannot be a per-launch random port.
KERNEL_URL: http://127.0.0.1:8769
# node-gyp's VS auto-detection is unreliable on windows-latest; pin VS2022.
GYP_MSVS_VERSION: '2022'
# macOS signing (hoisted so they're addressable in step-level `if:`).
APPLE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64_HIGH5 }}
APPLE_P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD_HIGH5 }}
Expand All @@ -82,6 +98,21 @@ jobs:
with:
node-version: 22

# node-gyp's bundled gyp imports Python's `distutils`, removed in Python
# 3.12. The macOS/Windows runners default to 3.12+ (Ubuntu still ships
# 3.11), so pin 3.11 everywhere to keep native rebuilds working.
- uses: actions/setup-python@v5
with:
python-version: '3.11'

# @electron/rebuild pulls @electron/node-gyp as a git+ssh GitHub dependency.
# Runners have no SSH key, so rewrite git+ssh GitHub URLs to https (public
# repo, no auth) before any npm install/ci, or those installs fail.
- name: Allow git-https for git dependencies
run: |
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
git config --global url."https://github.com/".insteadOf "git@github.com:"

# --- build the omadia runtime the installer bundles -------------------
- name: Build middleware kernel
working-directory: middleware
Expand All @@ -101,25 +132,29 @@ jobs:
working-directory: desktop
run: npm ci

# better-sqlite3/argon2/sharp ship in middleware/node_modules built for the
# system Node ABI; the kernel runs under Electron's Node (ELECTRON_RUN_AS_NODE)
# so they must be rebuilt against Electron's ABI before staging. electron-builder
# rebuilds the APP's own deps but NOT extraResources, so we do it here.
- name: Rebuild middleware native modules for Electron
working-directory: desktop
# Pin the Electron version explicitly: electron-rebuild's auto-detection
# is unreliable across the --module-dir boundary (electron lives in
# desktop/, the modules in middleware/), and a wrong-ABI rebuild fails
# silently until the kernel won't boot.
# Make the middleware's native modules load under Electron's Node (the kernel
# runs via ELECTRON_RUN_AS_NODE). argon2 + sharp are N-API → ABI-stable across
# node/electron, so their shipped binaries already work, no rebuild needed.
# Only better-sqlite3 (non-N-API) needs an Electron-ABI build — and it
# publishes Electron PREBUILDS, so fetch one with prebuild-install (no
# node-gyp / Visual Studio). Fall back to a source rebuild if no prebuild
# exists for this Electron version on this platform.
- name: Provision better-sqlite3 for Electron
shell: bash
run: |
EV="$(node -p "require('electron/package.json').version")"
echo "rebuilding middleware native modules for Electron $EV"
npx electron-rebuild --version "$EV" --module-dir ../middleware \
--only better-sqlite3,argon2,sharp --force
# Real ABI check: load a rebuilt native module under Electron's own Node
# runtime. If the rebuild targeted the wrong ABI this require() throws.
ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron -e \
"require('../middleware/node_modules/better-sqlite3'); console.log('native modules load under Electron ABI OK')"
EV="$(node -p "require('./desktop/node_modules/electron/package.json').version")"
echo "provisioning better-sqlite3 for Electron $EV"
( cd middleware/node_modules/better-sqlite3 \
&& npx --yes prebuild-install -r electron -t "$EV" --verbose ) \
|| ( cd desktop \
&& npx electron-rebuild --version "$EV" --module-dir ../middleware --only better-sqlite3 --force )
# Verify ALL three native modules actually load under Electron's runtime
# — not just the rebuilt better-sqlite3, but also argon2 + sharp, whose
# (un-rebuilt) prebuilds we ASSUME are N-API/ABI-stable. If that
# assumption is wrong, this fails here instead of crashing the kernel at
# boot in the shipped app.
ELECTRON_RUN_AS_NODE=1 ./desktop/node_modules/.bin/electron -e \
"require('./middleware/node_modules/better-sqlite3'); require('./middleware/node_modules/argon2'); require('./middleware/node_modules/sharp'); console.log('better-sqlite3 + argon2 + sharp load under Electron ABI OK')"

- name: Build desktop (main + preload + renderer)
working-directory: desktop
Expand All @@ -131,7 +166,7 @@ jobs:

# --- macOS signing + notarization (proven for omadia-ui) --------------
- name: Prepare Apple signing (optional — needs _HIGH5 secrets)
if: ${{ matrix.os == 'macos-latest' && env.APPLE_P12_BASE64 != '' }}
if: ${{ startsWith(matrix.os, 'macos') && env.APPLE_P12_BASE64 != '' }}
run: |
printf '%s' "$APPLE_P12_BASE64" | base64 -d > "$RUNNER_TEMP/developer-id.p12"
printf '%s' "$APPLE_ASC_KEY_P8_BASE64" | base64 -d > "$RUNNER_TEMP/asc-key.p8"
Expand All @@ -150,8 +185,17 @@ jobs:
security import "$RUNNER_TEMP/developer-id.p12" -k "$KEYCHAIN" \
-P "$APPLE_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KCPASS" "$KEYCHAIN" >/dev/null
# Prepend our keychain to the user search list (keep the existing ones).
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/[\"[:space:]]//g')
# Prepend our keychain to the user search list, KEEPING the existing ones.
# Build the list as an array (bash 3.2 on macOS runners — no mapfile) so
# multiple existing keychains aren't mashed into one path by whitespace
# stripping. Only quotes + leading/trailing space are trimmed per entry.
EXISTING=()
while IFS= read -r kc; do
kc="${kc//\"/}"
kc="$(printf '%s' "$kc" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
[ -n "$kc" ] && EXISTING+=("$kc")
done < <(security list-keychains -d user)
security list-keychains -d user -s "$KEYCHAIN" "${EXISTING[@]}"

# The exact Developer ID Application identity now present in the keychain.
IDENT=$(security find-identity -v -p codesigning "$KEYCHAIN" \
Expand Down Expand Up @@ -186,7 +230,7 @@ jobs:
# The .app is notarized + stapled by electron-builder above; the DMG needs
# its own ticket so `stapler validate` passes on the disk image itself.
- name: Notarize + staple the DMGs
if: ${{ matrix.os == 'macos-latest' && env.APPLE_P12_BASE64 != '' }}
if: ${{ startsWith(matrix.os, 'macos') && env.APPLE_P12_BASE64 != '' }}
working-directory: desktop
run: |
for dmg in release/*.dmg; do
Expand All @@ -199,11 +243,17 @@ jobs:
done

- name: Verify macOS signatures (acceptance gate)
if: ${{ matrix.os == 'macos-latest' && env.APPLE_P12_BASE64 != '' }}
if: ${{ startsWith(matrix.os, 'macos') && env.APPLE_P12_BASE64 != '' }}
working-directory: desktop
run: |
set -e
for app in release/mac*/*.app; do
shopt -s nullglob
apps=(release/mac*/*.app)
if [ ${#apps[@]} -eq 0 ]; then
echo "FAIL: no .app produced under release/mac*/ — nothing to verify" >&2
exit 1
fi
for app in "${apps[@]}"; do
# `--deep` is deprecated for verification and does NOT descend into
# extraResources — verify the app, then EACH nested native binary
# explicitly, then the real Gatekeeper verdict.
Expand All @@ -229,23 +279,47 @@ jobs:

- name: Tear down signing material
if: ${{ always() }}
run: rm -f "$RUNNER_TEMP/developer-id.p12" "$RUNNER_TEMP/asc-key.p8" "$RUNNER_TEMP/win-cert.p12" || true
run: |
rm -f "$RUNNER_TEMP/developer-id.p12" "$RUNNER_TEMP/asc-key.p8" "$RUNNER_TEMP/win-cert.p12" || true
# Also drop our temp signing keychain (and let macOS fall back to the
# default search list) so nothing signing-related lingers.
if [ -n "${MAC_SIGN_KEYCHAIN:-}" ]; then
security delete-keychain "$MAC_SIGN_KEYCHAIN" 2>/dev/null || true
fi

# Downloadable from the run itself (independent of the Release upload), and
# placed AFTER notarize/staple/verify so the artifacts are the final,
# stapled installers — not a pre-notarization copy.
- name: Upload installers as run artifacts
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: omadia-installers-${{ matrix.os }}
if-no-files-found: ignore
path: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.exe
desktop/release/*.AppImage
desktop/release/*.deb
desktop/release/*.blockmap
desktop/release/latest*.yml

- name: Upload installers to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: desktop
run: |
shopt -s nullglob
# On workflow_dispatch the tag may not correspond to an existing
# release — fail with a clear message rather than a cryptic gh error.
if ! gh release view "$TAG" >/dev/null 2>&1; then
echo "no GitHub Release exists for tag '$TAG' — create the release first" >&2
exit 1
fi
files=(release/*.dmg release/*.zip release/*.exe release/*.AppImage release/*.deb release/*.blockmap release/latest*.yml)
if [ ${#files[@]} -eq 0 ]; then
echo "no installer artifacts found in desktop/release/" >&2
exit 1
fi
# If TAG has no matching release (e.g. a build-validation dispatch on a
# branch name), this is a build-only run — skip upload, don't fail.
if ! gh release view "$TAG" >/dev/null 2>&1; then
echo "no GitHub Release for '$TAG' — build validated, skipping upload."
exit 0
fi
gh release upload "$TAG" "${files[@]}" --clobber
110 changes: 110 additions & 0 deletions desktop/buildResources/afterPack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// electron-builder afterPack hook — sign the native Mach-O binaries that ship
// as extraResources (the staged middleware's node_modules: better-sqlite3,
// argon2, sharp, and any nested .dylib).
//
// WHY: electron-builder signs the app bundle + its OWN dependencies, but it does
// NOT sign extraResources payloads. Under `hardenedRuntime: true` + notarization,
// Apple's notary service rejects bundles that contain unsigned Mach-O binaries
// (and hardened-runtime library validation refuses to load unsigned .node at
// runtime). We must sign these BEFORE electron-builder seals the outer app, so
// afterPack (post-pack, pre-sign) is the correct hook — the outer signature then
// records the now-signed nested binaries.
//
// Fail-soft: if no Developer ID identity is available (unsigned/local build), it
// logs and skips, so dev/ad-hoc builds still work.

const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');

/**
* Resolves the Developer ID Application identity to sign with.
*
* Prefers an explicit `MAC_SIGN_IDENTITY` env (set by CI) so we don't depend on
* electron-builder's temporary keychain already existing when afterPack runs;
* falls back to scanning the keychain via `security find-identity`.
*/
function findIdentity() {
const fromEnv = (process.env.MAC_SIGN_IDENTITY || '').trim();
if (fromEnv) return fromEnv;
try {
const out = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'], {
encoding: 'utf8',
});
const m = out.match(/"(Developer ID Application:[^"]+)"/);
return m ? m[1] : null;
} catch {
return null;
}
}

function collectMachO(dir, found) {
let entries;
try {
entries = fs.readdirSync(dir);
} catch {
return found;
}
for (const name of entries) {
const p = path.join(dir, name);
let st;
try {
// statSync FOLLOWS symlinks, so symlinked vendor dirs (e.g. sharp's libvips)
// get walked and symlinked .dylibs resolve to their real target — which we
// dedupe so we sign each Mach-O once. lstat-based walks miss these and ship
// them unsigned, which the verify gate (find follows them) would then fail.
st = fs.statSync(p);
} catch {
continue;
}
if (st.isDirectory()) {
collectMachO(p, found);
} else if (st.isFile() && (name.endsWith('.node') || name.endsWith('.dylib'))) {
try {
found.add(fs.realpathSync(p));
} catch {
found.add(p);
}
}
}
return found;
}

exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') return;

const identity = findIdentity();
if (!identity) {
// With signing secrets present (MAC_SIGN_EXPECTED=1) a missing identity is a
// HARD error — silently shipping unsigned nested modules would only fail
// later at notarization. Without secrets, this is the legitimate unsigned
// (dev / ad-hoc) path, so we skip.
if (process.env.MAC_SIGN_EXPECTED === '1') {
throw new Error(
'[afterPack] signing was expected (MAC_SIGN_EXPECTED=1) but no Developer ID ' +
'Application identity is available — the signing keychain was not set up ' +
'before packaging. Refusing to ship unsigned native modules.',
);
}
console.log('[afterPack] no Developer ID identity — skipping nested signing (unsigned build).');
return;
}

const appName = `${context.packager.appInfo.productFilename}.app`;
const omadiaResources = path.join(context.appOutDir, appName, 'Contents', 'Resources', 'omadia');
const targets = [...collectMachO(omadiaResources, new Set())];
if (targets.length === 0) {
console.log('[afterPack] no nested Mach-O binaries found under extraResources.');
return;
}

const keychain = (process.env.MAC_SIGN_KEYCHAIN || '').trim();
const args = ['--force', '--options', 'runtime', '--timestamp'];
if (keychain) args.push('--keychain', keychain);
args.push('--sign', identity);

console.log(`[afterPack] signing ${targets.length} nested Mach-O binaries with "${identity}"`);
for (const target of targets) {
execFileSync('codesign', [...args, target], { stdio: 'inherit' });
}
};
19 changes: 19 additions & 0 deletions desktop/buildResources/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Electron + a forked Node kernel need JIT/unsigned-executable-memory under
the hardened runtime. PGlite is WASM (no native JIT) but the bundled
V8/Node still requires these. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Outbound network: BYO LLM key calls + optional model/asset downloads.
No inbound server entitlement needed (loopback only). -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
Loading
Loading