Release #153
Workflow file for this run
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 | |
| on: | |
| push: | |
| tags: | |
| - 'v*' | |
| workflow_dispatch: | |
| inputs: | |
| arch: | |
| description: 'Architecture to build (arm64 | x64 | both)' | |
| required: false | |
| default: 'both' | |
| dry_run: | |
| description: 'Build signed+stapled but DO NOT publish a GitHub Release (artifacts only)' | |
| required: false | |
| default: 'false' | |
| permissions: | |
| contents: read | |
| jobs: | |
| build-mac: | |
| runs-on: macos-latest | |
| steps: | |
| - name: Init flags | |
| id: init | |
| run: | | |
| set -euo pipefail | |
| # Normalize dry_run from workflow_dispatch inputs (string: true/false) | |
| DRY=${{ inputs.dry_run || '' }} | |
| if [ -z "$DRY" ]; then DRY=${{ github.event.inputs.dry_run || '' }}; fi | |
| DRY=$(printf "%s" "$DRY" | tr '[:upper:]' '[:lower:]') | |
| case "$DRY" in | |
| true|1|yes) DRY=true ;; | |
| *) DRY=false ;; | |
| esac | |
| echo "dry_run=$DRY" >> "$GITHUB_OUTPUT" | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10.28.2 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: 'pnpm' | |
| - name: Setup Python 3.11 for node-gyp | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install Python build deps (setuptools shim for distutils) | |
| run: | | |
| python -m pip install --upgrade pip setuptools wheel | |
| echo "python=$(which python3)" >> $GITHUB_ENV | |
| - name: Install dependencies (strict lockfile) | |
| env: | |
| npm_config_python: ${{ env.python }} | |
| run: pnpm install --frozen-lockfile | |
| - name: Build app (ts + vite) | |
| run: pnpm run build | |
| - name: Inject PostHog config (dev build job) | |
| env: | |
| PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} | |
| PH_HOST: ${{ secrets.POSTHOG_HOST }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${PH_KEY:-}" ] || [ -z "${PH_HOST:-}" ]; then | |
| echo "PostHog secrets not set; skipping telemetry injection (DMG will have telemetry disabled)." | |
| exit 0 | |
| fi | |
| mkdir -p dist/main | |
| cat > dist/main/appConfig.json <<'JSON' | |
| { | |
| "posthogHost": "__PH_HOST__", | |
| "posthogKey": "__PH_KEY__" | |
| } | |
| JSON | |
| sed -i '' "s#__PH_HOST__#${PH_HOST}#g" dist/main/appConfig.json | |
| sed -i '' "s#__PH_KEY__#${PH_KEY}#g" dist/main/appConfig.json | |
| echo "Wrote dist/main/appConfig.json" | |
| - name: Build DMG(s) (no publish) | |
| env: | |
| CSC_IDENTITY_AUTO_DISCOVERY: 'false' | |
| run: | | |
| set -euo pipefail | |
| ARCH_INPUT="${{ github.event.inputs.arch }}" | |
| if [ -z "$ARCH_INPUT" ] || [ "$ARCH_INPUT" = "both" ]; then | |
| ARCHS=(x64 arm64) | |
| elif [ "$ARCH_INPUT" = "arm64" ]; then | |
| ARCHS=(arm64) | |
| elif [ "$ARCH_INPUT" = "x64" ]; then | |
| ARCHS=(x64) | |
| else | |
| echo "Unknown arch input: $ARCH_INPUT" && exit 1 | |
| fi | |
| ELECTRON_VERSION=$(node -p "require('electron/package.json').version") | |
| # Build each architecture separately to ensure native modules match. | |
| # Building both at once with --x64 --arm64 would use the same node_modules/ | |
| # for both, causing architecture mismatches (issue #706). | |
| for A in "${ARCHS[@]}"; do | |
| echo "=== Building for $A ===" | |
| echo "Rebuilding native modules for $A (Electron $ELECTRON_VERSION)" | |
| npm_config_build_from_source=true pnpm exec electron-rebuild -f -a "$A" -v "$ELECTRON_VERSION" -o sqlite3,node-pty,keytar | |
| echo "Packaging $A DMG" | |
| pnpm exec electron-builder --mac dmg --$A --publish never --config.npmRebuild=false | |
| done | |
| - name: Verify native module architectures match Electron | |
| run: | | |
| set -euo pipefail | |
| # Verify that each build has native modules matching its Electron binary architecture. | |
| # This catches issues like #706 where x64 builds had arm64 native modules. | |
| VERIFIED_COUNT=0 | |
| for APP_DIR in release/mac-*/emdash.app; do | |
| [ -d "$APP_DIR" ] || continue | |
| ARCH_DIR=$(basename "$(dirname "$APP_DIR")") | |
| case "$ARCH_DIR" in | |
| mac-arm64) EXPECTED_ARCH="arm64" ;; | |
| mac-x64|mac) EXPECTED_ARCH="x86_64" ;; | |
| *) echo "Unknown arch dir: $ARCH_DIR"; continue ;; | |
| esac | |
| echo "=== Checking $APP_DIR (expected: $EXPECTED_ARCH) ===" | |
| ELECTRON_BIN="$APP_DIR/Contents/MacOS/emdash" | |
| SQLITE_NODE="$APP_DIR/Contents/Resources/app.asar.unpacked/node_modules/sqlite3/build/Release/node_sqlite3.node" | |
| # Check Electron binary architecture | |
| ELECTRON_ARCH=$(file "$ELECTRON_BIN" | grep -o 'arm64\|x86_64' | head -1) | |
| echo "Electron binary: $ELECTRON_ARCH" | |
| if [ "$ELECTRON_ARCH" != "$EXPECTED_ARCH" ]; then | |
| echo "::error::Electron arch mismatch: got $ELECTRON_ARCH, expected $EXPECTED_ARCH" | |
| exit 1 | |
| fi | |
| # Check sqlite3 native module architecture | |
| if [ -f "$SQLITE_NODE" ]; then | |
| SQLITE_ARCH=$(file "$SQLITE_NODE" | grep -o 'arm64\|x86_64' | head -1) | |
| echo "sqlite3 native module: $SQLITE_ARCH" | |
| if [ "$SQLITE_ARCH" != "$EXPECTED_ARCH" ]; then | |
| echo "::error::sqlite3 arch mismatch: got $SQLITE_ARCH, expected $EXPECTED_ARCH" | |
| exit 1 | |
| fi | |
| else | |
| echo "::warning::sqlite3 native module not found at $SQLITE_NODE" | |
| fi | |
| VERIFIED_COUNT=$((VERIFIED_COUNT + 1)) | |
| done | |
| if [ "$VERIFIED_COUNT" -eq 0 ]; then | |
| echo "::error::No app bundles found to verify" | |
| exit 1 | |
| fi | |
| echo "Verified $VERIFIED_COUNT app bundle(s)" | |
| - name: Smoke test sqlite3 in packaged app (arm64) | |
| if: ${{ github.event.inputs.arch == '' || github.event.inputs.arch == 'arm64' || github.event.inputs.arch == 'both' }} | |
| run: | | |
| set -euo pipefail | |
| APP="release/mac-arm64/emdash.app" | |
| if [ -d "$APP" ]; then | |
| echo "Requiring sqlite3 using packaged Electron (arm64)…" | |
| NODE_PATH="$APP/Contents/Resources/app.asar.unpacked/node_modules" \ | |
| ELECTRON_RUN_AS_NODE=1 "$APP/Contents/MacOS/emdash" -e "require('sqlite3'); console.log('sqlite3 OK')" | |
| else | |
| echo "Arm64 app not found at $APP" && exit 1 | |
| fi | |
| release-linux: | |
| if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| environment: release | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10.28.2 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: 'pnpm' | |
| - name: Setup Python 3.11 for node-gyp | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install Python build deps (setuptools) | |
| run: | | |
| python -m pip install --upgrade pip setuptools wheel | |
| echo "python=$(which python3)" >> $GITHUB_ENV | |
| - name: Install system build dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y build-essential pkg-config libsecret-1-dev rpm | |
| - name: Install dependencies (allow platform optional deps) | |
| env: | |
| npm_config_python: ${{ env.python }} | |
| run: | | |
| # pnpm lockfiles are cross-platform; keep lockfile strict for reproducible builds | |
| pnpm install --frozen-lockfile | |
| - name: Build app (ts + vite) | |
| run: pnpm run build | |
| - name: Inject PostHog config | |
| env: | |
| PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} | |
| PH_HOST: ${{ secrets.POSTHOG_HOST }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${PH_KEY:-}" ] || [ -z "${PH_HOST:-}" ]; then | |
| echo "PostHog secrets not set; skipping telemetry injection (app will have telemetry disabled)." | |
| exit 0 | |
| fi | |
| mkdir -p dist/main | |
| cat > dist/main/appConfig.json <<'JSON' | |
| { | |
| "posthogHost": "__PH_HOST__", | |
| "posthogKey": "__PH_KEY__" | |
| } | |
| JSON | |
| sed -i "s#__PH_HOST__#${PH_HOST}#g" dist/main/appConfig.json | |
| sed -i "s#__PH_KEY__#${PH_KEY}#g" dist/main/appConfig.json | |
| echo "Wrote dist/main/appConfig.json" | |
| - name: Rebuild native modules for Electron (x64) | |
| run: | | |
| set -euo pipefail | |
| ELECTRON_VERSION=$(node -p "require('electron/package.json').version") | |
| npm_config_build_from_source=true pnpm exec electron-rebuild -f -a x64 -v "$ELECTRON_VERSION" -o sqlite3,node-pty,keytar | |
| - name: Build Linux packages (AppImage, .deb and .rpm) | |
| run: pnpm run package:linux | |
| - name: Upload Linux packages to GitHub Release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| TAG="${GITHUB_REF_NAME}" | |
| # Create if missing, or publish if draft | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| gh release edit "$TAG" --draft=false --prerelease=false | |
| else | |
| gh release create "$TAG" --title "$TAG" --generate-notes --latest || gh release edit "$TAG" --draft=false --prerelease=false | |
| fi | |
| # Upload Linux packages and metadata | |
| FILES=(release/emdash-*.AppImage release/emdash-*.deb release/emdash-*.rpm) | |
| if [ -f release/latest-linux.yml ]; then FILES+=(release/latest-linux.yml); fi | |
| gh release upload "$TAG" "${FILES[@]}" --clobber | |
| release-win: | |
| if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' | |
| runs-on: windows-2022 | |
| permissions: | |
| contents: write | |
| environment: release | |
| steps: | |
| - name: Init flags | |
| id: init | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| DRY=${{ inputs.dry_run || '' }} | |
| if [ -z "$DRY" ]; then DRY=${{ github.event.inputs.dry_run || '' }}; fi | |
| # For branch pushes, always do a dry run to avoid creating "releases" for branch names. | |
| if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${GITHUB_REF_TYPE:-}" = "branch" ]; then | |
| DRY=true | |
| fi | |
| DRY=$(printf "%s" "$DRY" | tr '[:upper:]' '[:lower:]') | |
| case "$DRY" in | |
| true|1|yes) DRY=true ;; | |
| *) DRY=false ;; | |
| esac | |
| echo "dry_run=$DRY" >> "$GITHUB_OUTPUT" | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10.28.2 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| # Node 22 can trigger native module source builds that are flakier on Windows. | |
| # Node 20 is within engines range and closer to Electron's Node baseline. | |
| node-version: '20' | |
| cache: 'pnpm' | |
| - name: Setup Python 3.11 for node-gyp | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install Python build deps (setuptools shim for distutils) | |
| shell: bash | |
| run: | | |
| python -m pip install --upgrade pip setuptools wheel | |
| echo "python=$(which python)" >> "$GITHUB_ENV" | |
| - name: Install dependencies (strict lockfile) | |
| shell: bash | |
| env: | |
| npm_config_python: ${{ env.python }} | |
| npm_config_msvs_version: 2022 | |
| GYP_MSVS_VERSION: 2022 | |
| run: pnpm install --frozen-lockfile | |
| - name: Build app (ts + vite) | |
| shell: bash | |
| run: pnpm run build | |
| - name: Inject PostHog config | |
| shell: bash | |
| env: | |
| PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} | |
| PH_HOST: ${{ secrets.POSTHOG_HOST }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${PH_KEY:-}" ] || [ -z "${PH_HOST:-}" ]; then | |
| echo "PostHog secrets not set; skipping telemetry injection (app will have telemetry disabled)." | |
| exit 0 | |
| fi | |
| node - <<'NODE' | |
| const fs = require('node:fs'); | |
| fs.mkdirSync('dist/main', { recursive: true }); | |
| fs.writeFileSync( | |
| 'dist/main/appConfig.json', | |
| JSON.stringify({ posthogHost: process.env.PH_HOST, posthogKey: process.env.PH_KEY }, null, 2) | |
| ); | |
| console.log('Wrote dist/main/appConfig.json'); | |
| NODE | |
| - name: Check Azure Trusted Signing secrets | |
| id: signing | |
| shell: bash | |
| env: | |
| AZ_TENANT: ${{ secrets.AZURE_TENANT_ID }} | |
| AZ_CLIENT: ${{ secrets.AZURE_CLIENT_ID }} | |
| AZ_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| run: | | |
| if [ -n "$AZ_TENANT" ] && [ -n "$AZ_CLIENT" ] && [ -n "$AZ_SECRET" ]; then | |
| echo "has_signing=true" >> "$GITHUB_OUTPUT" | |
| echo "Azure Trusted Signing secrets are configured." | |
| else | |
| echo "has_signing=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::Azure Trusted Signing secrets not configured. Windows build will be unsigned." | |
| fi | |
| - name: Rebuild native modules (x64) | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| ELECTRON_VERSION=$(node -p "require('electron/package.json').version") | |
| npm_config_build_from_source=true pnpm exec electron-rebuild -f -a x64 -v "$ELECTRON_VERSION" -o sqlite3,node-pty,keytar | |
| - name: Build Windows packages (NSIS + MSI) (no publish) | |
| shell: bash | |
| env: | |
| AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| run: | | |
| set -euo pipefail | |
| pnpm exec electron-builder --win nsis msi --x64 --publish never --config.npmRebuild=false | |
| ls -lah release || true | |
| - name: Verify Windows code signature | |
| if: ${{ steps.signing.outputs.has_signing == 'true' }} | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $files = @() | |
| $files += Get-ChildItem -Path release -Filter 'emdash-*.exe' -ErrorAction SilentlyContinue | |
| $files += Get-ChildItem -Path release -Filter 'emdash-*.msi' -ErrorAction SilentlyContinue | |
| if ($files.Count -eq 0) { | |
| Write-Host "::error::No .exe or .msi files found in release/ to verify" | |
| exit 1 | |
| } | |
| $failed = $false | |
| foreach ($f in $files) { | |
| Write-Host "Verifying signature on $($f.Name)..." | |
| $sig = Get-AuthenticodeSignature -FilePath $f.FullName | |
| if ($sig.Status -ne 'Valid') { | |
| Write-Host "::error::Signature invalid on $($f.Name): $($sig.Status) - $($sig.StatusMessage)" | |
| $failed = $true | |
| } else { | |
| Write-Host " Status: $($sig.Status)" | |
| Write-Host " Subject: $($sig.SignerCertificate.Subject)" | |
| Write-Host " Issuer: $($sig.SignerCertificate.Issuer)" | |
| Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)" | |
| } | |
| } | |
| if ($failed) { exit 1 } | |
| Write-Host "All Windows installers are properly signed." | |
| - name: Publish GitHub Release and upload Windows artifacts | |
| if: ${{ steps.init.outputs.dry_run != 'true' }} | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| TAG="${GITHUB_REF_NAME}" | |
| # Create if missing, or publish if draft | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| gh release edit "$TAG" --draft=false --prerelease=false | |
| else | |
| gh release create "$TAG" --title "$TAG" --generate-notes --latest || gh release edit "$TAG" --draft=false --prerelease=false | |
| fi | |
| FILES=(release/emdash-*.exe release/emdash-*.msi) | |
| if ls release/*.blockmap >/dev/null 2>&1; then FILES+=(release/*.blockmap); fi | |
| if [ -f release/latest.yml ]; then FILES+=(release/latest.yml); fi | |
| if [ -f release/latest-win.yml ]; then FILES+=(release/latest-win.yml); fi | |
| gh release upload "$TAG" "${FILES[@]}" --clobber | |
| - name: Upload Windows artifacts (dry-run) | |
| if: ${{ steps.init.outputs.dry_run == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: WINDOWS-ARTIFACTS | |
| path: | | |
| release/emdash-*.exe | |
| release/emdash-*.msi | |
| release/*.blockmap | |
| release/latest*.yml | |
| if-no-files-found: error | |
| release-mac: | |
| # Run for tagged pushes (normal release) and for manual runs (dry run or ad-hoc release) | |
| if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' | |
| runs-on: macos-latest | |
| permissions: | |
| contents: write | |
| environment: release | |
| steps: | |
| - name: Init flags | |
| id: init | |
| run: | | |
| set -euo pipefail | |
| # Normalize dry_run from workflow_dispatch inputs (string: true/false) | |
| DRY=${{ inputs.dry_run || '' }} | |
| if [ -z "$DRY" ]; then DRY=${{ github.event.inputs.dry_run || '' }}; fi | |
| DRY=$(printf "%s" "$DRY" | tr '[:upper:]' '[:lower:]') | |
| case "$DRY" in | |
| true|1|yes) DRY=true ;; | |
| *) DRY=false ;; | |
| esac | |
| echo "dry_run=$DRY" >> "$GITHUB_OUTPUT" | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10.28.2 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: 'pnpm' | |
| - name: Setup Python 3.11 for node-gyp | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install Python build deps (setuptools shim for distutils) | |
| run: | | |
| python -m pip install --upgrade pip setuptools wheel | |
| echo "python=$(which python3)" >> $GITHUB_ENV | |
| - name: Install dependencies (strict lockfile) | |
| env: | |
| npm_config_python: ${{ env.python }} | |
| run: pnpm install --frozen-lockfile | |
| - name: Import Apple signing certificate | |
| uses: apple-actions/import-codesign-certs@v2 | |
| with: | |
| p12-file-base64: ${{ secrets.CERTIFICATE_P12 }} | |
| p12-password: ${{ secrets.CERTIFICATE_PASSWORD }} | |
| - name: Build app (ts + vite) | |
| run: pnpm run build | |
| - name: Inject PostHog config (release job) | |
| env: | |
| PH_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }} | |
| PH_HOST: ${{ secrets.POSTHOG_HOST }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${PH_KEY:-}" ] || [ -z "${PH_HOST:-}" ]; then | |
| echo "::warning::PostHog secrets not set; telemetry will be disabled in this build." | |
| exit 0 | |
| fi | |
| mkdir -p dist/main | |
| cat > dist/main/appConfig.json <<'JSON' | |
| { | |
| "posthogHost": "__PH_HOST__", | |
| "posthogKey": "__PH_KEY__" | |
| } | |
| JSON | |
| sed -i '' "s#__PH_HOST__#${PH_HOST}#g" dist/main/appConfig.json | |
| sed -i '' "s#__PH_KEY__#${PH_KEY}#g" dist/main/appConfig.json | |
| echo "Wrote dist/main/appConfig.json" | |
| - name: Check notarization secrets | |
| id: flags | |
| env: | |
| K: ${{ secrets.APPLE_API_KEY }} | |
| KID: ${{ secrets.APPLE_API_KEY_ID }} | |
| ISS: ${{ secrets.APPLE_API_ISSUER }} | |
| run: | | |
| if [ -n "$K" ] && [ -n "$KID" ] && [ -n "$ISS" ]; then | |
| echo "has_apple_api=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "has_apple_api=false" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Prepare Apple API key (if provided) | |
| if: ${{ steps.flags.outputs.has_apple_api == 'true' }} | |
| run: | | |
| echo "${{ secrets.APPLE_API_KEY }}" > ./apple_api_key.p8 | |
| echo "APPLE_API_KEY=$(pwd)/apple_api_key.p8" >> $GITHUB_ENV | |
| echo "APPLE_API_KEY_ID=${{ secrets.APPLE_API_KEY_ID }}" >> $GITHUB_ENV | |
| echo "APPLE_API_ISSUER=${{ secrets.APPLE_API_ISSUER }}" >> $GITHUB_ENV | |
| - name: Build signed DMG(s) (do not publish yet) | |
| env: | |
| CSC_IDENTITY_AUTO_DISCOVERY: 'true' | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # Guard against legacy env vars overriding cert-import flow | |
| unset CSC_LINK CSC_KEY_PASSWORD CSC_NAME | |
| # Force Apple ID notarization for the app build to avoid bad APPLE_API_KEY paths | |
| unset APPLE_API_KEY APPLE_API_KEY_ID APPLE_API_ISSUER | |
| export TEAM_ID="${APPLE_TEAM_ID}" | |
| export NOTARIZE_TEAM_ID="${APPLE_TEAM_ID}" | |
| echo "App notarization method: Apple ID (TEAM_ID=$TEAM_ID)" | |
| ARCH_INPUT="${{ github.event.inputs.arch }}" | |
| if [ -z "$ARCH_INPUT" ] || [ "$ARCH_INPUT" = "both" ]; then | |
| ARCHS=(x64 arm64) | |
| elif [ "$ARCH_INPUT" = "arm64" ]; then | |
| ARCHS=(arm64) | |
| elif [ "$ARCH_INPUT" = "x64" ]; then | |
| ARCHS=(x64) | |
| else | |
| echo "Unknown arch input: $ARCH_INPUT" && exit 1 | |
| fi | |
| ELECTRON_VERSION=$(node -p "require('electron/package.json').version") | |
| # Build each architecture separately to ensure native modules match. | |
| # Building both at once with --x64 --arm64 would use the same node_modules/ | |
| # for both, causing architecture mismatches (issue #706). | |
| for A in "${ARCHS[@]}"; do | |
| echo "=== Building for $A ===" | |
| echo "Rebuilding native modules for $A (Electron $ELECTRON_VERSION)" | |
| npm_config_build_from_source=true pnpm exec electron-rebuild -f -a "$A" -v "$ELECTRON_VERSION" -o sqlite3,node-pty,keytar | |
| echo "Packaging $A DMG and ZIP" | |
| pnpm exec electron-builder --mac dmg zip --$A --publish never --config.npmRebuild=false | |
| done | |
| - name: Verify native module architectures match Electron | |
| run: | | |
| set -euo pipefail | |
| # Verify that each build has native modules matching its Electron binary architecture. | |
| # This catches issues like #706 where x64 builds had arm64 native modules. | |
| VERIFIED_COUNT=0 | |
| for APP_DIR in release/mac-*/emdash.app; do | |
| [ -d "$APP_DIR" ] || continue | |
| ARCH_DIR=$(basename "$(dirname "$APP_DIR")") | |
| case "$ARCH_DIR" in | |
| mac-arm64) EXPECTED_ARCH="arm64" ;; | |
| mac-x64|mac) EXPECTED_ARCH="x86_64" ;; | |
| *) echo "Unknown arch dir: $ARCH_DIR"; continue ;; | |
| esac | |
| echo "=== Checking $APP_DIR (expected: $EXPECTED_ARCH) ===" | |
| ELECTRON_BIN="$APP_DIR/Contents/MacOS/emdash" | |
| SQLITE_NODE="$APP_DIR/Contents/Resources/app.asar.unpacked/node_modules/sqlite3/build/Release/node_sqlite3.node" | |
| # Check Electron binary architecture | |
| ELECTRON_ARCH=$(file "$ELECTRON_BIN" | grep -o 'arm64\|x86_64' | head -1) | |
| echo "Electron binary: $ELECTRON_ARCH" | |
| if [ "$ELECTRON_ARCH" != "$EXPECTED_ARCH" ]; then | |
| echo "::error::Electron arch mismatch: got $ELECTRON_ARCH, expected $EXPECTED_ARCH" | |
| exit 1 | |
| fi | |
| # Check sqlite3 native module architecture | |
| if [ -f "$SQLITE_NODE" ]; then | |
| SQLITE_ARCH=$(file "$SQLITE_NODE" | grep -o 'arm64\|x86_64' | head -1) | |
| echo "sqlite3 native module: $SQLITE_ARCH" | |
| if [ "$SQLITE_ARCH" != "$EXPECTED_ARCH" ]; then | |
| echo "::error::sqlite3 arch mismatch: got $SQLITE_ARCH, expected $EXPECTED_ARCH" | |
| exit 1 | |
| fi | |
| else | |
| echo "::warning::sqlite3 native module not found at $SQLITE_NODE" | |
| fi | |
| VERIFIED_COUNT=$((VERIFIED_COUNT + 1)) | |
| done | |
| if [ "$VERIFIED_COUNT" -eq 0 ]; then | |
| echo "::error::No app bundles found to verify" | |
| exit 1 | |
| fi | |
| echo "Verified $VERIFIED_COUNT app bundle(s)" | |
| - name: Smoke test sqlite3 in packaged app (arm64, signed) | |
| if: ${{ github.event.inputs.arch == '' || github.event.inputs.arch == 'arm64' || github.event.inputs.arch == 'both' }} | |
| run: | | |
| set -euo pipefail | |
| APP="release/mac-arm64/emdash.app" | |
| if [ -d "$APP" ]; then | |
| echo "Requiring sqlite3 using packaged Electron (arm64, signed)…" | |
| NODE_PATH="$APP/Contents/Resources/app.asar.unpacked/node_modules" \ | |
| ELECTRON_RUN_AS_NODE=1 "$APP/Contents/MacOS/emdash" -e "require('sqlite3'); console.log('sqlite3 OK')" | |
| else | |
| echo "Arm64 app not found at $APP" && exit 1 | |
| fi | |
| - name: Verify Developer ID signing | |
| env: | |
| EXPECT_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| FAILED=0 | |
| for APP in release/mac*/emdash.app; do | |
| if [ -d "$APP" ]; then | |
| echo "Checking codesign for $APP" | |
| META=$(codesign -dv --verbose=4 "$APP" 2>&1) | |
| echo "$META" | grep -q "Authority=Developer ID Application" || { echo "::error::Not Developer ID Application signed"; FAILED=1; } | |
| if [ -n "${EXPECT_TEAM_ID:-}" ]; then | |
| TID=$(printf "%s\n" "$META" | awk -F= '/TeamIdentifier=/{print $2; exit}') | |
| if [ "$TID" != "$EXPECT_TEAM_ID" ]; then | |
| echo "::error::TeamIdentifier mismatch (got '$TID', expected '$EXPECT_TEAM_ID')"; FAILED=1 | |
| fi | |
| fi | |
| fi | |
| done | |
| [ "$FAILED" -eq 0 ] || exit 1 | |
| - name: Verify bundle metadata and integrity (no app notarization required) | |
| run: | | |
| set -euo pipefail | |
| for APP in release/mac*/emdash.app; do | |
| if [ -d "$APP" ]; then | |
| echo "Asserting bundle ID and resources for $APP" | |
| PLIST="$APP/Contents/Info.plist" | |
| if [ ! -f "$PLIST" ]; then | |
| echo "::error::Missing Info.plist at $PLIST"; exit 1 | |
| fi | |
| # Read CFBundleIdentifier robustly (defaults can be flaky on raw files) | |
| BID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST" 2>/dev/null || true) | |
| if [ -z "$BID" ]; then | |
| BID=$(plutil -extract CFBundleIdentifier xml1 -o - "$PLIST" 2>/dev/null | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p' | head -n1) | |
| fi | |
| if [ "$BID" != "com.emdash" ]; then | |
| echo "::error::CFBundleIdentifier mismatch (got '$BID', expected 'com.emdash')"; exit 1 | |
| fi | |
| # Ensure packaged resources exist (prevents white-screen adhoc shells) | |
| if [ ! -f "$APP/Contents/Resources/app.asar" ] && [ ! -d "$APP/Contents/Resources/app" ]; then | |
| echo "::error::Missing packaged renderer resources (app.asar or Resources/app)"; exit 1 | |
| fi | |
| echo "codesign --verify --deep --strict" | |
| codesign --verify --deep --strict --verbose=2 "$APP" | |
| echo "Skip spctl on raw app (validated later via DMG end-to-end check)" | |
| fi | |
| done | |
| - name: Staple and validate artifacts (best-effort for app) | |
| run: | | |
| set -euo pipefail | |
| # Staple apps | |
| for APP in release/mac*/emdash.app; do | |
| if [ -d "$APP" ]; then | |
| echo "Attempt staple $APP (may be not notarized)"; xcrun stapler staple "$APP" || echo "App staple skipped" | |
| echo "Validate $APP (best-effort)"; xcrun stapler validate "$APP" || echo "App validate skipped" | |
| fi | |
| done | |
| # Staple DMGs | |
| for DMG in release/*.dmg; do | |
| if [ -f "$DMG" ]; then | |
| echo "Stapling $DMG (best-effort; only app must be stapled)" | |
| if ! xcrun stapler staple "$DMG"; then | |
| echo "Warning: DMG notarization ticket not found (expected if only app was notarized). Skipping." | |
| else | |
| xcrun stapler validate "$DMG" || echo "Warning: DMG validate failed; app is stapled and validated." | |
| fi | |
| fi | |
| done | |
| - name: Notarize and staple DMGs (ensures user-downloadable DMG works) | |
| if: ${{ steps.flags.outputs.has_apple_api == 'true' }} | |
| env: | |
| APPLE_API_KEY: ${{ env.APPLE_API_KEY }} | |
| APPLE_API_KEY_ID: ${{ env.APPLE_API_KEY_ID }} | |
| APPLE_API_ISSUER: ${{ env.APPLE_API_ISSUER }} | |
| run: | | |
| set -euo pipefail | |
| for DMG in release/*.dmg; do | |
| [ -f "$DMG" ] || continue | |
| echo "Submitting $DMG to Apple Notary (notarytool)..." | |
| xcrun notarytool submit "$DMG" \ | |
| --key "$APPLE_API_KEY" \ | |
| --key-id "$APPLE_API_KEY_ID" \ | |
| --issuer "$APPLE_API_ISSUER" \ | |
| --wait | |
| echo "Stapling $DMG after notarization" | |
| xcrun stapler staple -v "$DMG" | |
| xcrun stapler validate "$DMG" | |
| done | |
| - name: 'End-to-end check: app inside DMG passes Gatekeeper' | |
| run: | | |
| set -euo pipefail | |
| for DMG in release/*.dmg; do | |
| [ -f "$DMG" ] || continue | |
| MNT=$(mktemp -d) | |
| echo "Mounting $DMG at $MNT"; hdiutil attach "$DMG" -mountpoint "$MNT" -nobrowse -quiet | |
| APP="$MNT/emdash.app" | |
| if [ ! -d "$APP" ]; then | |
| echo "::error::No emdash.app found inside $DMG"; hdiutil detach "$MNT" -quiet || true; rm -rf "$MNT"; exit 1 | |
| fi | |
| echo "codesign info for app inside DMG:"; codesign -dv --verbose=4 "$APP" 2>&1 | sed -n '1,60p' | |
| echo "Gatekeeper assessment (should be accepted):"; spctl -a -vv --type execute "$APP" | |
| hdiutil detach "$MNT" -quiet || true; rm -rf "$MNT" | |
| done | |
| - name: Publish GitHub Release and upload artifacts | |
| if: ${{ steps.init.outputs.dry_run != 'true' }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| TAG="${GITHUB_REF_NAME}" | |
| # Create if missing, or publish if draft | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| gh release edit "$TAG" --draft=false --prerelease=false | |
| else | |
| gh release create "$TAG" --title "$TAG" --generate-notes --latest || gh release edit "$TAG" --draft=false --prerelease=false | |
| fi | |
| # Upload stapled DMGs, ZIPs (for auto-update), and update files | |
| FILES=(release/emdash-*.dmg release/emdash-*.zip) | |
| if ls release/*.blockmap >/dev/null 2>&1; then FILES+=(release/*.blockmap); fi | |
| if [ -f release/latest-mac.yml ]; then FILES+=(release/latest-mac.yml); fi | |
| gh release upload "$TAG" "${FILES[@]}" --clobber | |
| - name: Inspect built artifacts (dry-run) | |
| if: ${{ steps.init.outputs.dry_run == 'true' }} | |
| run: | | |
| set -euo pipefail | |
| echo "Contents of release/:" | |
| ls -lah release || true | |
| echo "Find DMGs:" | |
| find release -maxdepth 1 -type f -name 'emdash-*.dmg' -print | |
| DMG_COUNT=$(find release -maxdepth 1 -type f -name 'emdash-*.dmg' | wc -l | tr -d ' ') | |
| if [ "$DMG_COUNT" -eq 0 ]; then | |
| echo "::error::No DMG files found in release/."; exit 1 | |
| fi | |
| - name: Upload signed DMGs (dry-run) | |
| if: ${{ steps.init.outputs.dry_run == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: SIGNED-STAPLED-DMGS | |
| path: release/emdash-*.dmg | |
| if-no-files-found: error | |
| - name: Upload supplemental files (blockmaps/yml) (dry-run) | |
| if: ${{ steps.init.outputs.dry_run == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: SIGNED-STAPLED-DMGS-extras | |
| path: | | |
| release/*.blockmap | |
| release/latest-mac.yml | |
| if-no-files-found: ignore |