builder / .github /workflows /desktop-release.yml
Leon4gr45's picture
Upload folder using huggingface_hub
eeb9404 verified
Raw
History Blame Contribute Delete
13.4 kB
name: Desktop Release
# Atomic desktop release: all three platforms build first, then a single
# publish job verifies the complete artifact set and publishes the GitHub
# release in one step. If any platform fails, no release is created and
# auto-updaters keep pointing at the last complete release.
#
# The desktop shell source lives in desktop/ — this workflow contains no
# app code of its own.
on:
push:
tags:
- 'desktop-v*'
permissions:
contents: write
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: mac
- os: ubuntu-latest
platform: linux
- os: windows-latest
platform: win
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Extract version from tag
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> $GITHUB_OUTPUT
- name: Verify tag matches package.json version
shell: bash
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$PKG_VERSION" != "${{ steps.version.outputs.version }}" ]; then
echo "::error::Tag desktop-v${{ steps.version.outputs.version }} does not match package.json version $PKG_VERSION"
exit 1
fi
- name: Install dependencies
run: npm ci
- name: Build Next.js standalone (Server Mode)
env:
NEXT_PUBLIC_SERVER_MODE: 'true'
NEXT_PUBLIC_DESKTOP: 'true'
run: npm run build
- name: Assemble desktop app
shell: bash
run: ./desktop/assemble-app.sh
- name: Smoke test desktop server boot
if: matrix.platform == 'linux'
shell: bash
run: |
# Boot with the exact env contract desktop/electron/main.ts sets.
# Asserts the app serves, the desktop workspace bootstrap succeeds,
# and all writable state lands in DATA_DIR — not in the (read-only
# when installed) app directory.
export PORT=34999
export HOSTNAME=localhost
export OSW_DESKTOP=true
export DATA_DIR="$RUNNER_TEMP/osw-data"
export DEPLOYMENTS_DIR="$RUNNER_TEMP/osw-deployments"
export SESSION_SECRET=smoke-test-secret
export SECRETS_ENCRYPTION_KEY=$(head -c 32 /dev/urandom | base64)
mkdir -p "$DATA_DIR" "$DEPLOYMENTS_DIR"
cd desktop/app
node server.js &
SERVER_PID=$!
cd "$GITHUB_WORKSPACE"
for i in $(seq 1 30); do
if curl -fs -o /dev/null "http://localhost:34999/"; then break; fi
sleep 1
done
STATUS=$(curl -sSL -o /dev/null -w '%{http_code}' "http://localhost:34999/" || echo "000")
INIT_STATUS=$(curl -s -o /tmp/init1.json -w '%{http_code}' -X POST "http://localhost:34999/api/auth/desktop-init" || echo "000")
WS1=$(jq -r '.workspaceId // empty' /tmp/init1.json 2>/dev/null || true)
# Recovery path: simulate a lost workspace (e.g. database wiped by an
# old-version update while the stale reference survives) and assert
# the bootstrap re-initializes instead of returning a dead workspace.
RECOVERY="skipped"
if [ -n "$WS1" ] && [ -f "$DATA_DIR/system.sqlite" ]; then
sqlite3 "$DATA_DIR/system.sqlite" "DELETE FROM workspaces WHERE id='$WS1';"
INIT2_STATUS=$(curl -s -o /tmp/init2.json -w '%{http_code}' -X POST "http://localhost:34999/api/auth/desktop-init" || echo "000")
WS2=$(jq -r '.workspaceId // empty' /tmp/init2.json 2>/dev/null || true)
if [ "$INIT2_STATUS" = "200" ] && [ -n "$WS2" ] && [ "$WS2" != "$WS1" ]; then
RECOVERY="ok"
else
RECOVERY="failed (status=$INIT2_STATUS ws1=$WS1 ws2=$WS2)"
fi
fi
kill $SERVER_PID || true
FAIL=""
[ "$STATUS" != "200" ] && FAIL="$FAIL / returned $STATUS;"
[ "$INIT_STATUS" != "200" ] && FAIL="$FAIL desktop-init returned $INIT_STATUS;"
[ -n "$WS1" ] || FAIL="$FAIL desktop-init returned no workspaceId;"
[ "$RECOVERY" = "ok" ] || FAIL="$FAIL workspace recovery $RECOVERY;"
[ -f "$DATA_DIR/system.sqlite" ] || FAIL="$FAIL system.sqlite not created in DATA_DIR;"
[ -d "desktop/app/data" ] && FAIL="$FAIL server wrote data/ into the app directory (read-only when installed);"
if [ -n "$FAIL" ]; then
echo "::error::Desktop smoke test failed —$FAIL"
exit 1
fi
echo "Smoke test passed — server up, workspace bootstrap ok, recovery ok, data in DATA_DIR"
- name: Install desktop dependencies
shell: bash
working-directory: desktop
run: |
npm ci
npm pkg set version="${{ steps.version.outputs.version }}"
- name: Typecheck and compile Electron main process
working-directory: desktop
run: |
npx tsc --noEmit
npx tsup
- name: Bundle better-sqlite3 for Electron's ABI
working-directory: desktop
shell: bash
run: |
# The standalone's better-sqlite3 is built for the CI Node ABI, but the
# server runs in-process under Electron — swap in the official Electron
# prebuilt (electron-builder doesn't rebuild the bundled copy).
EV=$(node -p 'require("electron/package.json").version')
BS=../node_modules/better-sqlite3
DEST=app/node_modules/better-sqlite3/build/Release/better_sqlite3.node
fetch() { # <arch> <dest> — download the Electron prebuilt and place it
( cd "$BS" && rm -f build/Release/better_sqlite3.node \
&& npx --yes prebuild-install --runtime=electron --target="$EV" --arch="$1" )
cp "$BS/build/Release/better_sqlite3.node" "$2"
}
if [ "${{ matrix.platform }}" = "mac" ]; then
# The dmg is universal — fetch both slices and lipo them into one .node.
fetch arm64 /tmp/bs-arm64.node
fetch x64 /tmp/bs-x64.node
lipo -create /tmp/bs-arm64.node /tmp/bs-x64.node -output "$DEST"
else
fetch "$(node -p process.arch)" "$DEST"
fi
# Correct iff it does NOT load under system Node (Electron ABI != Node ABI).
if node -e 'process.dlopen({exports:{}}, process.argv[1])' "$DEST" 2>/dev/null; then
echo "::error::bundled better-sqlite3 loads under system Node — wrong ABI for Electron"
exit 1
fi
echo "bundled better-sqlite3 set to Electron $EV ABI"
- name: Build installer (no publish)
working-directory: desktop
run: npx electron-builder --${{ matrix.platform }} --publish never
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.platform }}
if-no-files-found: error
path: |
desktop/dist/*.dmg
desktop/dist/*.exe
desktop/dist/*.AppImage
desktop/dist/*.deb
desktop/dist/*.yml
desktop/dist/*.blockmap
# Boot the packaged AppImage on Fedora and assert workspace init succeeds.
# The build-job smoke test runs `node server.js` under the runner's system
# Node, so it never exercises native modules under Electron's ABI — this job
# runs the real binary's --self-test entry point to cover that path.
selftest-fedora:
name: Self-test (Fedora)
needs: build
runs-on: ubuntu-latest
# Pinned to the reported environment (Fedora 43). Bump or add distros later.
container: fedora:43
steps:
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
name: desktop-linux
path: dist
- name: Install Electron runtime libraries
# Electron needs an X/GTK userland even to reach app.whenReady() with no
# window. This list is the likeliest iteration point — a missing lib
# surfaces as the app failing to start under xvfb; add packages here.
run: |
dnf -y install \
nss nspr atk at-spi2-atk at-spi2-core cups-libs gtk3 \
libdrm mesa-libgbm libX11 libXcomposite libXdamage libXext \
libXfixes libXrandr libxcb libxkbcommon alsa-lib pango cairo \
libXScrnSaver xorg-x11-server-Xvfb findutils
- name: Boot AppImage headlessly and assert workspace init
run: |
set -euo pipefail
APPIMAGE=$(ls dist/*.AppImage | head -1)
echo "Testing: $APPIMAGE"
chmod +x "$APPIMAGE"
# APPIMAGE_EXTRACT_AND_RUN: no FUSE needed inside the container.
# --no-sandbox: Chromium's sandbox needs privileges the container lacks.
# --self-test: headless boot + desktop-init probe, exits 0/1 with the
# real cause printed on failure.
APPIMAGE_EXTRACT_AND_RUN=1 xvfb-run -a "$APPIMAGE" --self-test --no-sandbox
publish:
needs: [build, selftest-fedora]
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Extract version from tag
id: version
shell: bash
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Verify complete artifact set
shell: bash
run: |
cd artifacts
ls -la
MISSING=""
ls *.dmg >/dev/null 2>&1 || MISSING="$MISSING dmg"
ls *.exe >/dev/null 2>&1 || MISSING="$MISSING exe"
ls *.AppImage >/dev/null 2>&1 || MISSING="$MISSING AppImage"
ls *.deb >/dev/null 2>&1 || MISSING="$MISSING deb"
[ -f latest.yml ] || MISSING="$MISSING latest.yml"
[ -f latest-linux.yml ] || MISSING="$MISSING latest-linux.yml"
[ -f latest-mac.yml ] || MISSING="$MISSING latest-mac.yml"
if [ -n "$MISSING" ]; then
echo "::error::Incomplete artifact set — missing:$MISSING. No release will be published."
exit 1
fi
echo "All expected artifacts present."
- name: Generate release notes
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
cd artifacts
DMG=$(ls *.dmg | head -1)
EXE=$(ls *.exe | head -1)
APPIMAGE=$(ls *.AppImage | head -1)
DEB=$(ls *.deb | head -1)
DOWNLOADS="- **macOS**: ${DMG}\n- **Windows**: ${EXE}\n- **Linux**: ${APPIMAGE} or ${DEB}"
cd ..
# Extract this version's What's New entry (from "## vX" to the next "---").
# Feature releases have an entry; maintenance/patch releases do not, so
# fall back to a generic blurb rather than shipping an empty section.
awk -v ver="## v${VERSION}" '
index($0, ver) == 1 && (substr($0, length(ver)+1, 1) == " " || length($0) == length(ver)) { grab=1; next }
grab && /^---[[:space:]]*$/ { exit }
grab { print }
' docs/WHATS_NEW.md > whatsnew.md
if ! grep -q "[^[:space:]]" whatsnew.md; then
printf '%s\n' "This is a maintenance release with bug fixes and small improvements. See the [changelog](https://github.com/o-stahl/osw-studio/blob/main/CHANGELOG.md) for the full list of changes in this version." > whatsnew.md
fi
printf '%b' "$DOWNLOADS" > downloads.md
sed "s/{{VERSION}}/${VERSION}/g" .github/desktop-release-notes.md > notes.md
# Replace the {{WHATS_NEW}} and {{DOWNLOADS}} placeholders with real content.
awk '
/{{WHATS_NEW}}/ { while ((getline line < "whatsnew.md") > 0) print line; next }
/{{DOWNLOADS}}/ { while ((getline line < "downloads.md") > 0) print line; next }
{ print }
' notes.md > notes-final.md
cat notes-final.md
- name: Publish release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="desktop-v${{ steps.version.outputs.version }}"
# A leftover draft from a failed run can be replaced; a published release cannot.
if gh release view "$TAG" --json isDraft -q .isDraft 2>/dev/null | grep -q true; then
echo "Deleting leftover draft release for $TAG"
gh release delete "$TAG" --yes
elif gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::Release $TAG already exists and is published. Delete it manually to re-release."
exit 1
fi
gh release create "$TAG" \
--draft \
--title "OSW Studio v${{ steps.version.outputs.version }}" \
--notes-file notes-final.md \
artifacts/*
# Flip public only after every file is uploaded — updaters never see a partial release
gh release edit "$TAG" --draft=false
echo "Release $TAG published."