OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / .github / workflows / desktop-native-release.yml

469 lines · yaml ref:
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