name: Electron desktop release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
release_tag:
description: "Existing version tag to package using this workflow from main (optional)"
type: string
default: ''
publish:
description: "Publish the tagged Electron release after all platforms pass"
type: boolean
default: false
allow_unsigned:
description: "Explicitly accept unsigned/unnotarized Electron installers and documented updater limitations"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: electron-release-${{ inputs.release_tag || github.ref_name }}
cancel-in-progress: false
env:
RELEASE_REF: ${{ inputs.release_tag && format('refs/tags/{0}', inputs.release_tag) || github.ref }}
jobs:
validate:
if: github.event_name == 'workflow_dispatch' || github.ref_name != vars.TAURI_SUNSET_TAG
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.channel.outputs.channel }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ env.RELEASE_REF }}
- name: Require an exact version tag
env:
REF: ${{ env.RELEASE_REF }}
WORKFLOW_REF: ${{ github.ref }}
RELEASE_TAG_OVERRIDE: ${{ inputs.release_tag }}
ALLOW_UNSIGNED: ${{ inputs.allow_unsigned }}
DISPATCH_ACTOR: ${{ github.actor }}
RERUN_ACTOR: ${{ github.triggering_actor }}
OWNER: ${{ github.repository_owner }}
UNSIGNED_PUBLISHERS: ${{ vars.UNSIGNED_RELEASE_PUBLISHERS }}
run: |
may_publish_unsigned() {
[ -n "$1" ] || return 1
[ "$1" = "$OWNER" ] && return 0
case ",$(printf '%s' "$UNSIGNED_PUBLISHERS" | tr -d '[:space:]')," in
*",$1,"*) return 0 ;;
esac
return 1
}
if [ "$ALLOW_UNSIGNED" = true ]; then
may_publish_unsigned "$DISPATCH_ACTOR" && may_publish_unsigned "$RERUN_ACTOR" || {
echo "Only the repository owner or an account in UNSIGNED_RELEASE_PUBLISHERS may accept unsigned installers"; exit 1;
}
fi
if [ -n "$RELEASE_TAG_OVERRIDE" ]; then
test "$WORKFLOW_REF" = refs/heads/main || { echo "Tag overrides require the workflow from main"; exit 1; }
fi
VERSION=$(node -p "require('./package.json').version")
test "$REF" = "refs/tags/v$VERSION" || { echo "Select the exact version tag"; exit 1; }
test "$(git rev-parse HEAD)" = "$(git rev-parse "$REF^{commit}")" || { echo "Checkout does not match the release tag"; exit 1; }
- name: Resolve the release channel
id: channel
run: |
# stable (X.Y.Z) or beta (X.Y.Z-beta.N); any other shape fails here.
echo "channel=$(python3 scripts/prepare_electron_release.py --print-channel)" >> "$GITHUB_OUTPUT"
package:
needs: validate
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
continue-on-error: ${{ matrix.experimental == true }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux
arch: x64
target: x86_64-unknown-linux-gnu
flags: --linux --x64
- runner: windows-2022
platform: win32
arch: x64
target: x86_64-pc-windows-msvc
flags: --win --x64
- runner: windows-11-arm
platform: win32
arch: arm64
target: aarch64-pc-windows-msvc
flags: --win --arm64
experimental: true
- runner: macos-15
platform: darwin
arch: arm64
target: aarch64-apple-darwin
flags: --mac --arm64
- runner: macos-15-intel
platform: darwin
arch: x64
target: x86_64-apple-darwin
flags: --mac --x64
defaults:
run:
shell: bash
env:
VOICESTUDIO_RUST_TARGET: ${{ matrix.target }}
VOICESTUDIO_UPDATE_CHANNEL: electron-${{ needs.validate.outputs.channel }}-${{ matrix.platform }}-${{ matrix.arch }}
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
VITE_POSTHOG_KEY: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
VITE_POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ env.RELEASE_REF }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '22'
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: '1.4.2'
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0
with:
workspaces: native/desktop-bridge -> target
key: electron-${{ matrix.target }}
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e
with:
version: '0.12.13'
enable-cache: false
- name: Linux native dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libasound2-dev libxdo-dev libxtst-dev libx11-dev libxkbcommon-dev libwayland-dev libssl-dev pkg-config xvfb zsync
- name: Bundle pinned uv for the host architecture
run: |
node --input-type=module <<'NODE'
import { execFileSync } from 'node:child_process';
import { mkdirSync, copyFileSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
const expected = process.env.VOICESTUDIO_RUST_TARGET;
const targets = { 'linux-x64': 'x86_64-unknown-linux-gnu', 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc', 'darwin-arm64': 'aarch64-apple-darwin', 'darwin-x64': 'x86_64-apple-darwin' };
if (targets[`${process.platform}-${process.arch}`] !== expected) throw new Error('Runner architecture does not match package target');
const source = execFileSync(process.platform === 'win32' ? 'where.exe' : 'which', ['uv'], { encoding: 'utf8' }).trim().split(/\r?\n/)[0];
const dir = 'electron/build/uv';
mkdirSync(dir, { recursive: true });
const destination = join(dir, `uv-${expected}${process.platform === 'win32' ? '.exe' : ''}`);
copyFileSync(source, destination);
if (process.platform !== 'win32') chmodSync(destination, 0o755);
NODE
- name: Install locked dependencies
run: bun install --frozen-lockfile
- name: Validate and build Electron
run: bun run check:electron
- name: Package without publishing
env:
CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.ELECTRON_MACOS_CSC_LINK || matrix.platform == 'win32' && secrets.ELECTRON_WINDOWS_CSC_LINK || '' }}
CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.ELECTRON_MACOS_CSC_KEY_PASSWORD || matrix.platform == 'win32' && secrets.ELECTRON_WINDOWS_CSC_KEY_PASSWORD || '' }}
APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.APPLE_TEAM_ID || '' }}
PUBLISH: ${{ inputs.publish }}
ALLOW_UNSIGNED: ${{ inputs.allow_unsigned }}
working-directory: electron
run: |
if [ "${{ matrix.platform }}" = darwin ] && [ "$PUBLISH" = true ] && [ "$ALLOW_UNSIGNED" != true ]; then
if [ -z "${CSC_LINK:-}" ] || [ -z "${CSC_KEY_PASSWORD:-}" ] || [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
echo "Signed macOS publication requires Developer ID and Apple notarization credentials"; exit 1
fi
fi
# An empty CSC_LINK is interpreted as the working directory by the
# signer. Omit absent credentials rather than passing empty strings.
if [ -z "${CSC_LINK:-}" ]; then
unset CSC_LINK CSC_KEY_PASSWORD
elif [ "${{ matrix.platform }}" = darwin ]; then
# With discovery off and no identity name, electron-builder never looks in
# the keychain it imported CSC_LINK into and skips signing (v0.5.7-beta.1).
# Discovery is limited to that imported keychain, not the runner's.
export CSC_IDENTITY_AUTO_DISCOVERY=true
fi
# Apple's ticket server can drop the staple lookup after notarization
# succeeded (error 68, v0.5.7-beta.2); only that failure builds again.
set -o pipefail
attempt=1
until bun x electron-builder --config electron-builder.config.mjs ${{ matrix.flags }} --publish never 2>&1 | tee "$RUNNER_TEMP/electron-builder.log"; do
if [ "$attempt" -ge 2 ] || ! grep -q "Failed to staple" "$RUNNER_TEMP/electron-builder.log"; then exit 1; fi
attempt=$((attempt + 1))
echo "::warning::Stapling the notarization ticket failed; building again"
done
if [ "$RUNNER_OS" = Linux ]; then
node scripts/embed-appimage-update.mjs
fi
node tests/packaging-contract.mjs --artifact
node tests/update-package-contract.mjs --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
node ../scripts/create_store_artifact_manifest.mjs --platform=${{ matrix.platform }} --arch=${{ matrix.arch }}
- name: Verify macOS signing and notarization before publication
if: inputs.publish == true && inputs.allow_unsigned != true && matrix.platform == 'darwin'
run: |
APP=$(find electron/release -maxdepth 2 -name VoiceStudio.app -type d -print -quit)
test -n "$APP"
codesign --verify --deep --strict "$APP"
spctl --assess --type execute --verbose=2 "$APP"
xcrun stapler validate "$APP"
- name: Verify Windows installer signature before publication
if: inputs.publish == true && inputs.allow_unsigned != true && matrix.platform == 'win32'
shell: pwsh
run: |
$installers = @(Get-ChildItem electron/release/VoiceStudio-*.exe)
if ($installers.Count -eq 0) { throw "No installer to verify" }
foreach ($installer in $installers) {
$signature = Get-AuthenticodeSignature $installer.FullName
if ($signature.Status -ne 'Valid') { throw "Installer signature is not trusted: $($installer.Name)" }
}
- name: Report and enforce macOS signing
if: matrix.platform == 'darwin'
env:
HAS_CREDENTIALS: ${{ secrets.ELECTRON_MACOS_CSC_LINK != '' }}
run: |
APP=$(find electron/release -maxdepth 2 -name VoiceStudio.app -type d -print -quit)
test -n "$APP" || { echo "No VoiceStudio.app to verify"; exit 1; }
# An invalid bundle signature makes browser downloads open as "damaged".
codesign --verify --deep --strict "$APP" || { echo "macOS bundle signature is invalid"; exit 1; }
# stapler validate reaches Apple over the network; retry so a dropped
# connection is not reported as an unsigned app (v0.5.7-beta.2 tag run).
validate_stapled() {
for attempt in 1 2 3 4; do
xcrun stapler validate "$APP" && return 0
sleep $((attempt * 10))
done
return 1
}
if codesign -dvv "$APP" 2>&1 | grep -q '^Authority=Developer ID Application:' && validate_stapled; then
STATUS=notarized
elif [ "$HAS_CREDENTIALS" = true ]; then
echo "Developer ID credentials are configured but the app is not signed and notarized with them"; exit 1
else
STATUS=adhoc
fi
mkdir -p electron/release-signing
printf '{"platform":"darwin","arch":"%s","status":"%s"}\n' "${{ matrix.arch }}" "$STATUS" > "electron/release-signing/signing-darwin-${{ matrix.arch }}.json"
- name: Report and enforce Windows signing
if: matrix.platform == 'win32'
shell: pwsh
env:
HAS_CREDENTIALS: ${{ secrets.ELECTRON_WINDOWS_CSC_LINK != '' }}
run: |
$installers = @(Get-ChildItem electron/release/VoiceStudio-*.exe)
if ($installers.Count -eq 0) { throw "No installer to verify" }
$unsigned = @($installers | Where-Object { (Get-AuthenticodeSignature $_.FullName).Status -ne 'Valid' })
if ($unsigned.Count -eq 0) { $status = 'signed' }
elseif ($env:HAS_CREDENTIALS -eq 'true') { throw "Windows signing is configured but these installers are not validly signed: $($unsigned.Name -join ', ')" }
else { $status = 'unsigned' }
New-Item -ItemType Directory -Force electron/release-signing | Out-Null
Set-Content "electron/release-signing/signing-win32-${{ matrix.arch }}.json" ('{"platform":"win32","arch":"${{ matrix.arch }}","status":"' + $status + '"}')
- name: Report Linux signing
if: matrix.platform == 'linux'
run: |
mkdir -p electron/release-signing
printf '{"platform":"linux","arch":"%s","status":"not-applicable"}\n' "${{ matrix.arch }}" > "electron/release-signing/signing-linux-${{ matrix.arch }}.json"
- name: Save the signing report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: electron-signing-${{ matrix.platform }}-${{ matrix.arch }}
retention-days: 14
if-no-files-found: error
path: electron/release-signing/signing-*.json
- name: Packaged startup smoke test
working-directory: electron
run: |
if [ "$RUNNER_OS" = Linux ]; then
for image in release/VoiceStudio-*-linux-x64.AppImage; do
VOICESTUDIO_PACKAGED_EXE="electron/$image" xvfb-run -a node tests/packaged-smoke.mjs --setup
done
else
node tests/packaged-smoke.mjs --setup
fi
- name: Save installers and updater metadata for review
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: electron-release-${{ matrix.platform }}-${{ matrix.arch }}
retention-days: 14
if-no-files-found: error
path: |
electron/release/VoiceStudio-*
electron/release/electron-*.yml
electron/release/store-artifact-manifest-*.json
mobile:
needs: validate
uses: ./.github/workflows/mobile-release.yml
with:
release_ref: ${{ inputs.release_tag || github.ref_name }}
secrets: inherit
release:
needs: [validate, package, mobile]
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.release_tag || github.ref_name }}
SUNSET_TAG: ${{ vars.TAURI_SUNSET_TAG }}
PUBLISH: ${{ inputs.publish }}
CHANNEL: ${{ needs.validate.outputs.channel }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: ${{ env.RELEASE_REF }}
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
pattern: electron-release-*
merge-multiple: true
path: release-assets
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
pattern: VoiceStudio-*
path: phone-assets
- name: Collect phone binaries
run: |
VERSION="${TAG#v}"
cp phone-assets/VoiceStudio-android-release/VoiceStudio.apk "release-assets/VoiceStudio-${VERSION}-android.apk"
cp phone-assets/VoiceStudio-android-release/VoiceStudio.aab "release-assets/VoiceStudio-${VERSION}-android.aab"
cp phone-assets/VoiceStudio-ios-unsigned/VoiceStudio-unsigned.ipa "release-assets/VoiceStudio-${VERSION}-ios-unsigned.ipa"
- name: Validate all platforms before creating a release
run: |
python3 scripts/prepare_electron_release.py --assets release-assets --tag "$TAG" --repo "$GITHUB_REPOSITORY"
- name: Preserve the final Tauri updater feeds
if: env.CHANNEL == 'stable'
run: |
test "$SUNSET_TAG" = "v0.5.3" || { echo "TAURI_SUNSET_TAG must be v0.5.3 before releasing"; exit 1; }
# Every release carries copies pointing to immutable v0.5.3 payloads
# so archived Tauri clients never receive Electron installers.
gh release download "$SUNSET_TAG" --pattern latest.json --dir release-assets
gh release download "$SUNSET_TAG" --pattern latest-user.json --dir release-assets
python3 scripts/prepare_electron_release.py --assets release-assets --tag "$TAG" --sunset-tag "$SUNSET_TAG" --repo "$GITHUB_REPOSITORY"
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
pattern: electron-signing-*
merge-multiple: true
path: release-signing
- name: Describe installer signing per platform
run: |
gh api -H "Accept: application/vnd.github.raw" \
"repos/$GITHUB_REPOSITORY/contents/scripts/release_signing_notes.py?ref=$GITHUB_SHA" \
> "$RUNNER_TEMP/release_signing_notes.py"
# The table leads the notes so downloaders see it before the changelog.
{
python3 "$RUNNER_TEMP/release_signing_notes.py" release-signing
echo
echo '### Phone downloads'
echo
BASE="https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/VoiceStudio-${TAG#v}"
echo "Android: [signed APK](${BASE}-android.apk) · [Play bundle](${BASE}-android.aab)."
echo
echo "iPhone: [unsigned sideload IPA](${BASE}-ios-unsigned.ipa). Sign with your own Apple ID to install; this is not a TestFlight build."
echo
cat release-assets/RELEASE_NOTES.md
} > "$RUNNER_TEMP/RELEASE_NOTES.md"
mv "$RUNNER_TEMP/RELEASE_NOTES.md" release-assets/RELEASE_NOTES.md
- name: Create or update draft
run: |
# A beta is always a prerelease so GitHub never makes it `latest`.
PRERELEASE=false
if [ "$CHANNEL" = beta ]; then PRERELEASE=true; fi
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --verify-tag --draft --prerelease="$PRERELEASE" --title "$TAG — VoiceStudio" --notes-file release-assets/RELEASE_NOTES.md
fi
test "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = true || { echo "Refusing to replace a published release"; exit 1; }
gh release edit "$TAG" --prerelease="$PRERELEASE" --notes-file release-assets/RELEASE_NOTES.md
find release-assets -maxdepth 1 -type f ! -name RELEASE_NOTES.md -print0 | xargs -0 gh release upload "$TAG" --clobber
- name: Publish only when explicitly requested
if: github.event_name == 'workflow_dispatch' && inputs.publish == true
run: |
if [ "$CHANNEL" = beta ]; then
gh release edit "$TAG" --draft=false --prerelease=true --latest=false
else
gh release edit "$TAG" --draft=false --prerelease=false --latest
fi
- name: Publish release container images
if: github.event_name == 'workflow_dispatch' && inputs.publish == true
run: |
PROMOTE=false
if [ "$CHANNEL" = stable ]; then PROMOTE=true; fi
gh workflow run docker.yml --ref main \
-f backfill_release=true -f release_ref="$TAG" -f promote_stable="$PROMOTE"