name: Desktop Native Release
"on":
workflow_dispatch:
inputs:
tag:
description: GitHub release tag to build and attach assets to
required: true
default: v0.5.0
source_ref:
description: Git ref to build the native wrapper from
required: true
default: main
release_mode:
description: package-proof allows ad-hoc/unsigned assets; public-trust requires macOS notarization and Windows signing credentials
required: true
type: choice
default: package-proof
options:
- package-proof
- public-trust
release:
types:
- published
push:
tags:
- v*
permissions:
contents: write
concurrency:
group: desktop-native-release-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
env:
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.release.tag_name || github.ref_name }}
RELEASE_MODE: ${{ github.event.inputs.release_mode || 'public-trust' }}
NODE_VERSION: 22
PEARBROWSER_MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.PEARBROWSER_MACOS_CERTIFICATE_P12_BASE64 || '' }}
PEARBROWSER_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.PEARBROWSER_MACOS_CERTIFICATE_PASSWORD || '' }}
PEARBROWSER_MACOS_KEYCHAIN_PASSWORD: ${{ secrets.PEARBROWSER_MACOS_KEYCHAIN_PASSWORD || github.run_id }}
PEARBROWSER_MACOS_SIGNING_IDENTITY: ${{ secrets.PEARBROWSER_MACOS_SIGNING_IDENTITY || '-' }}
PEARBROWSER_MACOS_SIGNING_KEYCHAIN: ${{ secrets.PEARBROWSER_MACOS_SIGNING_KEYCHAIN || '' }}
PEARBROWSER_MACOS_NOTARY_APPLE_ID: ${{ secrets.PEARBROWSER_MACOS_NOTARY_APPLE_ID || '' }}
PEARBROWSER_MACOS_NOTARY_PASSWORD: ${{ secrets.PEARBROWSER_MACOS_NOTARY_PASSWORD || '' }}
PEARBROWSER_MACOS_NOTARY_TEAM_ID: ${{ secrets.PEARBROWSER_MACOS_NOTARY_TEAM_ID || '' }}
PEARBROWSER_WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.PEARBROWSER_WINDOWS_CERTIFICATE_PFX_BASE64 || '' }}
PEARBROWSER_WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.PEARBROWSER_WINDOWS_CERTIFICATE_PASSWORD || '' }}
PEARBROWSER_WINDOWS_SIGNING_SUBJECT: ${{ secrets.PEARBROWSER_WINDOWS_SIGNING_SUBJECT || 'CN=PearBrowser Desktop' }}
PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT: ${{ secrets.PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT || '' }}
# Windows EV-equivalent via Azure Trusted Signing (key in Azure's HSM, no
# exportable .pfx). Presence of the account name turns the Azure signing step
# on; it is an alternative to the PFX/SignTool path above.
AZURE_TRUSTED_SIGNING_ACCOUNT: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT || '' }}
jobs:
build-native:
name: Build ${{ matrix.name }} appling
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS Apple Silicon
os: macos-15
platform: darwin
release-platform: macos
- name: macOS Intel
os: macos-15-intel
platform: darwin
release-platform: macos
- name: Windows
os: windows-latest
platform: win32
release-platform: windows
- name: Linux
os: ubuntu-latest
platform: linux
release-platform: linux
defaults:
run:
shell: bash
working-directory: pear-ecosystem/01-browser/pearbrowser-desktop
steps:
- name: Checkout PearBrowser desktop
uses: actions/checkout@v4
with:
ref: ${{ env.SOURCE_REF }}
path: pear-ecosystem/01-browser/pearbrowser-desktop
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Validate release mode
run: |
case "$RELEASE_MODE" in
package-proof|public-trust)
echo "release_mode=$RELEASE_MODE"
;;
*)
echo "::error::Unknown RELEASE_MODE: $RELEASE_MODE"
exit 1
;;
esac
- name: Setup Python for CMake
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install CMake 4 toolchain
run: |
python -m pip install --upgrade cmake
cmake --version
- name: Install Linux native dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libgtk-4-dev
- name: Enable Windows dependency checkout paths
if: runner.os == 'Windows'
run: git config --global core.longpaths true
- name: Add Windows SDK packaging tools to PATH
if: runner.os == 'Windows'
shell: pwsh
run: |
$sdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$sdkBin = Get-ChildItem $sdkRoot -Directory |
Sort-Object Name -Descending |
ForEach-Object { Join-Path $_.FullName 'x64' } |
Where-Object { Test-Path (Join-Path $_ 'MakeAppx.exe') } |
Select-Object -First 1
if (-not $sdkBin) {
throw 'MakeAppx.exe not found in Windows SDK'
}
Add-Content -Path $env:GITHUB_PATH -Value $sdkBin
Write-Host "Using Windows SDK tools from $sdkBin"
- name: Validate release signing configuration
run: |
set -euo pipefail
args=(--platform "$RUNNER_OS")
if [[ "$RELEASE_MODE" == "public-trust" ]]; then
args+=(--require-public-trust)
fi
node scripts/check-native-signing-credentials.mjs "${args[@]}"
- name: Import macOS signing certificate
if: runner.os == 'macOS' && env.PEARBROWSER_MACOS_CERTIFICATE_P12_BASE64 != ''
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/pearbrowser-signing.keychain-db"
cert="$RUNNER_TEMP/pearbrowser-signing.p12"
python - <<'PY'
import base64
import os
from pathlib import Path
cert = Path(os.environ["RUNNER_TEMP"]) / "pearbrowser-signing.p12"
cert.write_bytes(base64.b64decode(os.environ["PEARBROWSER_MACOS_CERTIFICATE_P12_BASE64"]))
PY
security create-keychain -p "$PEARBROWSER_MACOS_KEYCHAIN_PASSWORD" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$PEARBROWSER_MACOS_KEYCHAIN_PASSWORD" "$keychain"
security import "$cert" -P "$PEARBROWSER_MACOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$PEARBROWSER_MACOS_KEYCHAIN_PASSWORD" "$keychain"
security find-identity -v -p codesigning "$keychain"
echo "PEARBROWSER_MACOS_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV"
- name: Import Windows signing certificate
if: runner.os == 'Windows' && env.PEARBROWSER_WINDOWS_CERTIFICATE_PFX_BASE64 != ''
shell: pwsh
run: |
$pfxPath = Join-Path $env:RUNNER_TEMP 'pearbrowser-windows-signing.pfx'
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:PEARBROWSER_WINDOWS_CERTIFICATE_PFX_BASE64))
$securePassword = ConvertTo-SecureString -String $env:PEARBROWSER_WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
$cert = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $securePassword
if (-not $cert) {
throw 'Failed to import Windows signing certificate'
}
if (-not $env:PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT) {
Add-Content -Path $env:GITHUB_ENV -Value "PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT=$($cert.Thumbprint)"
}
Write-Host "Imported Windows signing certificate $($cert.Subject) $($cert.Thumbprint)"
- name: Install appling build tools
run: npm ci --prefix appling
- name: Verify appling release metadata
run: node scripts/check-appling-release.mjs --tag "$RELEASE_TAG"
- name: Generate native project
run: npm run --prefix appling generate
- name: Build native package
run: npm run --prefix appling build
# Windows EV-equivalent signing via Azure Trusted Signing. Guarded on the
# account secret. The build's local SignTool path stays skipped (no
# thumbprint), so this signs the unsigned .msix/.exe post-build. NOTE: the
# MSIX Publisher (PEARBROWSER_WINDOWS_SIGNING_SUBJECT) must equal the
# certificate subject Azure issues, or the signed package will not install.
- name: Sign Windows artifacts with Azure Trusted Signing
if: runner.os == 'Windows' && env.AZURE_TRUSTED_SIGNING_ACCOUNT != ''
uses: azure/trusted-signing-action@v0.5.1
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
trusted-signing-account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERT_PROFILE }}
files-folder: ${{ github.workspace }}/pear-ecosystem/01-browser/pearbrowser-desktop/appling/build
files-folder-filter: msix,exe
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
# Linux AppImages do not bundle GTK4 by default, so the build-machine-only
# libgtk-4 dependency makes them fail to launch on a clean distro. Use
# linuxdeploy + the GTK plugin (handles RPATH, AppRun hooks, GTK modules,
# gdk-pixbuf loaders, GSettings schemas) to repackage a self-contained image.
# NOTE: first run validates this against cmake-pear's AppDir layout.
- name: Bundle GTK4 runtime into the Linux AppImage
if: runner.os == 'Linux'
run: |
set -euo pipefail
mapfile -t product_images < <(find appling/build -maxdepth 5 -name 'PearBrowser.AppImage' -type f -not -path '*/_deps/*')
[ "${#product_images[@]}" -eq 1 ] || { echo "::error::Expected exactly one PearBrowser.AppImage, found ${#product_images[@]}"; printf '%s\n' "${product_images[@]}"; exit 1; }
rel="${product_images[0]}"
abs="$PWD/$rel"; chmod +x "$abs"
work="$RUNNER_TEMP/appimage-gtk"; rm -rf "$work"; mkdir -p "$work"
( cd "$work" && "$abs" --appimage-extract >/dev/null )
tools="$RUNNER_TEMP/ld"; mkdir -p "$tools"
curl -fsSL -o "$tools/linuxdeploy" https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
curl -fsSL -o "$tools/linuxdeploy-plugin-gtk.sh" https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh
chmod +x "$tools/linuxdeploy" "$tools/linuxdeploy-plugin-gtk.sh"
export PATH="$tools:$PATH"
( cd "$work" && linuxdeploy --appimage-extract-and-run --appdir squashfs-root --plugin gtk --output appimage )
mapfile -t bundled_images < <(find "$work" -maxdepth 1 -name '*.AppImage' -type f)
[ "${#bundled_images[@]}" -eq 1 ] || { echo "::error::Expected exactly one bundled AppImage, found ${#bundled_images[@]}"; printf '%s\n' "${bundled_images[@]}"; exit 1; }
out="${bundled_images[0]}"
mv -f "$out" "$abs"; chmod +x "$abs"
# Deterministic gate: assert libgtk-4 is now inside the image, in a clean
# container with no GTK dev libs. A broken bundle fails here instead of
# shipping an AppImage that will not start on users' machines.
- name: Verify GTK4 is bundled (clean container)
if: runner.os == 'Linux'
run: |
set -euo pipefail
mapfile -t product_images < <(find appling/build -maxdepth 5 -name 'PearBrowser.AppImage' -type f -not -path '*/_deps/*')
[ "${#product_images[@]}" -eq 1 ] || { echo "::error::Expected exactly one PearBrowser.AppImage, found ${#product_images[@]}"; printf '%s\n' "${product_images[@]}"; exit 1; }
rel="${product_images[0]}"
docker run --rm -v "$PWD:/w" -w /w ubuntu:22.04 bash -lc '
set -e
apt-get update -qq && apt-get install -y -qq libfuse2 >/dev/null
chmod +x "'"$rel"'"
"'"$rel"'" --appimage-extract >/dev/null
if ! find squashfs-root -name "libgtk-4.so*" | grep -q .; then
echo "::error::libgtk-4 is not bundled; the AppImage will not run on a clean distro"; exit 1
fi
echo "GTK4 bundled:"; find squashfs-root -name "libgtk-4.so*"
'
- name: Verify Linux AppImage metadata
if: runner.os == 'Linux'
run: npm run check:linux-appimage-metadata -- --build-dir appling/build
- name: Notarize macOS app
if: runner.os == 'macOS'
run: node scripts/notarize-appling-macos.mjs
- name: Create public-trust macOS DMG
if: runner.os == 'macOS' && env.RELEASE_MODE == 'public-trust'
run: npm run package:macos-dmg -- --tag "$RELEASE_TAG"
- name: Sign additional Windows installer artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
if (-not $env:PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT) {
Write-Host 'Skipping additional Windows installer signing; PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT is empty.'
exit 0
}
$timestampUrl = 'http://timestamp.digicert.com'
$assets = Get-ChildItem -Path appling/build -Recurse -File -Include *.exe
foreach ($asset in $assets) {
Write-Host "Signing $($asset.FullName)"
& signtool sign /sha1 $env:PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT /fd SHA256 /tr $timestampUrl /td SHA256 $asset.FullName
if ($LASTEXITCODE -ne 0) {
throw "SignTool failed for $($asset.FullName)"
}
}
- name: Verify Windows signatures when configured
if: runner.os == 'Windows'
shell: pwsh
run: |
if (-not $env:PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT) {
Write-Host 'Skipping Windows signature verification; PEARBROWSER_WINDOWS_SIGNING_THUMBPRINT is empty.'
exit 0
}
$assets = Get-ChildItem -Path appling/build -Recurse -File -Include *.msix,*.exe
if (-not $assets) {
throw 'No Windows installer artifacts found for signature verification.'
}
foreach ($asset in $assets) {
Write-Host "Verifying $($asset.FullName)"
& signtool verify /pa /v $asset.FullName
if ($LASTEXITCODE -ne 0) {
throw "SignTool verification failed for $($asset.FullName)"
}
}
- name: Collect release artifacts
run: >
node scripts/collect-appling-artifacts.mjs
--tag "$RELEASE_TAG"
--platform "${{ matrix.platform }}"
--arch "${{ runner.arch }}"
--out-dir "dist/appling-release/$RELEASE_TAG/${{ matrix.release-platform }}"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: pearbrowser-desktop-${{ env.RELEASE_TAG }}-${{ matrix.release-platform }}-${{ runner.arch }}
path: pear-ecosystem/01-browser/pearbrowser-desktop/dist/appling-release/${{ env.RELEASE_TAG }}/${{ matrix.release-platform }}/*
if-no-files-found: error
- name: Delete macOS signing keychain
if: always() && runner.os == 'macOS' && env.PEARBROWSER_MACOS_CERTIFICATE_P12_BASE64 != ''
run: security delete-keychain "$RUNNER_TEMP/pearbrowser-signing.keychain-db" || true
attach-release:
name: Attach assets to GitHub release
needs: build-native
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout release verifier
uses: actions/checkout@v4
with:
ref: ${{ env.SOURCE_REF }}
path: pear-ecosystem/01-browser/pearbrowser-desktop
- name: Validate release mode
run: |
case "$RELEASE_MODE" in
package-proof|public-trust)
echo "release_mode=$RELEASE_MODE"
;;
*)
echo "::error::Unknown RELEASE_MODE: $RELEASE_MODE"
exit 1
;;
esac
- name: Download native artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
pattern: pearbrowser-desktop-${{ env.RELEASE_TAG }}-*
merge-multiple: true
- name: Verify release asset bundle
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
ls -la release-assets
gh release view "$RELEASE_TAG" --repo "$GH_REPO" >/dev/null
shopt -s nullglob
assets=(release-assets/*)
if (( ${#assets[@]} == 0 )); then
echo "::error::No native release assets were downloaded"
exit 1
fi
for requirement in \
"macos:PearBrowser-*-macos-*.app.zip" \
"windows:PearBrowser-*-windows-*.msix" \
"linux:PearBrowser-*-linux-*.AppImage"; do
platform="${requirement%%:*}"
pattern="${requirement#*:}"
matches=(release-assets/${pattern})
if (( ${#matches[@]} == 0 )); then
echo "::error::Expected at least one ${platform} backfill artifact matching ${pattern}"
exit 1
fi
done
for platform in macos windows linux; do
sums=(release-assets/SHA256SUMS-${platform}-*.txt)
manifests=(release-assets/manifest-${platform}-*.json)
if (( ${#sums[@]} == 0 )); then
echo "::error::Expected at least one SHA256SUMS file for ${platform}"
exit 1
fi
if (( ${#manifests[@]} == 0 )); then
echo "::error::Expected at least one manifest file for ${platform}"
exit 1
fi
done
for asset in "${assets[@]}"; do
case "$(basename "$asset")" in
*.sha256|SHA256SUMS-*|manifest-*) continue ;;
esac
if [[ ! -f "${asset}.sha256" ]]; then
echo "::error::Missing SHA-256 sidecar for ${asset}"
exit 1
fi
done
- name: Attach assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
shopt -s nullglob
assets=(release-assets/*)
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "$GH_REPO" --clobber
- name: Verify attached release assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
args=(--tag "$RELEASE_TAG" --repo "$GH_REPO" --require-backfill-formats)
if [[ "$RELEASE_MODE" == "public-trust" ]]; then
args+=(--require-published --require-public-trust)
fi
node pear-ecosystem/01-browser/pearbrowser-desktop/scripts/check-native-release-assets.mjs "${args[@]}"
- name: Verify public-trust release downloads
if: env.RELEASE_MODE == 'public-trust'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: >
node pear-ecosystem/01-browser/pearbrowser-desktop/scripts/verify-native-downloads.mjs
--tag "$RELEASE_TAG"
--repo "$GH_REPO"
--all