Skip to content

Instantly share code, notes, and snippets.

@EYHN
Last active May 21, 2026 19:58
Show Gist options
  • Select an option

  • Save EYHN/aba3d65fa945a79161b51e75dc323eb8 to your computer and use it in GitHub Desktop.

Select an option

Save EYHN/aba3d65fa945a79161b51e75dc323eb8 to your computer and use it in GitHub Desktop.
velopack electron workflow, with macos codesign & notarized & dmg image
import { VelopackApp } from 'velopack'
VelopackApp.build().run()
import { UpdateManager } from './update-manager'
import electron from 'electron'
const { app, ipcMain } = electron
const UPDATE_URL = 'https://s3.eyhn.in'
const updateManager = new UpdateManager(UPDATE_URL)
ipcMain.handle('update:check', () => {
return updateManager.checkForUpdates()
})
ipcMain.handle('update:apply', () => {
return updateManager.applyUpdate()
})
updateManager.on('updateStatusChanged', (status) => {
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send('update:status-changed', status)
})
})
app.whenReady().then(() => {
updateManager.checkForUpdatesInBackground()
})
name: Release Desktop
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.1.0)"
required: true
jobs:
release:
name: Build, Sign, Notarize & Release (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
permissions:
contents: write
steps:
- name: Get Version
id: get-version
shell: bash
run: |
if [ -n "${{ github.event.inputs.version }}" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
echo "version=$(echo $GITHUB_REF | sed 's/refs\/tags\/v//')" >> $GITHUB_OUTPUT
fi
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.x"
- name: Install Velopack CLI
shell: bash
run: dotnet tool install -g vpk
- name: Install Dependencies (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y squashfs-tools
- name: Install npm dependencies
shell: bash
working-directory: desktop
run: npm install
- name: Set Version
shell: bash
run: ./set-version.sh ${{ steps.get-version.outputs.version }}
# ── Signing keychain (p12 import, macOS only) ────────────────────
- name: Set up signing keychain
if: matrix.os == 'macos-latest'
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
shell: bash
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/ci-signing.keychain-db
CERT_PATH=$RUNNER_TEMP/build_certificate.p12
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security default-keychain -d user -s "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep "Developer ID Application" | head -1 \
| sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p')
echo "Signing identity: $SIGNING_IDENTITY"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV
echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> $GITHUB_ENV
# ── Notarization keychain (whole keychain restore, macOS only) ───
- name: Restore notarization keychain
if: matrix.os == 'macos-latest'
env:
KEYCHAIN_CONTENT_GZIP: ${{ secrets.KEYCHAIN_CONTENT_GZIP }}
KEYCHAIN_SECRET: ${{ secrets.KEYCHAIN_SECRET }}
shell: bash
run: |
NOTARY_KEYCHAIN=$RUNNER_TEMP/notary.keychain-db
echo -n "$KEYCHAIN_CONTENT_GZIP" | base64 -d | gzip -dc > "$NOTARY_KEYCHAIN"
chmod 600 "$NOTARY_KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_SECRET" "$NOTARY_KEYCHAIN"
security set-keychain-settings -t 3600 -u "$NOTARY_KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN_PATH" "$NOTARY_KEYCHAIN"
NOTARY_PROFILE=$(security dump-keychain "$NOTARY_KEYCHAIN" 2>/dev/null \
| grep "com.apple.gke.notary.tool.saved-creds" | head -1 \
| sed 's/.*saved-creds\.\([^"]*\).*/\1/')
echo "Notary profile: $NOTARY_PROFILE"
echo "NOTARY_KEYCHAIN=$NOTARY_KEYCHAIN" >> $GITHUB_ENV
echo "NOTARY_PROFILE=$NOTARY_PROFILE" >> $GITHUB_ENV
# ── Build ────────────────────────────────────────────────────────
- name: Build desktop app
shell: bash
working-directory: desktop
env:
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: npm run build
- name: Package desktop app (electron-builder, macOS)
if: matrix.os == 'macos-latest'
shell: bash
working-directory: desktop
run: npm run build:icon:mac && npx cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac dir --arm64
- name: Package desktop app (electron-builder, Windows)
if: matrix.os == 'windows-latest'
shell: bash
working-directory: desktop
run: npm run build:icon:win && npx cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win dir --x64
- name: Package desktop app (electron-builder, Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
working-directory: desktop
run: npm run build:icon:linux && npx cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --linux dir --x64
- name: Create Output Folder
shell: bash
run: mkdir -p desktop/out
- name: Download Old Releases
working-directory: desktop/out
shell: bash
run: |
vpk download s3 --bucket ${{ secrets.RELEASE_R2_BUCKET }} \
--endpoint ${{ secrets.RELEASE_R2_ENDPOINT }} \
--keyId ${{ secrets.RELEASE_R2_KEY_ID }} \
--secret ${{ secrets.RELEASE_R2_SECRET }}
# ── Velopack (macOS) ─────────────────────────────────────────────
- name: Create Velopack Release (macOS)
if: matrix.os == 'macos-latest'
working-directory: desktop
shell: bash
run: |
vpk pack --packId example-app \
--packVersion ${{ steps.get-version.outputs.version }} \
--icon ".generated/app-icon.icns" \
--packDir "release/mac-arm64/example app.app" \
--mainExe "example app" \
--packAuthors "example app Team" \
--packTitle "example app" \
--outputDir out/Releases
# ── Velopack (Windows) ───────────────────────────────────────────
- name: Create Velopack Release (Windows)
if: matrix.os == 'windows-latest'
working-directory: desktop
shell: bash
run: |
vpk pack --packId example-app \
--packVersion ${{ steps.get-version.outputs.version }} \
--icon ".generated/app-icon.ico" \
--packDir "release/win-unpacked" \
--mainExe "example app.exe" \
--packAuthors "example app Team" \
--packTitle "example app" \
--outputDir out/Releases
# ── Velopack (Linux) ─────────────────────────────────────────────
- name: Create Velopack Release (Linux)
if: matrix.os == 'ubuntu-latest'
working-directory: desktop
shell: bash
run: |
vpk pack --packId example-app \
--packVersion ${{ steps.get-version.outputs.version }} \
--icon ".generated/app-icon.png" \
--packDir "release/linux-unpacked" \
--mainExe "example app" \
--packAuthors "example app Team" \
--packTitle "example app" \
--outputDir out/Releases
# ── Deep codesign (macOS only) ───────────────────────────────────
- name: Deep codesign Velopack app
if: matrix.os == 'macos-latest'
working-directory: desktop
shell: bash
run: |
PORTABLE_ZIP="out/Releases/example-app-osx-Portable.zip"
SIGN_DIR=$(mktemp -d)
ditto -x -k "$PORTABLE_ZIP" "$SIGN_DIR"
APP_PATH="$SIGN_DIR/example app.app"
echo "==> Signing all Mach-O binaries inside the app bundle..."
find "$APP_PATH" -type f -not -path "*/Contents/MacOS/*" -print0 | while IFS= read -r -d '' file; do
if file "$file" | grep -qE "Mach-O"; then
codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp --keychain "$KEYCHAIN_PATH" "$file"
fi
done
find "$APP_PATH/Contents/MacOS" -type f -not -name "example app" \
-exec codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp --keychain "$KEYCHAIN_PATH" {} \;
find "$APP_PATH/Contents/Frameworks" -name "*.app" \
-exec codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp --keychain "$KEYCHAIN_PATH" --entitlements "entitlements.mac.entitlements" {} \;
find "$APP_PATH/Contents/Frameworks" -depth -name "*.framework" \
-exec codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp --keychain "$KEYCHAIN_PATH" {} \;
codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp \
--keychain "$KEYCHAIN_PATH" --entitlements "entitlements.mac.entitlements" "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
echo "==> Code signing verified"
rm -f "$PORTABLE_ZIP"
cd "$SIGN_DIR"
ditto -c -k --keepParent "example app.app" "$OLDPWD/$PORTABLE_ZIP"
cd "$OLDPWD"
echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV
echo "SIGN_DIR=$SIGN_DIR" >> $GITHUB_ENV
# ── Upload Velopack to R2 ───────────────────────────────────────
- name: Upload Velopack releases to R2
working-directory: desktop
shell: bash
run: |
vpk upload s3 --outputDir out/Releases \
--bucket ${{ secrets.RELEASE_R2_BUCKET }} \
--endpoint ${{ secrets.RELEASE_R2_ENDPOINT }} \
--keyId ${{ secrets.RELEASE_R2_KEY_ID }} \
--secret ${{ secrets.RELEASE_R2_SECRET }}
# ── Notarize (macOS only) ───────────────────────────────────────
- name: Submit for notarization
if: matrix.os == 'macos-latest'
shell: bash
run: |
NOTARIZE_ZIP=$RUNNER_TEMP/notarize-submit.zip
ditto -c -k --keepParent "$APP_PATH" "$NOTARIZE_ZIP"
echo "Submitting for notarization..."
xcrun notarytool submit "$NOTARIZE_ZIP" \
--keychain-profile "$NOTARY_PROFILE" \
--keychain "$NOTARY_KEYCHAIN" \
--wait
- name: Staple notarization ticket
if: matrix.os == 'macos-latest'
shell: bash
run: xcrun stapler staple "$APP_PATH"
- name: Verify with spctl
if: matrix.os == 'macos-latest'
shell: bash
run: |
spctl --assess --type execute --verbose=4 "$APP_PATH"
echo "spctl assessment passed"
# ── DMG (notarized, macOS only) ──────────────────────────────────
- name: Install create-dmg
if: matrix.os == 'macos-latest'
shell: bash
run: brew install create-dmg
- name: Create and upload notarized DMG
if: matrix.os == 'macos-latest'
shell: bash
env:
AWS_ACCESS_KEY_ID: ${{ secrets.RELEASE_R2_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.RELEASE_R2_SECRET }}
AWS_EC2_METADATA_DISABLED: "true"
run: |
DMG_NAME="example-app-osx.dmg"
create-dmg \
--volname "example app" \
--volicon "desktop/.generated/app-icon.icns" \
--background "desktop/resources/dmg/background@2x.png" \
--window-size 540 380 \
--icon-size 80 \
--icon "example app.app" 170 190 \
--app-drop-link 370 190 \
"$RUNNER_TEMP/$DMG_NAME" \
"$APP_PATH"
aws s3 cp "$RUNNER_TEMP/$DMG_NAME" \
"s3://${{ secrets.RELEASE_R2_BUCKET }}/$DMG_NAME" \
--endpoint-url "${{ secrets.RELEASE_R2_ENDPOINT }}"
- name: Upload notarized DMG artifact
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: notarized-dmg
path: ${{ runner.temp }}/example-app-osx.dmg
retention-days: 30
# ── Cleanup ─────────────────────────────────────────────────────
- name: Cleanup
if: always()
shell: bash
run: |
if [ "${{ matrix.os }}" = "macos-latest" ]; then
security delete-keychain "$RUNNER_TEMP/ci-signing.keychain-db" 2>/dev/null || true
security delete-keychain "$RUNNER_TEMP/notary.keychain-db" 2>/dev/null || true
rm -rf "${SIGN_DIR:-}" 2>/dev/null || true
fi
import electron from 'electron'
import { type UpdateInfo, UpdateManager as VelopackUpdateManager } from 'velopack'
import { EventEmitter } from 'events'
const { app } = electron
async function backoffRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000,
): Promise<T> {
let lastError: Error
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
if (attempt === maxRetries) {
throw lastError
}
const delay = baseDelay * Math.pow(2, attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
throw lastError!
}
export type UpdateStatus = {
unsupported: boolean
currentVersion: string
isCheckingForUpdates: boolean
isDownloadingUpdate: boolean
downloadingProgress: number
updateDownloaded: boolean
updateInfo: UpdateInfo | null
}
export interface UpdateManagerEvents {
updateStatusChanged: (status: UpdateStatus) => void
}
export class UpdateManager {
private updateManager: VelopackUpdateManager | null = null
private events = new EventEmitter()
private _status: UpdateStatus
get status() {
return this._status
}
set status(status: UpdateStatus) {
this._status = status
this.events.emit('updateStatusChanged', this.status)
}
constructor(updateUrl: string) {
try {
this.updateManager = new VelopackUpdateManager(updateUrl)
} catch (e) {
this.updateManager = null
}
this._status = {
unsupported: this.updateManager === null,
currentVersion: this.getCurrentVersion(),
isCheckingForUpdates: false,
isDownloadingUpdate: false,
downloadingProgress: 0,
updateDownloaded: false,
updateInfo: null,
}
}
getCurrentVersion() {
if (this.updateManager === null) {
return '0.0.0'
}
return this.updateManager.getCurrentVersion()
}
checkForUpdatesInBackground() {
const checkForUpdatesLoop = async () => {
if (this.updateManager === null) {
return
}
try {
const updateInfo = await this.updateManager.checkForUpdatesAsync()
if (this.status.updateInfo) {
return
}
if (updateInfo) {
this.checkForUpdates(false)
return
}
} catch (e) {}
setTimeout(checkForUpdatesLoop, 1000 * 60 * 5)
}
checkForUpdatesLoop()
}
checkForUpdates(autoDownload: boolean = true) {
if (this.updateManager === null) {
return
}
if (this.status.isCheckingForUpdates) {
return
}
this.status = {
...this.status,
isCheckingForUpdates: true,
}
backoffRetry(() => this.updateManager!.checkForUpdatesAsync(), 3)
.then((updateInfo) => {
this.status = {
...this.status,
isCheckingForUpdates: false,
updateInfo,
}
if (updateInfo && autoDownload) {
this.downloadUpdate()
}
})
.catch((e) => {
console.error(e)
this.status = {
...this.status,
isCheckingForUpdates: false,
updateInfo: null,
}
})
}
downloadUpdate() {
if (
this.updateManager === null ||
this.status.isDownloadingUpdate ||
!this.status.updateInfo
) {
return
}
this.status = {
...this.status,
isDownloadingUpdate: true,
updateDownloaded: false,
}
backoffRetry(() => {
this.status = {
...this.status,
downloadingProgress: 0,
}
return this.updateManager!.downloadUpdateAsync(
this.status.updateInfo!,
(progress) => {
this.status = {
...this.status,
downloadingProgress: progress,
}
},
)
}, 3)
.then(() => {
this.status = {
...this.status,
isDownloadingUpdate: false,
updateDownloaded: true,
}
})
.catch((e) => {
console.error(e)
this.status = {
...this.status,
isDownloadingUpdate: false,
updateDownloaded: false,
}
})
}
async applyUpdate() {
if (this.updateManager === null) {
return
}
await this.updateManager.waitExitThenApplyUpdate(this.status.updateInfo!)
app.exit()
}
on<K extends keyof UpdateManagerEvents>(
event: K,
listener: UpdateManagerEvents[K],
): this {
this.events.on(event, listener)
return this
}
off<K extends keyof UpdateManagerEvents>(
event: K,
listener: UpdateManagerEvents[K],
): this {
this.events.off(event, listener)
return this
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment