Last active
May 21, 2026 19:58
-
-
Save EYHN/aba3d65fa945a79161b51e75dc323eb8 to your computer and use it in GitHub Desktop.
velopack electron workflow, with macos codesign & notarized & dmg image
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | |
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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