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
46 changes: 40 additions & 6 deletions .github/workflows/mac-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ permissions:

jobs:
package-macos:
name: Package macOS App
runs-on: macos-latest
name: Package macOS App (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- runner: macos-latest
arch: arm64
- runner: macos-15-intel
arch: x64
runs-on: ${{ matrix.runner }}
timeout-minutes: 60

steps:
Expand All @@ -22,7 +30,8 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
architecture: ${{ matrix.arch }}
cache: npm

- name: Install Node dependencies
Expand All @@ -31,21 +40,46 @@ jobs:
- name: Build Python runtime
run: npm run setup:python-runtime

- name: Test native Python runtime and platform selection
run: |
node --test tests/unit/typescript/python-runtime-manifest.test.mjs tests/unit/typescript/device-capability-parsers.test.mjs tests/unit/typescript/mac-package.test.mjs
npm run test:unit:python
npm run test:integration

- name: Package macOS app
run: npm run package:mac

- name: Verify packaged Python runtime
env:
EXPECTED_ARCH: ${{ matrix.arch }}
run: |
node -e 'if (process.arch !== process.env.EXPECTED_ARCH) process.exit(1)'
BINARY_ARCH="$EXPECTED_ARCH"
if [ "$BINARY_ARCH" = x64 ]; then BINARY_ARCH=x86_64; fi
lipo release/mac/TokenSmith.app/Contents/MacOS/TokenSmith -verify_arch "$BINARY_ARCH"
RUNTIME="release/mac/TokenSmith.app/Contents/Resources/app/app_runtime/python"
test -x "$RUNTIME/bin/python"
PYTHONHOME="$RUNTIME" DYLD_FALLBACK_LIBRARY_PATH="$RUNTIME/lib" \
"$RUNTIME/bin/python" -c 'import faiss, numpy, pypdfium2, PIL'
lipo "$RUNTIME/bin/python" -verify_arch "$BINARY_ARCH"
PYTHONHOME="$RUNTIME" DYLD_FALLBACK_LIBRARY_PATH="$RUNTIME/lib" PYTHONDONTWRITEBYTECODE=1 \
"$RUNTIME/bin/python" - <<'PY'
import os, platform
import faiss, numpy, pypdfium2, PIL
assert platform.machine() == {"x64": "x86_64", "arm64": "arm64"}[os.environ["EXPECTED_ARCH"]]
vectors = numpy.eye(2, dtype="float32")
index = faiss.IndexFlatL2(2)
index.add(vectors)
assert index.search(vectors[:1], 1)[1][0, 0] == 0
PY
if [ "$EXPECTED_ARCH" = x64 ]; then
test "$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' release/mac/TokenSmith.app/Contents/Info.plist)" = 15.0
fi
codesign --verify --deep --strict release/mac/TokenSmith.app

- name: Upload macOS package
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: TokenSmith-macos-${{ github.run_number }}
name: TokenSmith-macos-${{ matrix.arch }}-${{ github.run_number }}
path: release/TokenSmith-*-mac-*.dmg
if-no-files-found: error
retention-days: 7
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Local models run through Ollama. You can also connect a cloud model with an API
| Windows (x64) | [Portable ZIP](https://github.com/georgia-tech-db/TokenSmith/releases/download/v0.1.15/TokenSmith-0.1.15-win-x64.zip) |
| Linux (x64) | [AppImage](https://github.com/georgia-tech-db/TokenSmith/releases/download/v0.1.15/TokenSmith-0.1.15-linux-x64.AppImage) or [Debian package](https://github.com/georgia-tech-db/TokenSmith/releases/download/v0.1.15/tokensmith_0.1.15_amd64.deb) |

Intel Macs running macOS 15 or newer can use the same
[setup and packaging commands](docs/development.md). An Intel installer has
not yet been published for the listed v0.1.15 release.

## Demo

https://github.com/user-attachments/assets/933908db-2117-4e90-9628-74e2038bbbfa
Expand Down
23 changes: 21 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ npm run setup:python-runtime

This creates `app_runtime/python` inside the repository. It does not require,
modify, or install packages into your system Python. The download is selected
for Linux x64, Windows x64, or macOS ARM64 and verified with SHA-256 before it
is used.
for Linux x64, Windows x64, macOS Apple Silicon (ARM64), or macOS Intel (x64),
and verified with SHA-256 before it is used. Intel Macs require macOS 15 or
newer because of the pinned native Python dependencies. Both Mac architectures
use the same setup commands and dependency versions.

The runtime comes from Astral's
[python-build-standalone](https://github.com/astral-sh/python-build-standalone)
Expand All @@ -48,13 +50,30 @@ that command automatically and include the private runtime in each application,
so students do not need to install Python.

Run the packaging command on its matching operating system.
On macOS, install Apple's Command Line Tools (`xcode-select --install`) first.

Create a macOS DMG:

```sh
npm run package:mac
```

Mac packaging uses the architecture of Node.js. Run the same command on an
Intel Mac to create
`release/TokenSmith-<version>-mac-x64.dmg`, or on Apple Silicon to create
`release/TokenSmith-<version>-mac-arm64.dmg`. The Intel application declares
macOS 15.0 as its minimum version.

Use Node.js, Electron, and the Python runtime for the same architecture.
Packaging checks the Electron and Python binaries before building the app;
after changing Node.js architectures, reinstall Node dependencies and rerun
`npm run setup:python-runtime`.

The macOS release workflow builds both architectures on native GitHub
runners, tests the native runtime, verifies packaged binary architectures and
vector search, and keeps their artifacts separate.
It runs manually or when a `v*` release tag is pushed.

Create a Windows portable ZIP:

```sh
Expand Down
32 changes: 29 additions & 3 deletions scripts/package-mac.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { execFile } from 'node:child_process'
import { copyFile, cp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { existsSync, readdirSync } from 'node:fs'
import { basename, dirname, join, relative, resolve } from 'node:path'
import { basename, dirname, join, relative } from 'node:path'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'

const execFileAsync = promisify(execFile)
const rootDir = resolve(new URL('..', import.meta.url).pathname)
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
const packageJson = JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf8'))
const appName = 'TokenSmith'
const appVersion = packageJson.version
Expand Down Expand Up @@ -68,6 +69,29 @@ function requirePythonRuntime(appRuntimePath) {
}
}

async function verifyPackageArchitecture() {
const binaryArch = { x64: 'x86_64', arm64: 'arm64' }[arch]
if (!binaryArch) {
throw new Error(`macOS packaging does not support the ${arch} architecture.`)
}

requirePythonRuntime(join(rootDir, 'app_runtime'))
const binaries = [
join(rootDir, 'node_modules', 'electron', 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron'),
join(rootDir, 'app_runtime', 'python', 'bin', 'python')
]
for (const binary of binaries) {
try {
await run('/usr/bin/lipo', [binary, '-verify_arch', binaryArch])
} catch {
throw new Error(
`The binary at ${binary} does not support macOS ${arch}. ` +
`Reinstall Node dependencies and run npm run setup:python-runtime with ${arch} Node.js before packaging.`
)
}
}
}

function runtimeMachOCandidates(directoryPath, pythonBinPath, candidates = []) {
for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
const entryPath = join(directoryPath, entry.name)
Expand Down Expand Up @@ -246,7 +270,8 @@ async function prepareAppBundle() {
CFBundleName: appName,
CFBundleShortVersionString: appVersion,
CFBundleVersion: appVersion,
LSApplicationCategoryType: 'public.app-category.education'
LSApplicationCategoryType: 'public.app-category.education',
...(arch === 'x64' ? { LSMinimumSystemVersion: '15.0' } : {})
})

await prepareAppPayload(resourcesPath)
Expand Down Expand Up @@ -317,6 +342,7 @@ if (process.platform !== 'darwin') {
}

log(`Building ${appName} ${appVersion} for macOS ${arch}.`)
await verifyPackageArchitecture()
await run('npm', ['run', 'build'])
await prepareAppBundle()
await signAppBundle()
Expand Down
4 changes: 4 additions & 0 deletions scripts/python-runtime-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ const runtimes = {
filename: 'cpython-3.12.14+20260901-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz',
sha256: '72748da13197c1fb161e3afeef20a6a385ff24f2165e6e2758e47008e7faba4c'
},
'darwin-x64': {
filename: 'cpython-3.12.14+20260901-x86_64-apple-darwin-install_only_stripped.tar.gz',
sha256: '65b195c9cedc1fef6767f044f9822069adbd1bd9204d424ece4628776fdc04bb'
},
'darwin-arm64': {
filename: 'cpython-3.12.14+20260901-aarch64-apple-darwin-install_only_stripped.tar.gz',
sha256: '81a359f1cfadd4da11766534c5913791cea55f26e1bb902cacd2a531bb1e4b2b'
Expand Down
3 changes: 2 additions & 1 deletion tests/helpers/buzzdb-fixture.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'

const rootDir = resolve(new URL('../..', import.meta.url).pathname)
const rootDir = fileURLToPath(new URL('../..', import.meta.url))

export const buzzdbFixturePath = resolve(rootDir, 'tests/fixtures/buzzdb/buzzdb-book.tokensmith.md')
export const buzzdbFixtureText = readFileSync(buzzdbFixturePath, 'utf8')
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/python-engine-pdf.test.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { existsSync, readdirSync } from 'node:fs'
Expand All @@ -6,7 +7,7 @@ import { tmpdir } from 'node:os'
import { delimiter, dirname, join, resolve } from 'node:path'
import { once } from 'node:events'

const rootDir = resolve(new URL('../..', import.meta.url).pathname)
const rootDir = fileURLToPath(new URL('../..', import.meta.url))
const workerPath = join(rootDir, 'python_engine', 'tokensmith_engine.py')
const bundledPythonPath = process.platform === 'win32'
? join(rootDir, 'app_runtime', 'python', 'python.exe')
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/typescript/mac-package.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import test from 'node:test'

for (const incompatibleBinary of ['Electron', 'Python']) {
test(`macOS packaging rejects incompatible ${incompatibleBinary} before changing an existing package`, {
skip: process.platform !== 'darwin'
}, async () => {
const root = await mkdtemp(join(tmpdir(), 'tokensmith mac package '))
try {
const script = join(root, 'scripts', 'package-mac.mjs')
const binaries = {
Electron: join(root, 'node_modules', 'electron', 'dist', 'Electron.app', 'Contents', 'MacOS', 'Electron'),
Python: join(root, 'app_runtime', 'python', 'bin', 'python')
}
await mkdir(dirname(script), { recursive: true })
await copyFile(new URL('../../../scripts/package-mac.mjs', import.meta.url), script)
await writeFile(join(root, 'package.json'), JSON.stringify({ version: '0.1.15' }))
for (const [name, binary] of Object.entries(binaries)) {
await mkdir(dirname(binary), { recursive: true })
if (name === incompatibleBinary) {
await writeFile(binary, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
} else {
await symlink(process.execPath, binary)
}
}
const existingPackage = join(root, 'release', 'mac', 'existing-package')
await mkdir(dirname(existingPackage), { recursive: true })
await writeFile(existingPackage, 'preserve this package')

const result = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 30_000 })
assert.equal(result.status, 1, result.error?.message || result.stderr)
assert.ok(result.stderr.includes(binaries[incompatibleBinary]), result.stderr)
assert.ok(result.stderr.includes(`does not support macOS ${process.arch}`), result.stderr)
assert.equal(await readFile(existingPackage, 'utf8'), 'preserve this package')
} finally {
await rm(root, { recursive: true, force: true })
}
})
}
9 changes: 9 additions & 0 deletions tests/unit/typescript/python-runtime-manifest.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ test('selects the pinned Linux x64 runtime', () => {
assert.match(runtime.sha256, /^[a-f0-9]{64}$/)
})

test('selects the pinned Intel Mac runtime without changing the Apple Silicon Python version', () => {
const runtime = standaloneRuntime('darwin', 'x64')
assert.equal(runtime.key, 'darwin-x64')
assert.equal(runtime.pythonVersion, standaloneRuntime('darwin', 'arm64').pythonVersion)
assert.match(runtime.filename, /x86_64-apple-darwin-install_only_stripped\.tar\.gz$/)
assert.match(runtime.url, /cpython-3\.12\.14%2B20260901-x86_64-apple-darwin/)
assert.equal(runtime.sha256, '65b195c9cedc1fef6767f044f9822069adbd1bd9204d424ece4628776fdc04bb')
})

test('selects platform-specific runtimes', () => {
assert.match(standaloneRuntime('darwin', 'arm64').filename, /aarch64-apple-darwin/)
assert.match(standaloneRuntime('win32', 'x64').filename, /x86_64-pc-windows-msvc/)
Expand Down
Loading