Skip to content

Build and Release

Build and Release #48

Workflow file for this run

name: Build and Release
on:
# Every merge to main publishes to the BETA (nightly) channel — opt-in users only.
push:
branches:
- main
# Promote to STABLE on demand: Actions → Build and Release → Run workflow → stable.
# (Default beta, so an accidental run never ships stable to everyone.)
workflow_dispatch:
inputs:
channel:
description: 'Release channel — beta = nightly opt-in feed; stable = everyone'
type: choice
options: [beta, stable]
default: beta
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
# macOS-only for now. ONE signed + notarized DMG: the build bundles the private pro
# source (cloned from off-grid-ai/desktop-pro) and is license-gated at runtime —
# free = unlicensed (pro tabs locked), a valid Keygen key unlocks in place (no
# re-download). The ASAR integrity fuse (electron-builder.yml) makes a tampered
# app.asar refuse to load — our local-first anti-tamper lever (we don't phone home).
# Actions are pinned to commit SHAs. Windows is parked.
jobs:
# Resolve the channel + version. STABLE (manual promote) bumps the patch and
# commits it so main tracks the released version; BETA (every merge) stamps a
# throwaway -beta.<run> version WITHOUT committing, so nightly builds never move
# main's base version or spam it with bump commits.
version:
if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
sha: ${{ steps.bump.outputs.sha }}
channel: ${{ steps.bump.outputs.channel }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20'
- name: Resolve channel + version
id: bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.channel }}" = "stable" ]; then
CHANNEL=stable
npm version patch --no-git-tag-version
VERSION=$(node -p "require('./package.json').version")
git add package.json package-lock.json
git commit -m "chore: bump version to $VERSION [skip ci]"
git push
else
CHANNEL=beta
# A nightly is a prerelease of the NEXT patch, so it sorts ABOVE the last
# stable, not below it: 0.0.35 stable -> 0.0.36-beta.<run> (NOT
# 0.0.35-beta.<run>, which semver ranks BELOW 0.0.35 and would read as a
# downgrade). electron-builder turns the -beta suffix into the `beta`
# channel (beta-mac.yml) automatically. No commit.
BASE=$(node -p "require('./package.json').version")
BASE=${BASE%%-*}
NEXT=$(node -e "const p='$BASE'.split('.').map(Number); console.log(p[0]+'.'+p[1]+'.'+(p[2]+1))")
VERSION="${NEXT}-beta.${{ github.run_number }}"
fi
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Pin the build to THIS exact commit, not floating main.
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Single build: pro code bundled, license-gated. Publishes to the default update
# channel (latest-mac.yml).
build-mac:
needs: version
runs-on: macos-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.version.outputs.sha }} # the exact bumped commit
fetch-depth: 0
lfs: true # resources/bin/* (llama-server, sd-cli, whisper, ffmpeg, dylibs)
# are Git LFS — without this CI bundles pointer stubs and every
# runtime spawn fails with ENOEXEC.
- name: Pull LFS binaries
run: git lfs pull
# Rebuild the chat engine from source with a PINNED macOS deployment target.
# The committed/LFS llama-server is minos 26 (built on a macOS-26 toolchain),
# so it only launches on macOS 26 — every macOS 13/14/15 user saw "Chat model
# Down". This compiles llama-server targeting macOS 13, GATES on its minos,
# and overwrites resources/bin/llama/ BEFORE electron-builder signs+notarizes,
# so the shipped engine is correct by construction. (~2 min, zero deps.)
- name: Build llama-server (pinned deployment target)
run: |
command -v cmake >/dev/null || brew install cmake
MACOS_DEPLOYMENT_TARGET=13.0 LLAMA_REF=b9838 bash scripts/build-llama.sh
otool -l resources/bin/llama/llama-server | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print "[ci] bundled engine minos="$2; exit}'
# Compile the native meeting recorder (ScreenCaptureKit + AVFoundation) and stage
# it into resources/bin so extraResources bundles it at Contents/Resources/bin —
# the first path recorderBin() resolves in the packaged app (pro meeting-native.ts).
# Built fresh against the pinned macOS 13 target (NOT the committed dev copy), and
# signed automatically by the electron-builder pass. Without this the binary is
# never staged into the .app and packaged users hit "Meeting recorder binary not
# found" — same class of bug as the un-pinned/un-staged engine failures above.
- name: Build meeting recorder (native ScreenCaptureKit helper)
run: |
bash scripts/build-meeting-recorder.sh
mkdir -p resources/bin
cp scripts/meeting-recorder/meeting-recorder resources/bin/meeting-recorder
chmod +x resources/bin/meeting-recorder
file resources/bin/meeting-recorder
otool -l resources/bin/meeting-recorder | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print "[ci] meeting-recorder minos="$2; exit}'
# Compile the dictation push-to-talk helper (CGEventTap) and stage it into
# resources/bin so extraResources bundles it at Contents/Resources/bin — the
# path PushToTalkHotkey resolves. Self-contained: no committed binary, built
# fresh against the pinned target. If this ever fails to ship, dictation
# simply falls back to tap-to-toggle (globalShortcut), so it can't break a release.
- name: Build dictation hotkey helper (push-to-talk)
run: |
bash scripts/build-dictation-hotkey.sh
mkdir -p resources/bin
cp scripts/dictation-hotkey/dictation-hotkey resources/bin/dictation-hotkey
chmod +x resources/bin/dictation-hotkey
# Private pro source into pro/ → __OFFGRID_PRO__ true. The checkout action
# injects the token as an http.extraheader (never in a clone URL) and scrubs
# it afterwards — safer than embedding ${TOKEN} in a git URL.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: off-grid-ai/desktop-pro
token: ${{ secrets.PRO_REPO_TOKEN }}
path: pro
fetch-depth: 1
- name: Drop nested .git from pro/
run: rm -rf pro/.git # don't nest a repo inside the build tree
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
# Stamp the resolved version into package.json so electron-builder picks the
# right channel. For stable this matches the committed bump (no-op); for beta
# it applies the -beta.<run> version (no commit needed — this checkout is
# throwaway). The -beta suffix is what makes electron-builder write beta-mac.yml.
- name: Stamp build version
run: npm version "${{ needs.version.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Build (typecheck + bundle)
run: npm run build
# Same approach as mobile (.github/workflows/release.yml): collect the commits
# since the previous release tag into release-notes.md. Generated BEFORE publish
# so the tag electron-builder is about to create (v$VERSION) is not yet the
# "last tag" — git describe resolves to the PREVIOUS release. We drop the
# bot version-bump commit ([skip ci]) so notes are only real changes.
- name: Generate release notes
run: |
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
node scripts/release-notes.mjs \
"$LAST_TAG" \
"${{ needs.version.outputs.version }}" \
"${{ github.repository }}" \
> release-notes.md
cat release-notes.md
- name: Package, sign, notarize & publish (DMG + auto-update metadata)
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# notarytool needs the API key as a file; write it from the secret.
printf '%s' "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
export APPLE_API_KEY="$RUNNER_TEMP/AuthKey.p8"
# ONE signed + notarized build. The version's channel decides the feed:
# stable → latest-mac.yml, published as a normal release (everyone)
# beta → beta-mac.yml (from the -beta suffix), published as a
# PRE-RELEASE so it never becomes /releases/latest and the
# stable feed + the OffGrid-latest.dmg link stay untouched.
if [ "${{ needs.version.outputs.channel }}" = "stable" ]; then
npx electron-builder --mac \
-c.dmg.artifactName='OffGrid-${version}.${ext}' \
-c.publish.releaseType=release \
--publish always
else
npx electron-builder --mac \
-c.dmg.artifactName='OffGrid-${version}.${ext}' \
-c.publish.releaseType=prerelease \
--publish always
fi
- name: Migrate legacy Pro update channel (publish pro-mac.yml)
if: needs.version.outputs.channel == 'stable'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
# Older Pro builds poll a SEPARATE channel (pro-mac.yml); the single build
# now publishes only the default latest-mac.yml. Upload a copy as
# pro-mac.yml (identical - same signed zip + version) so those installs see
# this release, auto-update, and land on the default channel. After that
# they follow latest-mac.yml. One-time bridge so no Pro user is orphaned.
cp dist/latest-mac.yml dist/pro-mac.yml
gh release upload "v$VERSION" dist/pro-mac.yml --clobber
# Stable "always latest" download link. The versioned DMG (OffGrid-<v>.dmg)
# changes name every release, so /releases/latest/download/<name> can't target
# it. Upload a constant-named COPY so this URL is permanent across releases:
# https://github.com/off-grid-ai/off-grid-ai-desktop/releases/latest/download/OffGrid-latest.dmg
# --clobber replaces the copy from the prior run. The versioned DMG + updater
# feed (latest-mac.yml) are untouched, so auto-update is unaffected.
- name: Publish stable latest.dmg alias
if: needs.version.outputs.channel == 'stable'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
cp "dist/OffGrid-$VERSION.dmg" dist/OffGrid-latest.dmg
gh release upload "v$VERSION" dist/OffGrid-latest.dmg --clobber
# Constant nightly download link. /releases/latest EXCLUDES pre-releases, so it
# can never point at a beta build (that is exactly what keeps the STABLE link
# safe). Nightly therefore gets its own fixed-tag 'nightly' release whose dmg is
# force-replaced every beta build — a permanent, SEPARATE URL that never touches
# /releases/latest or the stable OffGrid-latest.dmg:
# https://github.com/off-grid-ai/off-grid-ai-desktop/releases/download/nightly/OffGrid-nightly.dmg
- name: Publish constant nightly.dmg link
if: needs.version.outputs.channel == 'beta'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: |
cp "dist/OffGrid-$VERSION.dmg" dist/OffGrid-nightly.dmg
# Ensure the rolling 'nightly' pre-release exists (kept separate from the
# versioned -beta.N releases electron-builder makes for the updater feed),
# then clobber its dmg so the link always serves the freshest nightly.
gh release view nightly >/dev/null 2>&1 || \
gh release create nightly --prerelease --title "Nightly (rolling)" \
--notes "Rolling nightly build, auto-updated on every change. Pre-release — expect rough edges. Stable: https://github.com/off-grid-ai/off-grid-ai-desktop/releases/latest"
gh release upload nightly dist/OffGrid-nightly.dmg --clobber
# electron-builder creates the GitHub release with empty notes; attach the
# notes generated above (mobile sets them at create time via --notes-file; the
# desktop release is created by electron-builder, so we set them after).
- name: Attach release notes
if: needs.version.outputs.channel == 'stable'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.version.outputs.version }}
run: gh release edit "v$VERSION" --notes-file release-notes.md