From e9acd379204c28be2e4075de056e550f0b42e135 Mon Sep 17 00:00:00 2001 From: ExtraBrain Developer Date: Tue, 27 Oct 2026 07:18:35 +0100 Subject: [PATCH 1/4] Build Windows CLI releases on self-hosted runners Cross-build win32-x64 and win32-arm64 executables in a Node container on the CodeBuild Linux runners, and build darwin-arm64 and darwin-x64 on the self-hosted Mac mini. build-sea.mjs now accepts --target and embeds the blob into the checksum-verified official Node binary for that target. The Windows installer supports ARM64 and Windows PowerShell 5.1, update replaces the running executable in place, and the DPAPI credential script loads System.Security. The README covers installing, updating, and removing the CLI on Windows. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actionlint.yaml | 8 +++ .github/workflows/ci.yml | 87 +++++++++++-------------- .github/workflows/release.yml | 78 +++++++++++++++++------ README.md | 88 ++++++++++++++++++++++---- install.ps1 | 11 ++-- scripts/build-sea.mjs | 115 ++++++++++++++++++++++++++++------ scripts/build-windows.sh | 28 +++++++++ scripts/verify-macos.sh | 27 ++++++++ src/credentialStore.ts | 1 + src/resumeStore.service.ts | 5 +- src/update.ts | 15 ++--- 11 files changed, 351 insertions(+), 112 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100755 scripts/build-windows.sh create mode 100755 scripts/verify-macos.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..35edf48 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,8 @@ +self-hosted-runner: + labels: + - macmini + # Per-job labels that route each job to the CodeBuild runner started for it. + - cli-ci-linux + - cli-release-prepare + - cli-release-windows + - cli-release-publish diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 238ccaa..756a49e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,34 @@ permissions: contents: read jobs: - validate: + validate-linux: + name: Validate on Linux and cross-build Windows + # The last label routes this job only to the CodeBuild runner started for it. + runs-on: + - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} + - cli-ci-linux + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.20.0 + cache: npm + - run: npm ci + - run: npm run check + - name: Cross-build and check Windows executables + run: bash scripts/build-windows.sh + + validate-macos: + name: Validate macOS ${{ matrix.target }} strategy: fail-fast: false matrix: include: - - runner: macos-15 - - runner: macos-15-intel - runs-on: ${{ matrix.runner }} + - target: darwin-arm64 + arch: arm64 + - target: darwin-x64 + arch: x64 + runs-on: [self-hosted, macOS, ARM64, macmini] steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-node@v7.0.0 @@ -24,38 +44,34 @@ jobs: node-version: 24.20.0 cache: npm - run: npm ci - - run: npm run check - - run: npm run build:sea - - name: Test packaged executable updates - run: EXTRABRAIN_TEST_EXECUTABLE="$PWD/dist/extrabrain" npm test -- test/install.test.ts + - if: matrix.arch == 'arm64' + run: npm run check + - name: Build executable + run: npm run build:sea -- --target ${{ matrix.target }} - name: Smoke test macOS executable - shell: bash - run: | - set -euo pipefail - test "$(dist/extrabrain --version)" = "$(node -p 'require("./package.json").version')" - set +e - result=$(dist/extrabrain --json capabilities) - code=$? - set -e - test "$code" = 3 - echo "$result" | jq -e '.code == 3' + run: bash scripts/verify-macos.sh "dist/${{ matrix.target }}/extrabrain" ${{ matrix.arch }} "$(node -p 'require("./package.json").version')" + - name: Test packaged executable updates + if: matrix.arch == 'arm64' + run: EXTRABRAIN_TEST_EXECUTABLE="$PWD/dist/${{ matrix.target }}/extrabrain" npm test -- test/install.test.ts + # Installs only into a temporary home, so the shared runner is left unchanged. - name: Install macOS command for the current user + if: matrix.arch == 'arm64' shell: bash run: | set -euo pipefail version=$(node -p 'require("./package.json").version') - arch=$(uname -m) - if [ "$arch" = x86_64 ]; then arch=x64; fi release="$RUNNER_TEMP/installer-releases/download/v$version" mkdir -p "$release" - asset="extrabrain-v$version-darwin-$arch.tar.gz" - tar -czf "$release/$asset" -C dist extrabrain + asset="extrabrain-v$version-${{ matrix.target }}.tar.gz" + tar -czf "$release/$asset" -C "dist/${{ matrix.target }}" extrabrain (cd "$release" && shasum -a 256 "$asset" > SHA256SUMS) install_home="$RUNNER_TEMP/installer-home" + rm -rf "$install_home" mkdir -p "$install_home" echo "EXTRABRAIN_TEST_HOME=$install_home" >> "$GITHUB_ENV" cat install.sh | env HOME="$install_home" ZDOTDIR="$install_home" SHELL=/bin/zsh EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= bash - name: Verify user installation in new terminals + if: matrix.arch == 'arm64' shell: bash run: | set -euo pipefail @@ -64,30 +80,3 @@ jobs: test "$(env HOME="$EXTRABRAIN_TEST_HOME" ZDOTDIR="$EXTRABRAIN_TEST_HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin /bin/zsh "$mode" 'extrabrain --version')" = "$version" done test "$(stat -f '%Su:%Lp' "$EXTRABRAIN_TEST_HOME/.local/bin/extrabrain")" = "$(id -un):755" - - name: Install optional system-wide command - shell: bash - run: | - set -euo pipefail - version=$(node -p 'require("./package.json").version') - cat install.sh | env EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR=/usr/local/bin bash - - name: Verify installed version and access from another account - shell: bash - run: | - set -euo pipefail - version=$(node -p 'require("./package.json").version') - test "$(PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$version" - test "$(stat -f '%Su:%Sg:%Lp' /usr/local/bin/extrabrain)" = root:wheel:755 - test "$(sudo -n -u nobody /usr/bin/env PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$version" - - name: Update system-wide command - shell: bash - run: | - set -euo pipefail - version=$(node -p 'require("./package.json").version') - result=$(EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= /usr/local/bin/extrabrain --json update) - echo "$result" | jq -e '.code == 0' - - name: Verify version after update - shell: bash - run: | - set -euo pipefail - test "$(PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$(node -p 'require("./package.json").version')" - test "$(stat -f '%Su:%Sg:%Lp' /usr/local/bin/extrabrain)" = root:wheel:755 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba01298..818b98f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,10 @@ concurrency: jobs: prepare: - runs-on: ubuntu-24.04 + # Each CodeBuild job has a label of its own, so a runner started for one job cannot take another. + runs-on: + - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} + - cli-release-prepare outputs: base_sha: ${{ steps.version.outputs.base_sha }} version: ${{ steps.version.outputs.version }} @@ -52,17 +55,19 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" - build: + build-macos: + name: Build ${{ matrix.target }} needs: prepare strategy: fail-fast: false matrix: include: - - runner: macos-15 - target: darwin-arm64 - - runner: macos-15-intel - target: darwin-x64 - runs-on: ${{ matrix.runner }} + - target: darwin-arm64 + arch: arm64 + - target: darwin-x64 + arch: x64 + # The x64 executable is cross-built from the x64 Node release on the ARM64 Mac mini. + runs-on: [self-hosted, macOS, ARM64, macmini] steps: - uses: actions/checkout@v7.0.1 with: @@ -83,31 +88,64 @@ jobs: - name: Validate CLI run: npm run check - name: Build executable - run: npm run build:sea + run: npm run build:sea -- --target ${{ matrix.target }} - name: Verify and archive macOS executable shell: bash env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} TARGET: ${{ matrix.target }} + ARCH: ${{ matrix.arch }} run: | set -euo pipefail - test "$(dist/extrabrain --version)" = "${RELEASE_TAG#v}" - set +e - result=$(dist/extrabrain --json capabilities) - code=$? - set -e - test "$code" = 3 - echo "$result" | jq -e '.code == 3 and .message == "ExtraBrain is not running"' - tar -czf "extrabrain-$RELEASE_TAG-$TARGET.tar.gz" -C dist extrabrain + bash scripts/verify-macos.sh "dist/$TARGET/extrabrain" "$ARCH" "$RELEASE_VERSION" + tar -czf "extrabrain-$RELEASE_TAG-$TARGET.tar.gz" -C "dist/$TARGET" extrabrain - uses: actions/upload-artifact@v7.0.1 with: name: ${{ matrix.target }} - path: extrabrain-${{ needs.prepare.outputs.tag }}-${{ matrix.target }}.* + path: extrabrain-${{ needs.prepare.outputs.tag }}-${{ matrix.target }}.tar.gz + if-no-files-found: error + + build-windows: + name: Build win32-x64 and win32-arm64 + needs: prepare + runs-on: + - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} + - cli-release-windows + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.20.0 + cache: npm + - name: Set release version + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + npm version "$RELEASE_VERSION" --no-git-tag-version --ignore-scripts + node scripts/check-release-version.mjs + - name: Install dependencies + run: npm ci + - name: Validate CLI + run: npm run check + - name: Cross-build, check, and archive Windows executables + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: bash scripts/build-windows.sh + - uses: actions/upload-artifact@v7.0.1 + with: + name: win32 + path: extrabrain-${{ needs.prepare.outputs.tag }}-win32-*.zip if-no-files-found: error publish: - needs: [prepare, build] - runs-on: ubuntu-24.04 + needs: [prepare, build-macos, build-windows] + runs-on: + - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} + - cli-release-publish permissions: contents: write steps: @@ -147,4 +185,4 @@ jobs: set -euo pipefail cd release-assets sha256sum extrabrain-* > SHA256SUMS - gh release create "$RELEASE_TAG" extrabrain-* SHA256SUMS --title "$RELEASE_TAG" --notes "Unsigned macOS CLI release. This executable is not Developer ID signed or notarized." --verify-tag + gh release create "$RELEASE_TAG" extrabrain-* SHA256SUMS --title "$RELEASE_TAG" --notes "Unsigned CLI release for macOS and Windows. The macOS executables are not Developer ID signed or notarized, and the Windows executables are not Authenticode signed." --verify-tag diff --git a/README.md b/README.md index 504ab1b..3d8aeef 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,21 @@ # ExtraBrain CLI -The ExtraBrain CLI is a small add-on for the [ExtraBrain](https://extrabrain.app) Mac app. It lets you work with your ExtraBrain library by typing short commands in the Terminal instead of clicking through the app. It is also handy for AI assistants and scripts that need to read from or add to your library. +The ExtraBrain CLI is a small add-on for the [ExtraBrain](https://extrabrain.app) app on Mac and Windows. It lets you work with your ExtraBrain library by typing short commands in Terminal (Mac) or PowerShell (Windows) instead of clicking through the app. It is also handy for AI assistants and scripts that need to read from or add to your library. To learn more about ExtraBrain itself, or to download the app, visit **[extrabrain.app](https://extrabrain.app)**. -> **Good to know:** The CLI is a companion to the app, not a replacement. **ExtraBrain must be open and running** on your Mac whenever you use it. Everything stays on your computer; nothing is sent over the internet. +> **Good to know:** The CLI is a companion to the app, not a replacement. **ExtraBrain must be open and running** on your computer whenever you use it. Everything stays on your computer; nothing is sent over the internet. ## What you need -- A Mac (Apple Silicon or Intel). +- A Mac (Apple Silicon or Intel), or a Windows 10 or 11 PC (Intel/AMD or ARM). - The ExtraBrain app, installed and open. Get it at [extrabrain.app](https://extrabrain.app). - About two minutes. You do not need to install anything else (no Node, no Python), and no administrator password. ## Install +### Mac + 1. Open the **Terminal** app. (Press `⌘ Space`, type "Terminal", and press Return.) 2. Copy the line below, paste it into Terminal, and press Return: @@ -28,32 +30,72 @@ curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master extrabrain --version ``` -If you see a version number such as `0.1.7`, you are done. +If you see a version number such as `0.1.8`, you are done. **If macOS blocks the program the first time:** this release is not yet signed by Apple, so macOS may ask for extra approval. Open **System Settings → Privacy & Security**, scroll down, and choose **Open Anyway** next to the message about `extrabrain`. **If Terminal says "command not found":** close every Terminal window and open a new one. The installer adds the command to your shell setup, and that only takes effect in new windows. +### Windows + +1. Open **PowerShell**. (Press the Windows key, type "PowerShell", and press Enter.) +2. Copy the line below, paste it into PowerShell, and press Enter: + +```powershell +irm https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.ps1 | iex +``` + +3. When it finishes, **close PowerShell and open a new window**. +4. Check that it worked: + +```powershell +extrabrain --version +``` + +If you see a version number such as `0.1.8`, you are done. + +The installer puts `extrabrain.exe` in `%LOCALAPPDATA%\ExtraBrain\bin` and adds that folder to your user `PATH`. It works for both Intel/AMD and ARM PCs and picks the right version automatically. + +**If Windows shows "Windows protected your PC" or Microsoft Defender warns about the program:** this release is not yet signed by Microsoft, so Windows may ask for extra approval. Choose **More info → Run anyway**. + +**If PowerShell says "'extrabrain' is not recognized":** close every PowerShell and Command Prompt window and open a new one. The installer adds the command to your `PATH`, and that only takes effect in new windows. + ### Update ```sh extrabrain update ``` -Check the new version afterwards with `extrabrain --version`. Updating never happens automatically. +Check the new version afterwards with `extrabrain --version`. On Windows the update finishes just after the command exits, so check the version with a new command. Updating never happens automatically. ### Uninstall +On a Mac: + ```sh rm ~/.local/bin/extrabrain ``` +On Windows, in PowerShell: + +```powershell +Remove-Item "$env:LOCALAPPDATA\ExtraBrain\bin\extrabrain.exe" +``` + +To also remove the folder from your `PATH` on Windows: + +```powershell +$dir = "$env:LOCALAPPDATA\ExtraBrain\bin" +$path = ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and $_ -ne $dir }) -join ';' +[Environment]::SetEnvironmentVariable('Path', $path, 'User') +``` + This removes only the command. Your ExtraBrain app, documents, and settings are not touched. ## Try it 1. Open the ExtraBrain app. -2. In Terminal, run: +2. In Terminal (Mac) or PowerShell (Windows), run: ```sh extrabrain capabilities @@ -63,7 +105,7 @@ If it lists what your app supports, everything is connected. If it says the app ## What the CLI can do -Think of these as its skills. Each one is a group of commands. Add `--json` right after `extrabrain` (for example `extrabrain --json documents list`) when a script or AI assistant needs machine-readable results. +Think of these as its skills. Each one is a group of commands, and they work the same on Mac and Windows. Add `--json` right after `extrabrain` (for example `extrabrain --json documents list`) when a script or AI assistant needs machine-readable results. ### 1. Documents: add files to your library @@ -164,23 +206,38 @@ Exports go to a **new** location and never overwrite existing files. They contai | --- | --- | | "App not running" (exit code `3`) | Open the ExtraBrain app, then try again. | | "Unsupported API version or capability" (exit code `8`) | Your app is too old, or that feature is turned off. Update ExtraBrain from [extrabrain.app](https://extrabrain.app). | -| `command not found` | Close Terminal and open a new window. | -| Wrong port | The CLI talks to the app on port `37373`. If your app uses another port, set `EXTRABRAIN_PORT` to that number. | +| `command not found` (Mac) | Close Terminal and open a new window. | +| `'extrabrain' is not recognized` (Windows) | Close PowerShell and open a new window. | +| "Windows protected your PC" | Choose **More info → Run anyway**. The program is not yet signed by Microsoft. | +| Wrong port | The CLI talks to the app on port `37373`. If your app uses another port, set `EXTRABRAIN_PORT` to that number (`export EXTRABRAIN_PORT=37374` on a Mac, `$env:EXTRABRAIN_PORT = 37374` in PowerShell). | ## Advanced
Installer options +On a Mac: + - **Install location:** by default the installer puts `extrabrain` in `~/.local/bin` and adds that folder to your shell setup (zsh and bash). Set `EXTRABRAIN_NO_MODIFY_PATH=1` to skip changing shell files, or `EXTRABRAIN_INSTALL_DIR=` to choose another folder. - **All users on the Mac:** run the installer with `EXTRABRAIN_INSTALL_DIR=/usr/local/bin`. It asks for an administrator password only when it needs one. Uninstall with `sudo rm /usr/local/bin/extrabrain`. - **A specific version:** set `EXTRABRAIN_VERSION=v0.1.1` (any release tag). -- **Safety:** downloads are checked against the release's `SHA256SUMS` before anything is replaced. ```sh curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.sh | env EXTRABRAIN_INSTALL_DIR=/usr/local/bin bash ``` +On Windows: + +- **Install location:** by default the installer puts `extrabrain.exe` in `%LOCALAPPDATA%\ExtraBrain\bin` and adds that folder to your user `PATH`. Set `$env:EXTRABRAIN_INSTALL_DIR` to choose another folder; the installer then leaves `PATH` unchanged. +- **A specific version:** set `$env:EXTRABRAIN_VERSION` to any release tag that includes Windows builds, as in the example below (replace `vX.Y.Z`). The setting lasts only for that PowerShell window, so open a new window afterwards. +- **Requirements:** Windows PowerShell 5.1 (built into Windows 10 and 11) or PowerShell 7. + +```powershell +$env:EXTRABRAIN_VERSION = 'vX.Y.Z'; irm https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.ps1 | iex +``` + +On both, downloads are checked against the release's `SHA256SUMS` before anything is replaced. +
@@ -189,6 +246,8 @@ curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master Use `--json` to get `{ "code": number, "data": object | null, "message": string }`. Exit codes: `0` success, `1` failure, `2` usage, `3` app not running, `4` authentication or revocation, `5` conflict, `6` partial batch, `7` protected storage unavailable, `8` unsupported API or capability. - The CLI talks to the app over a local-only connection (`127.0.0.1`) and needs no password or token. It never starts the app or reads its database. +- `extrabrain pair` keeps its credential in the macOS Keychain on a Mac, or in a file protected with Windows DPAPI under `%LOCALAPPDATA%\ExtraBrain` on Windows. There is no plaintext fallback. +- Interrupted imports are remembered in `~/.local/state/extrabrain/imports` on a Mac and `%LOCALAPPDATA%\ExtraBrain\state\imports` on Windows, so `documents resume` can continue them. - Put file paths that begin with `-` after `--`. Symlinks and non-regular files are rejected. The CLI reads files from its own filesystem, so a sandboxed or remote agent cannot import a desktop-only path. - Deleting requires one document ID and its current revision. On a revision conflict, read the document again and confirm with the user instead of retrying. - List commands accept `--limit` and `--cursor`; time filters (`--since`, `--until`) are Unix seconds. Follow `data.nextCursor` to page through results. @@ -199,9 +258,14 @@ Use `--json` to get `{ "code": number, "data": object | null, "message": string
Building from source -Use Node 24.20.0 and npm 11.19.0. `npm ci`, `npm run typecheck`, and `npm test` validate the CLI; `npm run build:sea` creates a self-contained executable in `dist/`. +Use Node 24.20.0 and npm 11.19.0. `npm ci`, `npm run typecheck`, and `npm test` validate the CLI; `npm run build:sea` creates a self-contained executable for your computer in `dist/`. + +To build for another platform, pass one or more targets: `npm run build:sea -- --target win32-x64 --target win32-arm64` (also `darwin-arm64` and `darwin-x64`). Each lands in `dist//`. The build downloads the matching official Node.js binary and checks it against the Node.js release's `SHA256SUMS`. + +- macOS executables must be built on a Mac (they are signed ad hoc with `codesign`). +- Windows executables can be built anywhere. On Windows the build uses `signtool` from the Windows SDK (or `EXTRABRAIN_SIGNTOOL_PATH`); elsewhere it needs `osslsigncode`. `scripts/build-windows.sh` does the whole Windows build in a Docker container. -To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`. +To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`. It runs on the ExtraBrain self-hosted runners: macOS archives are built on the Mac mini, and Windows archives are cross-built on Linux.
diff --git a/install.ps1 b/install.ps1 index 4d7b4f7..3e136ed 100644 --- a/install.ps1 +++ b/install.ps1 @@ -17,6 +17,7 @@ $releaseBase = if ($env:EXTRABRAIN_RELEASE_BASE) { $env:EXTRABRAIN_RELEASE_BASE $installDir = if ($env:EXTRABRAIN_INSTALL_DIR) { $env:EXTRABRAIN_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'ExtraBrain\bin' } $arch = switch ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { 'X64' { 'x64' } + 'Arm64' { 'arm64' } default { throw 'Unsupported architecture' } } if ($env:EXTRABRAIN_VERSION) { @@ -33,11 +34,11 @@ $temporary = Join-Path ([IO.Path]::GetTempPath()) ("extrabrain-install-" + [guid try { $manifest = Join-Path $temporary 'SHA256SUMS' $archive = Join-Path $temporary $asset - Invoke-WebRequest "$releaseBase/download/$version/SHA256SUMS" -OutFile $manifest - Invoke-WebRequest "$releaseBase/download/$version/$asset" -OutFile $archive - $matches = @(Get-Content $manifest | Where-Object { $_ -match ('^[a-fA-F0-9]{64} ' + [regex]::Escape($asset) + '$') }) - if ($matches.Count -ne 1) { throw 'Release checksum is missing or invalid' } - $expected = $matches[0].Substring(0, 64).ToLowerInvariant() + Invoke-WebRequest "$releaseBase/download/$version/SHA256SUMS" -OutFile $manifest -UseBasicParsing + Invoke-WebRequest "$releaseBase/download/$version/$asset" -OutFile $archive -UseBasicParsing + $checksumLines = @(Get-Content $manifest | Where-Object { $_ -match ('^[a-fA-F0-9]{64} ' + [regex]::Escape($asset) + '$') }) + if ($checksumLines.Count -ne 1) { throw 'Release checksum is missing or invalid' } + $expected = $checksumLines[0].Substring(0, 64).ToLowerInvariant() $stream = [IO.File]::OpenRead($archive) $hasher = [Security.Cryptography.SHA256]::Create() try { diff --git a/scripts/build-sea.mjs b/scripts/build-sea.mjs index 132863e..caa62df 100644 --- a/scripts/build-sea.mjs +++ b/scripts/build-sea.mjs @@ -1,11 +1,26 @@ import { spawnSync } from 'node:child_process' -import { copyFile, mkdir, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { parseArgs } from 'node:util' + +// Cross-target builds embed a portable blob (no snapshot or code cache) into the +// official Node binary for the target, downloaded and checked against SHASUMS256.txt. +const TARGETS = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64'] +const host = `${process.platform}-${process.arch}` +const { values } = parseArgs({ options: { target: { type: 'string', multiple: true } } }) +const requested = values.target ?? [] +for (const target of requested) { + if (!TARGETS.includes(target)) throw new Error(`--target must be one of: ${TARGETS.join(', ')}`) +} const outputDir = 'dist' -const executable = join(outputDir, process.platform === 'win32' ? 'extrabrain.exe' : 'extrabrain') const seaConfig = join(outputDir, 'sea-config.json') const blob = join(outputDir, 'sea.blob') +const nodeVersion = `v${process.versions.node}` +const nodeDist = `https://nodejs.org/dist/${nodeVersion}` +const cacheDir = join('node_modules', '.cache', 'extrabrain-sea', nodeVersion) const run = (command, args) => { const result = spawnSync(command, args, { stdio: 'inherit' }) @@ -14,6 +29,85 @@ const run = (command, args) => { } } +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') + +const fetchBytes = async (url) => { + const response = await fetch(url) + if (!response.ok) throw new Error(`Download failed: ${url} (${response.status})`) + return Buffer.from(await response.arrayBuffer()) +} + +let checksums +const verifiedDownload = async (name) => { + checksums ??= (await fetchBytes(`${nodeDist}/SHASUMS256.txt`)).toString('utf8') + const expected = checksums.split('\n').find((line) => line.endsWith(` ${name}`))?.slice(0, 64) + if (!expected) throw new Error(`${name} is missing from ${nodeDist}/SHASUMS256.txt`) + const path = join(cacheDir, name.replaceAll('/', '-')) + if (existsSync(path) && sha256(await readFile(path)) === expected) return path + const bytes = await fetchBytes(`${nodeDist}/${name}`) + if (sha256(bytes) !== expected) throw new Error(`Checksum mismatch for ${name}`) + await mkdir(cacheDir, { recursive: true }) + await writeFile(path, bytes) + return path +} + +const baseBinary = async (platform, arch) => { + if (`${platform}-${arch}` === host) return process.execPath + if (platform === 'win32') return verifiedDownload(`win-${arch}/node.exe`) + const name = `node-${nodeVersion}-darwin-${arch}` + const archive = await verifiedDownload(`${name}.tar.gz`) + run('tar', ['-xzf', archive, '-C', cacheDir, `${name}/bin/node`]) + return join(cacheDir, name, 'bin', 'node') +} + +const findSigntool = async () => { + if (process.env.EXTRABRAIN_SIGNTOOL_PATH) return process.env.EXTRABRAIN_SIGNTOOL_PATH + const kits = join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Windows Kits', '10', 'bin') + const versions = existsSync(kits) ? await readdir(kits) : [] + versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + for (const version of versions) { + const tool = join(kits, version, process.arch === 'arm64' ? 'arm64' : 'x64', 'signtool.exe') + if (existsSync(tool)) return tool + } + return 'signtool' +} + +// Copies the target's Node binary to the executable path without its signature. +const prepareExecutable = async (platform, base, executable) => { + await rm(executable, { force: true }) + if (platform === 'darwin') { + if (process.platform !== 'darwin') throw new Error('macOS executables must be built on macOS') + await copyFile(base, executable) + run('codesign', ['--remove-signature', executable]) + } else if (platform === 'win32' && process.platform === 'win32') { + await copyFile(base, executable) + run(await findSigntool(), ['remove', '/s', executable]) + } else if (platform === 'win32') { + run(process.env.EXTRABRAIN_OSSLSIGNCODE_PATH ?? 'osslsigncode', ['remove-signature', '-in', base, '-out', executable]) + } else { + await copyFile(base, executable) + } +} + +const build = async (target) => { + const [platform, arch] = target.split('-') + const directory = requested.length ? join(outputDir, target) : outputDir + const executable = join(directory, platform === 'win32' ? 'extrabrain.exe' : 'extrabrain') + await mkdir(directory, { recursive: true }) + await prepareExecutable(platform, await baseBinary(platform, arch), executable) + run(process.execPath, [ + join('node_modules', 'postject', 'dist', 'cli.js'), + executable, + 'NODE_SEA_BLOB', + blob, + '--sentinel-fuse', + 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', + ...(platform === 'darwin' ? ['--macho-segment-name', 'NODE_SEA'] : []) + ]) + if (platform !== 'win32') await chmod(executable, 0o755) + if (platform === 'darwin') run('codesign', ['--force', '--sign', '-', executable]) +} + await mkdir(outputDir, { recursive: true }) await writeFile( seaConfig, @@ -26,19 +120,4 @@ await writeFile( }) ) run(process.execPath, ['--experimental-sea-config', seaConfig]) -await copyFile(process.execPath, executable) -if (process.platform === 'darwin') run('codesign', ['--remove-signature', executable]) -if (process.platform === 'win32') { - run(process.env.EXTRABRAIN_SIGNTOOL_PATH ?? 'signtool', ['remove', '/s', executable]) -} -run(process.execPath, [ - join('node_modules', 'postject', 'dist', 'cli.js'), - executable, - 'NODE_SEA_BLOB', - blob, - '--sentinel-fuse', - 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', - ...(process.platform === 'darwin' ? ['--macho-segment-name', 'NODE_SEA'] : []) -]) -if (process.platform !== 'win32') run('chmod', ['755', executable]) -if (process.platform === 'darwin') run('codesign', ['--force', '--sign', '-', executable]) +for (const target of requested.length ? requested : [host]) await build(target) diff --git a/scripts/build-windows.sh b/scripts/build-windows.sh new file mode 100755 index 0000000..6e234a2 --- /dev/null +++ b/scripts/build-windows.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Cross-builds the Windows x64 and arm64 executables in a pinned Node container and +# checks their format and injected blob. Run `npm ci` first. With RELEASE_TAG set, it +# also writes the release archives, which hold extrabrain.exe at their root. +set -euo pipefail + +docker run --rm \ + -v "$PWD:/src" -w /src \ + -e HOME=/tmp \ + -e RELEASE_TAG="${RELEASE_TAG:-}" \ + -e OWNER="$(id -u):$(id -g)" \ + node:24.20.0-bookworm bash -c ' + set -euo pipefail + trap "chown -R \"\$OWNER\" dist node_modules/.cache *.zip 2>/dev/null || true" EXIT + apt-get update -qq + apt-get install -y -qq --no-install-recommends osslsigncode file zip >/dev/null + npm run build:bundle + node scripts/build-sea.mjs --target win32-x64 --target win32-arm64 + for arch in x64 arm64; do + executable="dist/win32-$arch/extrabrain.exe" + machine=$([ "$arch" = x64 ] && echo x86-64 || echo Aarch64) + file "$executable" | grep -q "PE32+ executable (console) $machine" + grep -q NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1 "$executable" + if [ -n "$RELEASE_TAG" ]; then + zip -j "extrabrain-$RELEASE_TAG-win32-$arch.zip" "$executable" + fi + done + ' diff --git a/scripts/verify-macos.sh b/scripts/verify-macos.sh new file mode 100755 index 0000000..5584215 --- /dev/null +++ b/scripts/verify-macos.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Checks a macOS executable's architecture and signature, then its version and the +# app-not-running contract. x64 runs through Rosetta when the host has it. +# Usage: scripts/verify-macos.sh +set -euo pipefail +executable=$1 +arch=$2 +version=$3 + +test "$(lipo -archs "$executable")" = "$([ "$arch" = x64 ] && echo x86_64 || echo arm64)" +codesign --verify --strict "$executable" +command=("$executable") +if [ "$arch" = x64 ]; then + if ! arch -x86_64 /usr/bin/true 2>/dev/null; then + echo "::warning::Rosetta is unavailable; skipped running the x64 executable" + exit 0 + fi + command=(arch -x86_64 "$executable") +fi +test "$("${command[@]}" --version)" = "$version" +# Port 1 is never the app, so the result does not depend on what the runner has open. +set +e +result=$(EXTRABRAIN_PORT=1 "${command[@]}" --json capabilities) +code=$? +set -e +test "$code" = 3 +echo "$result" | jq -e '.code == 3 and .message == "ExtraBrain is not running"' diff --git a/src/credentialStore.ts b/src/credentialStore.ts index 27950ac..096e6cc 100644 --- a/src/credentialStore.ts +++ b/src/credentialStore.ts @@ -5,6 +5,7 @@ const SERVICE = 'ExtraBrain standalone CLI document automation' const ACCOUNT = 'local-api' const WINDOWS_SCRIPT = ` $ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Security $path = Join-Path $env:LOCALAPPDATA 'ExtraBrain\\cli-credential.dat' try { if ($env:EXTRABRAIN_CLI_CREDENTIAL_ACTION -eq 'read') { diff --git a/src/resumeStore.service.ts b/src/resumeStore.service.ts index b194a07..4c40cc1 100644 --- a/src/resumeStore.service.ts +++ b/src/resumeStore.service.ts @@ -29,7 +29,10 @@ export interface ResumeManifest { } } -const defaultDirectory = (): string => join(homedir(), '.local', 'state', 'extrabrain', 'imports') +const defaultDirectory = (): string => + process.platform === 'win32' && process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, 'ExtraBrain', 'state', 'imports') + : join(homedir(), '.local', 'state', 'extrabrain', 'imports') export class ResumeStore { private saveQueue: Promise = Promise.resolve() diff --git a/src/update.ts b/src/update.ts index 295b6f6..e5251ef 100644 --- a/src/update.ts +++ b/src/update.ts @@ -5,6 +5,12 @@ import installShell from '../install.sh?raw' import installPowerShell from '../install.ps1?raw' import { CliApiError } from './apiClient.service' +// A packaged executable updates itself in place unless an install directory is requested. +const installDirectory = (): Record => + isSea() && !process.env.EXTRABRAIN_INSTALL_DIR + ? { EXTRABRAIN_INSTALL_DIR: dirname(process.execPath) } + : {} + export const updateCli = (): { scheduled: boolean } => { if (process.platform === 'win32') { const encoded = Buffer.from(installPowerShell, 'utf16le').toString('base64') @@ -14,7 +20,7 @@ export const updateCli = (): { scheduled: boolean } => { { detached: true, stdio: 'ignore', - env: { ...process.env, EXTRABRAIN_WAIT_PID: String(process.pid) } + env: { ...process.env, ...installDirectory(), EXTRABRAIN_WAIT_PID: String(process.pid) } } ) child.unref() @@ -23,12 +29,7 @@ export const updateCli = (): { scheduled: boolean } => { const result = spawnSync('sh', ['-s'], { encoding: 'utf8', input: installShell, - env: { - ...process.env, - ...(isSea() && !process.env.EXTRABRAIN_INSTALL_DIR - ? { EXTRABRAIN_INSTALL_DIR: dirname(process.execPath) } - : {}) - }, + env: { ...process.env, ...installDirectory() }, timeout: 120_000 }) if (result.error || result.status !== 0) { From 22a6c22a4ea2f60f2df569677f795340028fe1b9 Mon Sep 17 00:00:00 2001 From: ExtraBrain Developer Date: Tue, 27 Oct 2026 08:42:48 +0100 Subject: [PATCH 2/4] Run macOS CI and release builds in disposable Tart VMs The shared Mac mini runner only dispatches jobs. Clone the pinned extrabrain-macos toolchain image, run validation and both macOS builds inside the VM over SSH, copy the release archives back, and delete the VM afterwards. macOS CI runs for pushes only, so pull requests from forks never reach the shared runner. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actionlint.yaml | 1 + .github/workflows/ci.yml | 69 ++++++++---------------------- .github/workflows/release.yml | 64 ++++++++++------------------ README.md | 2 +- scripts/ci-macos.sh | 40 ++++++++++++++++++ scripts/tart-job.sh | 79 +++++++++++++++++++++++++++++++++++ 6 files changed, 162 insertions(+), 93 deletions(-) create mode 100755 scripts/ci-macos.sh create mode 100755 scripts/tart-job.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 35edf48..22c5362 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,6 +1,7 @@ self-hosted-runner: labels: - macmini + - macos-vm # Per-job labels that route each job to the CodeBuild runner started for it. - cli-ci-linux - cli-release-prepare diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 756a49e..5234552 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,56 +27,23 @@ jobs: run: bash scripts/build-windows.sh validate-macos: - name: Validate macOS ${{ matrix.target }} - strategy: - fail-fast: false - matrix: - include: - - target: darwin-arm64 - arch: arm64 - - target: darwin-x64 - arch: x64 - runs-on: [self-hosted, macOS, ARM64, macmini] + name: Validate macOS in a Tart VM + # Runs for pushes only, so pull requests from forks never reach the shared Mac mini. + if: github.event_name == 'push' + runs-on: [self-hosted, macOS, ARM64, macmini, macos-vm] + permissions: + contents: read + packages: read + env: + TART_REGISTRY_HOSTNAME: ghcr.io + TART_REGISTRY_USERNAME: ${{ github.actor }} + TART_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7.0.1 - - uses: actions/setup-node@v7.0.0 - with: - node-version: 24.20.0 - cache: npm - - run: npm ci - - if: matrix.arch == 'arm64' - run: npm run check - - name: Build executable - run: npm run build:sea -- --target ${{ matrix.target }} - - name: Smoke test macOS executable - run: bash scripts/verify-macos.sh "dist/${{ matrix.target }}/extrabrain" ${{ matrix.arch }} "$(node -p 'require("./package.json").version')" - - name: Test packaged executable updates - if: matrix.arch == 'arm64' - run: EXTRABRAIN_TEST_EXECUTABLE="$PWD/dist/${{ matrix.target }}/extrabrain" npm test -- test/install.test.ts - # Installs only into a temporary home, so the shared runner is left unchanged. - - name: Install macOS command for the current user - if: matrix.arch == 'arm64' - shell: bash - run: | - set -euo pipefail - version=$(node -p 'require("./package.json").version') - release="$RUNNER_TEMP/installer-releases/download/v$version" - mkdir -p "$release" - asset="extrabrain-v$version-${{ matrix.target }}.tar.gz" - tar -czf "$release/$asset" -C "dist/${{ matrix.target }}" extrabrain - (cd "$release" && shasum -a 256 "$asset" > SHA256SUMS) - install_home="$RUNNER_TEMP/installer-home" - rm -rf "$install_home" - mkdir -p "$install_home" - echo "EXTRABRAIN_TEST_HOME=$install_home" >> "$GITHUB_ENV" - cat install.sh | env HOME="$install_home" ZDOTDIR="$install_home" SHELL=/bin/zsh EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= bash - - name: Verify user installation in new terminals - if: matrix.arch == 'arm64' - shell: bash - run: | - set -euo pipefail - version=$(node -p 'require("./package.json").version') - for mode in -lc -ic; do - test "$(env HOME="$EXTRABRAIN_TEST_HOME" ZDOTDIR="$EXTRABRAIN_TEST_HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin /bin/zsh "$mode" 'extrabrain --version')" = "$version" - done - test "$(stat -f '%Su:%Lp' "$EXTRABRAIN_TEST_HOME/.local/bin/extrabrain")" = "$(id -un):755" + - name: Start disposable macOS VM + run: bash scripts/tart-job.sh start + - name: Validate, build, and test both macOS executables + run: bash scripts/tart-job.sh run 'bash scripts/ci-macos.sh ci' + - name: Delete macOS VM + if: always() + run: bash scripts/tart-job.sh stop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 818b98f..14f43b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,54 +56,36 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" build-macos: - name: Build ${{ matrix.target }} + name: Build darwin-arm64 and darwin-x64 needs: prepare - strategy: - fail-fast: false - matrix: - include: - - target: darwin-arm64 - arch: arm64 - - target: darwin-x64 - arch: x64 - # The x64 executable is cross-built from the x64 Node release on the ARM64 Mac mini. - runs-on: [self-hosted, macOS, ARM64, macmini] + # Builds in a disposable Tart VM; the x64 executable is cross-built from the x64 Node release. + runs-on: [self-hosted, macOS, ARM64, macmini, macos-vm] + permissions: + contents: read + packages: read + env: + TART_REGISTRY_HOSTNAME: ghcr.io + TART_REGISTRY_USERNAME: ${{ github.actor }} + TART_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} steps: - uses: actions/checkout@v7.0.1 with: ref: ${{ needs.prepare.outputs.base_sha }} - - uses: actions/setup-node@v7.0.0 - with: - node-version: 24.20.0 - cache: npm - - name: Set release version - env: - RELEASE_VERSION: ${{ needs.prepare.outputs.version }} - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - run: | - npm version "$RELEASE_VERSION" --no-git-tag-version --ignore-scripts - node scripts/check-release-version.mjs - - name: Install dependencies - run: npm ci - - name: Validate CLI - run: npm run check - - name: Build executable - run: npm run build:sea -- --target ${{ matrix.target }} - - name: Verify and archive macOS executable - shell: bash - env: - RELEASE_VERSION: ${{ needs.prepare.outputs.version }} - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - TARGET: ${{ matrix.target }} - ARCH: ${{ matrix.arch }} - run: | - set -euo pipefail - bash scripts/verify-macos.sh "dist/$TARGET/extrabrain" "$ARCH" "$RELEASE_VERSION" - tar -czf "extrabrain-$RELEASE_TAG-$TARGET.tar.gz" -C "dist/$TARGET" extrabrain + - name: Start disposable macOS VM + run: bash scripts/tart-job.sh start + - name: Validate, build, verify, and archive macOS executables + run: bash scripts/tart-job.sh run "RELEASE_VERSION=$RELEASE_VERSION RELEASE_TAG=$RELEASE_TAG bash scripts/ci-macos.sh release" + - name: Copy archives from the VM + run: bash scripts/tart-job.sh fetch "extrabrain-$RELEASE_TAG-darwin-arm64.tar.gz" "extrabrain-$RELEASE_TAG-darwin-x64.tar.gz" + - name: Delete macOS VM + if: always() + run: bash scripts/tart-job.sh stop - uses: actions/upload-artifact@v7.0.1 with: - name: ${{ matrix.target }} - path: extrabrain-${{ needs.prepare.outputs.tag }}-${{ matrix.target }}.tar.gz + name: darwin + path: extrabrain-${{ needs.prepare.outputs.tag }}-darwin-*.tar.gz if-no-files-found: error build-windows: diff --git a/README.md b/README.md index 3d8aeef..ecb113f 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ To build for another platform, pass one or more targets: `npm run build:sea -- - - macOS executables must be built on a Mac (they are signed ad hoc with `codesign`). - Windows executables can be built anywhere. On Windows the build uses `signtool` from the Windows SDK (or `EXTRABRAIN_SIGNTOOL_PATH`); elsewhere it needs `osslsigncode`. `scripts/build-windows.sh` does the whole Windows build in a Docker container. -To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`. It runs on the ExtraBrain self-hosted runners: macOS archives are built on the Mac mini, and Windows archives are cross-built on Linux. +To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`. It runs on the ExtraBrain self-hosted runners: macOS archives are built in a disposable Tart VM on the shared Mac mini, and Windows archives are cross-built in Docker on Linux.
diff --git a/scripts/ci-macos.sh b/scripts/ci-macos.sh new file mode 100755 index 0000000..e50601f --- /dev/null +++ b/scripts/ci-macos.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Validates and builds both macOS executables inside the disposable Tart VM. +# `ci` also exercises the packaged update and per-user install paths; `release` sets +# RELEASE_VERSION and writes the release archives. +set -euo pipefail +mode=${1:?Usage: ci-macos.sh ci|release} + +if [ "$mode" = release ]; then + npm version "${RELEASE_VERSION:?}" --no-git-tag-version --ignore-scripts + RELEASE_TAG="${RELEASE_TAG:?}" node scripts/check-release-version.mjs +fi +version=$(node -p 'require("./package.json").version') +npm ci +npm run check +npm run build:bundle +node scripts/build-sea.mjs --target darwin-arm64 --target darwin-x64 +bash scripts/verify-macos.sh dist/darwin-arm64/extrabrain arm64 "$version" +bash scripts/verify-macos.sh dist/darwin-x64/extrabrain x64 "$version" + +if [ "$mode" = release ]; then + for target in darwin-arm64 darwin-x64; do + tar -czf "extrabrain-$RELEASE_TAG-$target.tar.gz" -C "dist/$target" extrabrain + done + exit 0 +fi + +EXTRABRAIN_TEST_EXECUTABLE="$PWD/dist/darwin-arm64/extrabrain" npm test -- test/install.test.ts + +work=$(mktemp -d) +release="$work/releases/download/v$version" +asset="extrabrain-v$version-darwin-arm64.tar.gz" +mkdir -p "$release" "$work/home" +tar -czf "$release/$asset" -C dist/darwin-arm64 extrabrain +(cd "$release" && shasum -a 256 "$asset" > SHA256SUMS) +env HOME="$work/home" ZDOTDIR="$work/home" SHELL=/bin/zsh EXTRABRAIN_RELEASE_BASE="file://$work/releases" \ + EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= bash < install.sh +for shell_mode in -lc -ic; do + test "$(env HOME="$work/home" ZDOTDIR="$work/home" PATH=/usr/bin:/bin:/usr/sbin:/sbin /bin/zsh "$shell_mode" 'extrabrain --version')" = "$version" +done +test "$(stat -f '%Su:%Lp' "$work/home/.local/bin/extrabrain")" = "$(id -un):755" diff --git a/scripts/tart-job.sh b/scripts/tart-job.sh new file mode 100755 index 0000000..e3633fc --- /dev/null +++ b/scripts/tart-job.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Runs CI commands in a disposable macOS VM cloned from the pinned ExtraBrain toolchain +# image (Node 24.20.0, codesign, Rosetta), so nothing executes on the shared Tart host. +# Usage: tart-job.sh start | run | fetch ... | stop +set -euo pipefail + +IMAGE=ghcr.io/extrabrainapp/extrabrain-macos@sha256:6e5191fc73f2f24e68fef3acd33c1834529655ab8069ba2777a705733957b1b3 +PREFIX=extrabrain-cli-job +GUEST_USER='admin' +GUEST_ROOT=/Users/admin/extrabrain-cli +state="${RUNNER_TEMP:?}/tart-job" +key="$state/id_ed25519" +vm="$PREFIX-${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:?}-${GITHUB_JOB:?}" + +ssh_vm() { + ssh -i "$key" -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ + "$GUEST_USER@$(cat "$state/ip")" "$@" +} + +remove_vm() { + tart stop --timeout 60 "$1" >/dev/null 2>&1 || true + tart delete "$1" >/dev/null 2>&1 || true +} + +case "${1:-}" in + start) + rm -rf "$state" + mkdir -p "$state" + # The runner takes one job at a time, so a VM left with this prefix belongs to a killed job. + while IFS= read -r stale; do + case "$stale" in "$PREFIX"-*) remove_vm "$stale" ;; esac + done < <(tart list --source local --quiet) + for attempt in 1 2 3; do + tart clone "$IMAGE" "$vm" && break + remove_vm "$vm" + if [ "$attempt" = 3 ]; then echo "Could not clone $IMAGE" >&2; exit 1; fi + sleep $((attempt * 10)) + done + tart set "$vm" --cpu 4 --memory 8192 + /usr/bin/env -u RUNNER_TRACKING_ID nohup tart run --no-graphics --no-clipboard --no-audio "$vm" \ + "$state/tart.log" 2>&1 & + ready= + for _ in $(seq 120); do + if tart exec "$vm" /usr/bin/true >/dev/null 2>&1; then ready=1; break; fi + sleep 1 + done + if [ -z "$ready" ]; then tail -n 50 "$state/tart.log" >&2; echo "$vm did not start" >&2; exit 1; fi + ssh-keygen -q -t ed25519 -N '' -f "$key" + # The single quotes are intentional: HOME must expand in the guest. + # shellcheck disable=SC2016 + tart exec -i "$vm" /bin/bash -c \ + 'umask 077; mkdir -p "$HOME/.ssh"; cat > "$HOME/.ssh/authorized_keys"' <"$key.pub" + tart ip --wait 60 "$vm" >"$state/ip" + for _ in $(seq 60); do + ssh_vm /usr/bin/true >/dev/null 2>&1 && break + sleep 1 + done + ssh_vm /bin/mkdir -p "$GUEST_ROOT" + git ls-files -z | xargs -0 tar -c -f - | ssh_vm tar -x -f - -C "$GUEST_ROOT" + ;; + run) + printf -v command 'cd %q && %s' "$GUEST_ROOT" "$2" + ssh_vm /bin/bash -lc "$(printf '%q' "$command")" + ;; + fetch) + shift + printf -v paths ' %q' "$@" + ssh_vm "cd $(printf '%q' "$GUEST_ROOT") && tar -c -f -$paths" | tar -x -f - + ;; + stop) + remove_vm "$vm" + rm -rf "$state" + ;; + *) + echo 'Usage: tart-job.sh start | run | fetch ... | stop' >&2 + exit 2 + ;; +esac From b837d2af2b9433b330cab464ba306215b952f7e6 Mon Sep 17 00:00:00 2001 From: ExtraBrain Developer Date: Tue, 27 Oct 2026 09:09:28 +0100 Subject: [PATCH 3/4] Build and test Windows releases on GitHub-hosted runners GitHub-hosted runners are free for this public repository, and self-hosted runners would let code from fork pull requests run on shared machines. Restore the hosted macOS jobs and build Windows x64 and ARM64 natively on windows-2025 and windows-11-arm. CI now runs the tests on Windows, smoke tests both executables, and installs one with install.ps1 into a new shell. The Tart and Docker helper scripts are removed. Removing the signature on Windows is skipped when signtool is not installed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actionlint.yaml | 9 -- .github/workflows/ci.yml | 172 ++++++++++++++++++++++++++++------ .github/workflows/release.yml | 102 ++++++++++---------- README.md | 4 +- scripts/build-sea.mjs | 16 +++- scripts/build-windows.sh | 28 ------ scripts/ci-macos.sh | 40 -------- scripts/tart-job.sh | 79 ---------------- scripts/verify-macos.sh | 27 ------ 9 files changed, 210 insertions(+), 267 deletions(-) delete mode 100644 .github/actionlint.yaml delete mode 100755 scripts/build-windows.sh delete mode 100755 scripts/ci-macos.sh delete mode 100755 scripts/tart-job.sh delete mode 100755 scripts/verify-macos.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml deleted file mode 100644 index 22c5362..0000000 --- a/.github/actionlint.yaml +++ /dev/null @@ -1,9 +0,0 @@ -self-hosted-runner: - labels: - - macmini - - macos-vm - # Per-job labels that route each job to the CodeBuild runner started for it. - - cli-ci-linux - - cli-release-prepare - - cli-release-windows - - cli-release-publish diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5234552..ced690c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,14 @@ permissions: contents: read jobs: - validate-linux: - name: Validate on Linux and cross-build Windows - # The last label routes this job only to the CodeBuild runner started for it. - runs-on: - - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} - - cli-ci-linux + validate: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + - runner: macos-15-intel + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7.0.1 - uses: actions/setup-node@v7.0.0 @@ -23,27 +25,143 @@ jobs: cache: npm - run: npm ci - run: npm run check - - name: Cross-build and check Windows executables - run: bash scripts/build-windows.sh + - run: npm run build:sea + - name: Test packaged executable updates + run: EXTRABRAIN_TEST_EXECUTABLE="$PWD/dist/extrabrain" npm test -- test/install.test.ts + - name: Smoke test macOS executable + shell: bash + run: | + set -euo pipefail + test "$(dist/extrabrain --version)" = "$(node -p 'require("./package.json").version')" + set +e + result=$(dist/extrabrain --json capabilities) + code=$? + set -e + test "$code" = 3 + echo "$result" | jq -e '.code == 3' + - name: Install macOS command for the current user + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + arch=$(uname -m) + if [ "$arch" = x86_64 ]; then arch=x64; fi + release="$RUNNER_TEMP/installer-releases/download/v$version" + mkdir -p "$release" + asset="extrabrain-v$version-darwin-$arch.tar.gz" + tar -czf "$release/$asset" -C dist extrabrain + (cd "$release" && shasum -a 256 "$asset" > SHA256SUMS) + install_home="$RUNNER_TEMP/installer-home" + mkdir -p "$install_home" + echo "EXTRABRAIN_TEST_HOME=$install_home" >> "$GITHUB_ENV" + cat install.sh | env HOME="$install_home" ZDOTDIR="$install_home" SHELL=/bin/zsh EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= bash + - name: Verify user installation in new terminals + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + for mode in -lc -ic; do + test "$(env HOME="$EXTRABRAIN_TEST_HOME" ZDOTDIR="$EXTRABRAIN_TEST_HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin /bin/zsh "$mode" 'extrabrain --version')" = "$version" + done + test "$(stat -f '%Su:%Lp' "$EXTRABRAIN_TEST_HOME/.local/bin/extrabrain")" = "$(id -un):755" + - name: Install optional system-wide command + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + cat install.sh | env EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR=/usr/local/bin bash + - name: Verify installed version and access from another account + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + test "$(PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$version" + test "$(stat -f '%Su:%Sg:%Lp' /usr/local/bin/extrabrain)" = root:wheel:755 + test "$(sudo -n -u nobody /usr/bin/env PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$version" + - name: Update system-wide command + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + result=$(EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= /usr/local/bin/extrabrain --json update) + echo "$result" | jq -e '.code == 0' + - name: Verify version after update + shell: bash + run: | + set -euo pipefail + test "$(PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$(node -p 'require("./package.json").version')" + test "$(stat -f '%Su:%Sg:%Lp' /usr/local/bin/extrabrain)" = root:wheel:755 - validate-macos: - name: Validate macOS in a Tart VM - # Runs for pushes only, so pull requests from forks never reach the shared Mac mini. - if: github.event_name == 'push' - runs-on: [self-hosted, macOS, ARM64, macmini, macos-vm] - permissions: - contents: read - packages: read - env: - TART_REGISTRY_HOSTNAME: ghcr.io - TART_REGISTRY_USERNAME: ${{ github.actor }} - TART_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + validate-windows: + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2025 + arch: x64 + - runner: windows-11-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7.0.1 - - name: Start disposable macOS VM - run: bash scripts/tart-job.sh start - - name: Validate, build, and test both macOS executables - run: bash scripts/tart-job.sh run 'bash scripts/ci-macos.sh ci' - - name: Delete macOS VM - if: always() - run: bash scripts/tart-job.sh stop + - uses: actions/setup-node@v7.0.0 + with: + node-version: 24.20.0 + cache: npm + - run: npm ci + - run: npm run check + - run: npm run build:sea + - name: Smoke test Windows executable + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $version = node -p 'require("./package.json").version' + $actualVersion = & dist/extrabrain.exe --version + if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $version) { throw "Version mismatch: $actualVersion" } + $output = & dist/extrabrain.exe --json capabilities + $exitCode = $LASTEXITCODE + $result = $output | ConvertFrom-Json + if ($exitCode -ne 3 -or $result.code -ne 3) { throw "Capabilities contract failed: $output" } + exit 0 + - name: Install Windows command for the current user + shell: pwsh + env: + ARCH: ${{ matrix.arch }} + run: | + $version = node -p 'require("./package.json").version' + $root = Join-Path $env:RUNNER_TEMP 'installer-releases' + $release = Join-Path $root "download\v$version" + New-Item -ItemType Directory -Force -Path $release | Out-Null + $asset = "extrabrain-v$version-win32-$env:ARCH.zip" + Compress-Archive -LiteralPath dist/extrabrain.exe -DestinationPath (Join-Path $release $asset) + $hash = (Get-FileHash -Algorithm SHA256 (Join-Path $release $asset)).Hash.ToLowerInvariant() + Set-Content -NoNewline -Path (Join-Path $release 'SHA256SUMS') -Value "$hash $asset`n" + $server = Join-Path $env:RUNNER_TEMP 'release-server.cjs' + Set-Content -Path $server -Value @' + const { createReadStream, existsSync } = require('node:fs') + const { join } = require('node:path') + const root = process.argv[2] + require('node:http').createServer((request, response) => { + const path = join(root, decodeURIComponent(new URL(request.url, 'http://x').pathname)) + if (!path.startsWith(root) || !existsSync(path)) return response.writeHead(404).end() + createReadStream(path).pipe(response) + }).listen(38123, '127.0.0.1') + '@ + Start-Process -FilePath node -ArgumentList @($server, $root) -WindowStyle Hidden + Start-Sleep -Seconds 2 + $env:EXTRABRAIN_RELEASE_BASE = 'http://127.0.0.1:38123' + $env:EXTRABRAIN_VERSION = "v$version" + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File install.ps1 + if ($LASTEXITCODE -ne 0) { throw 'Installer failed' } + - name: Verify user installation in a new shell + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $version = node -p 'require("./package.json").version' + $installDir = Join-Path $env:LOCALAPPDATA 'ExtraBrain\bin' + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (@($userPath -split ';') -notcontains $installDir) { throw "User PATH does not contain $installDir" } + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $actual = powershell.exe -NoProfile -NonInteractive -Command "`$env:Path = '$machinePath;$userPath'; extrabrain --version" + if ($LASTEXITCODE -ne 0 -or $actual -ne $version) { throw "Installed version mismatch: $actual" } + exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14f43b3..43bf981 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,10 +21,7 @@ concurrency: jobs: prepare: - # Each CodeBuild job has a label of its own, so a runner started for one job cannot take another. - runs-on: - - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} - - cli-release-prepare + runs-on: ubuntu-24.04 outputs: base_sha: ${{ steps.version.outputs.base_sha }} version: ${{ steps.version.outputs.version }} @@ -55,45 +52,21 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" - build-macos: - name: Build darwin-arm64 and darwin-x64 + build: needs: prepare - # Builds in a disposable Tart VM; the x64 executable is cross-built from the x64 Node release. - runs-on: [self-hosted, macOS, ARM64, macmini, macos-vm] - permissions: - contents: read - packages: read - env: - TART_REGISTRY_HOSTNAME: ghcr.io - TART_REGISTRY_USERNAME: ${{ github.actor }} - TART_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - RELEASE_VERSION: ${{ needs.prepare.outputs.version }} - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - steps: - - uses: actions/checkout@v7.0.1 - with: - ref: ${{ needs.prepare.outputs.base_sha }} - - name: Start disposable macOS VM - run: bash scripts/tart-job.sh start - - name: Validate, build, verify, and archive macOS executables - run: bash scripts/tart-job.sh run "RELEASE_VERSION=$RELEASE_VERSION RELEASE_TAG=$RELEASE_TAG bash scripts/ci-macos.sh release" - - name: Copy archives from the VM - run: bash scripts/tart-job.sh fetch "extrabrain-$RELEASE_TAG-darwin-arm64.tar.gz" "extrabrain-$RELEASE_TAG-darwin-x64.tar.gz" - - name: Delete macOS VM - if: always() - run: bash scripts/tart-job.sh stop - - uses: actions/upload-artifact@v7.0.1 - with: - name: darwin - path: extrabrain-${{ needs.prepare.outputs.tag }}-darwin-*.tar.gz - if-no-files-found: error - - build-windows: - name: Build win32-x64 and win32-arm64 - needs: prepare - runs-on: - - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} - - cli-release-windows + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: darwin-arm64 + - runner: macos-15-intel + target: darwin-x64 + - runner: windows-2025 + target: win32-x64 + - runner: windows-11-arm + target: win32-arm64 + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7.0.1 with: @@ -103,6 +76,7 @@ jobs: node-version: 24.20.0 cache: npm - name: Set release version + shell: bash env: RELEASE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} @@ -113,21 +87,49 @@ jobs: run: npm ci - name: Validate CLI run: npm run check - - name: Cross-build, check, and archive Windows executables + - name: Build executable + run: npm run build:sea + - name: Verify and archive macOS executable + if: startsWith(matrix.target, 'darwin-') + shell: bash env: RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - run: bash scripts/build-windows.sh + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + test "$(dist/extrabrain --version)" = "${RELEASE_TAG#v}" + set +e + result=$(dist/extrabrain --json capabilities) + code=$? + set -e + test "$code" = 3 + echo "$result" | jq -e '.code == 3 and .message == "ExtraBrain is not running"' + tar -czf "extrabrain-$RELEASE_TAG-$TARGET.tar.gz" -C dist extrabrain + - name: Verify and archive Windows executable + if: startsWith(matrix.target, 'win32-') + shell: pwsh + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + TARGET: ${{ matrix.target }} + run: | + $PSNativeCommandUseErrorActionPreference = $false + $actualVersion = & dist/extrabrain.exe --version + if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $env:RELEASE_TAG.Substring(1)) { throw "Version mismatch: $actualVersion" } + $output = & dist/extrabrain.exe --json capabilities + $exitCode = $LASTEXITCODE + $result = $output | ConvertFrom-Json + if ($exitCode -ne 3 -or $result.code -ne 3 -or $result.message -ne 'ExtraBrain is not running') { throw "Capabilities contract failed: $output" } + Compress-Archive -LiteralPath dist/extrabrain.exe -DestinationPath "extrabrain-$env:RELEASE_TAG-$env:TARGET.zip" + exit 0 - uses: actions/upload-artifact@v7.0.1 with: - name: win32 - path: extrabrain-${{ needs.prepare.outputs.tag }}-win32-*.zip + name: ${{ matrix.target }} + path: extrabrain-${{ needs.prepare.outputs.tag }}-${{ matrix.target }}.* if-no-files-found: error publish: - needs: [prepare, build-macos, build-windows] - runs-on: - - codebuild-extrabrain-ci-${{ github.run_id }}-${{ github.run_attempt }} - - cli-release-publish + needs: [prepare, build] + runs-on: ubuntu-24.04 permissions: contents: write steps: diff --git a/README.md b/README.md index ecb113f..f26227b 100644 --- a/README.md +++ b/README.md @@ -263,9 +263,9 @@ Use Node 24.20.0 and npm 11.19.0. `npm ci`, `npm run typecheck`, and `npm test` To build for another platform, pass one or more targets: `npm run build:sea -- --target win32-x64 --target win32-arm64` (also `darwin-arm64` and `darwin-x64`). Each lands in `dist//`. The build downloads the matching official Node.js binary and checks it against the Node.js release's `SHA256SUMS`. - macOS executables must be built on a Mac (they are signed ad hoc with `codesign`). -- Windows executables can be built anywhere. On Windows the build uses `signtool` from the Windows SDK (or `EXTRABRAIN_SIGNTOOL_PATH`); elsewhere it needs `osslsigncode`. `scripts/build-windows.sh` does the whole Windows build in a Docker container. +- Windows executables can be built anywhere. On Windows the build uses `signtool` from the Windows SDK when it finds it (or `EXTRABRAIN_SIGNTOOL_PATH`); elsewhere it needs `osslsigncode`. -To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`. It runs on the ExtraBrain self-hosted runners: macOS archives are built in a disposable Tart VM on the shared Mac mini, and Windows archives are cross-built in Docker on Linux. +To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`. It builds and tests each executable natively on GitHub-hosted macOS (Apple Silicon and Intel) and Windows (x64 and ARM64) runners. diff --git a/scripts/build-sea.mjs b/scripts/build-sea.mjs index caa62df..c8b617d 100644 --- a/scripts/build-sea.mjs +++ b/scripts/build-sea.mjs @@ -65,11 +65,14 @@ const findSigntool = async () => { const kits = join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Windows Kits', '10', 'bin') const versions = existsSync(kits) ? await readdir(kits) : [] versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) - for (const version of versions) { - const tool = join(kits, version, process.arch === 'arm64' ? 'arm64' : 'x64', 'signtool.exe') - if (existsSync(tool)) return tool + // The x64 tool also runs on Windows on ARM through emulation. + for (const arch of [...new Set([process.arch === 'arm64' ? 'arm64' : 'x64', 'x64'])]) { + for (const version of versions) { + const tool = join(kits, version, arch, 'signtool.exe') + if (existsSync(tool)) return tool + } } - return 'signtool' + return undefined } // Copies the target's Node binary to the executable path without its signature. @@ -81,7 +84,10 @@ const prepareExecutable = async (platform, base, executable) => { run('codesign', ['--remove-signature', executable]) } else if (platform === 'win32' && process.platform === 'win32') { await copyFile(base, executable) - run(await findSigntool(), ['remove', '/s', executable]) + const signtool = await findSigntool() + // Removing the signature is optional; without it postject warns about the stale signature. + if (signtool) run(signtool, ['remove', '/s', executable]) + else console.warn('signtool.exe was not found; keeping the Node.js signature') } else if (platform === 'win32') { run(process.env.EXTRABRAIN_OSSLSIGNCODE_PATH ?? 'osslsigncode', ['remove-signature', '-in', base, '-out', executable]) } else { diff --git a/scripts/build-windows.sh b/scripts/build-windows.sh deleted file mode 100755 index 6e234a2..0000000 --- a/scripts/build-windows.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Cross-builds the Windows x64 and arm64 executables in a pinned Node container and -# checks their format and injected blob. Run `npm ci` first. With RELEASE_TAG set, it -# also writes the release archives, which hold extrabrain.exe at their root. -set -euo pipefail - -docker run --rm \ - -v "$PWD:/src" -w /src \ - -e HOME=/tmp \ - -e RELEASE_TAG="${RELEASE_TAG:-}" \ - -e OWNER="$(id -u):$(id -g)" \ - node:24.20.0-bookworm bash -c ' - set -euo pipefail - trap "chown -R \"\$OWNER\" dist node_modules/.cache *.zip 2>/dev/null || true" EXIT - apt-get update -qq - apt-get install -y -qq --no-install-recommends osslsigncode file zip >/dev/null - npm run build:bundle - node scripts/build-sea.mjs --target win32-x64 --target win32-arm64 - for arch in x64 arm64; do - executable="dist/win32-$arch/extrabrain.exe" - machine=$([ "$arch" = x64 ] && echo x86-64 || echo Aarch64) - file "$executable" | grep -q "PE32+ executable (console) $machine" - grep -q NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1 "$executable" - if [ -n "$RELEASE_TAG" ]; then - zip -j "extrabrain-$RELEASE_TAG-win32-$arch.zip" "$executable" - fi - done - ' diff --git a/scripts/ci-macos.sh b/scripts/ci-macos.sh deleted file mode 100755 index e50601f..0000000 --- a/scripts/ci-macos.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# Validates and builds both macOS executables inside the disposable Tart VM. -# `ci` also exercises the packaged update and per-user install paths; `release` sets -# RELEASE_VERSION and writes the release archives. -set -euo pipefail -mode=${1:?Usage: ci-macos.sh ci|release} - -if [ "$mode" = release ]; then - npm version "${RELEASE_VERSION:?}" --no-git-tag-version --ignore-scripts - RELEASE_TAG="${RELEASE_TAG:?}" node scripts/check-release-version.mjs -fi -version=$(node -p 'require("./package.json").version') -npm ci -npm run check -npm run build:bundle -node scripts/build-sea.mjs --target darwin-arm64 --target darwin-x64 -bash scripts/verify-macos.sh dist/darwin-arm64/extrabrain arm64 "$version" -bash scripts/verify-macos.sh dist/darwin-x64/extrabrain x64 "$version" - -if [ "$mode" = release ]; then - for target in darwin-arm64 darwin-x64; do - tar -czf "extrabrain-$RELEASE_TAG-$target.tar.gz" -C "dist/$target" extrabrain - done - exit 0 -fi - -EXTRABRAIN_TEST_EXECUTABLE="$PWD/dist/darwin-arm64/extrabrain" npm test -- test/install.test.ts - -work=$(mktemp -d) -release="$work/releases/download/v$version" -asset="extrabrain-v$version-darwin-arm64.tar.gz" -mkdir -p "$release" "$work/home" -tar -czf "$release/$asset" -C dist/darwin-arm64 extrabrain -(cd "$release" && shasum -a 256 "$asset" > SHA256SUMS) -env HOME="$work/home" ZDOTDIR="$work/home" SHELL=/bin/zsh EXTRABRAIN_RELEASE_BASE="file://$work/releases" \ - EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= bash < install.sh -for shell_mode in -lc -ic; do - test "$(env HOME="$work/home" ZDOTDIR="$work/home" PATH=/usr/bin:/bin:/usr/sbin:/sbin /bin/zsh "$shell_mode" 'extrabrain --version')" = "$version" -done -test "$(stat -f '%Su:%Lp' "$work/home/.local/bin/extrabrain")" = "$(id -un):755" diff --git a/scripts/tart-job.sh b/scripts/tart-job.sh deleted file mode 100755 index e3633fc..0000000 --- a/scripts/tart-job.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# Runs CI commands in a disposable macOS VM cloned from the pinned ExtraBrain toolchain -# image (Node 24.20.0, codesign, Rosetta), so nothing executes on the shared Tart host. -# Usage: tart-job.sh start | run | fetch ... | stop -set -euo pipefail - -IMAGE=ghcr.io/extrabrainapp/extrabrain-macos@sha256:6e5191fc73f2f24e68fef3acd33c1834529655ab8069ba2777a705733957b1b3 -PREFIX=extrabrain-cli-job -GUEST_USER='admin' -GUEST_ROOT=/Users/admin/extrabrain-cli -state="${RUNNER_TEMP:?}/tart-job" -key="$state/id_ed25519" -vm="$PREFIX-${GITHUB_RUN_ID:?}-${GITHUB_RUN_ATTEMPT:?}-${GITHUB_JOB:?}" - -ssh_vm() { - ssh -i "$key" -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 \ - -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ - "$GUEST_USER@$(cat "$state/ip")" "$@" -} - -remove_vm() { - tart stop --timeout 60 "$1" >/dev/null 2>&1 || true - tart delete "$1" >/dev/null 2>&1 || true -} - -case "${1:-}" in - start) - rm -rf "$state" - mkdir -p "$state" - # The runner takes one job at a time, so a VM left with this prefix belongs to a killed job. - while IFS= read -r stale; do - case "$stale" in "$PREFIX"-*) remove_vm "$stale" ;; esac - done < <(tart list --source local --quiet) - for attempt in 1 2 3; do - tart clone "$IMAGE" "$vm" && break - remove_vm "$vm" - if [ "$attempt" = 3 ]; then echo "Could not clone $IMAGE" >&2; exit 1; fi - sleep $((attempt * 10)) - done - tart set "$vm" --cpu 4 --memory 8192 - /usr/bin/env -u RUNNER_TRACKING_ID nohup tart run --no-graphics --no-clipboard --no-audio "$vm" \ - "$state/tart.log" 2>&1 & - ready= - for _ in $(seq 120); do - if tart exec "$vm" /usr/bin/true >/dev/null 2>&1; then ready=1; break; fi - sleep 1 - done - if [ -z "$ready" ]; then tail -n 50 "$state/tart.log" >&2; echo "$vm did not start" >&2; exit 1; fi - ssh-keygen -q -t ed25519 -N '' -f "$key" - # The single quotes are intentional: HOME must expand in the guest. - # shellcheck disable=SC2016 - tart exec -i "$vm" /bin/bash -c \ - 'umask 077; mkdir -p "$HOME/.ssh"; cat > "$HOME/.ssh/authorized_keys"' <"$key.pub" - tart ip --wait 60 "$vm" >"$state/ip" - for _ in $(seq 60); do - ssh_vm /usr/bin/true >/dev/null 2>&1 && break - sleep 1 - done - ssh_vm /bin/mkdir -p "$GUEST_ROOT" - git ls-files -z | xargs -0 tar -c -f - | ssh_vm tar -x -f - -C "$GUEST_ROOT" - ;; - run) - printf -v command 'cd %q && %s' "$GUEST_ROOT" "$2" - ssh_vm /bin/bash -lc "$(printf '%q' "$command")" - ;; - fetch) - shift - printf -v paths ' %q' "$@" - ssh_vm "cd $(printf '%q' "$GUEST_ROOT") && tar -c -f -$paths" | tar -x -f - - ;; - stop) - remove_vm "$vm" - rm -rf "$state" - ;; - *) - echo 'Usage: tart-job.sh start | run | fetch ... | stop' >&2 - exit 2 - ;; -esac diff --git a/scripts/verify-macos.sh b/scripts/verify-macos.sh deleted file mode 100755 index 5584215..0000000 --- a/scripts/verify-macos.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -# Checks a macOS executable's architecture and signature, then its version and the -# app-not-running contract. x64 runs through Rosetta when the host has it. -# Usage: scripts/verify-macos.sh -set -euo pipefail -executable=$1 -arch=$2 -version=$3 - -test "$(lipo -archs "$executable")" = "$([ "$arch" = x64 ] && echo x86_64 || echo arm64)" -codesign --verify --strict "$executable" -command=("$executable") -if [ "$arch" = x64 ]; then - if ! arch -x86_64 /usr/bin/true 2>/dev/null; then - echo "::warning::Rosetta is unavailable; skipped running the x64 executable" - exit 0 - fi - command=(arch -x86_64 "$executable") -fi -test "$("${command[@]}" --version)" = "$version" -# Port 1 is never the app, so the result does not depend on what the runner has open. -set +e -result=$(EXTRABRAIN_PORT=1 "${command[@]}" --json capabilities) -code=$? -set -e -test "$code" = 3 -echo "$result" | jq -e '.code == 3 and .message == "ExtraBrain is not running"' From ac2d38b23f3be7b908e67d2445295254187da9b0 Mon Sep 17 00:00:00 2001 From: ExtraBrain Developer Date: Tue, 27 Oct 2026 09:13:35 +0100 Subject: [PATCH 4/4] Use the runner's architecture in the Windows installer test Co-Authored-By: Claude Opus 5.5 (1M context) --- test/installWindows.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/installWindows.test.ts b/test/installWindows.test.ts index 476a0af..8d6221e 100644 --- a/test/installWindows.test.ts +++ b/test/installWindows.test.ts @@ -21,7 +21,7 @@ describe.skipIf(process.platform !== 'win32')('public Windows installer', () => directories.push(directory) const installDir = join(directory, 'bin') const source = join(directory, 'extrabrain.exe') - const asset = 'extrabrain-v0.1.0-win32-x64.zip' + const asset = `extrabrain-v0.1.0-win32-${process.arch === 'arm64' ? 'arm64' : 'x64'}.zip` const archive = join(directory, asset) let checksum = '' const publish = async (content: string, valid = true) => {