Release #22
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 macOS DMG | |
| 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 Node 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - 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 | |
| env: | |
| npm_config_python: ${{ env.python }} | |
| run: npm ci | |
| - name: Build app (ts + vite) | |
| run: npm run build | |
| - name: Build DMG(s) (no publish) | |
| env: | |
| CSC_IDENTITY_AUTO_DISCOVERY: 'false' | |
| run: | | |
| ARCH_INPUT="${{ github.event.inputs.arch }}" | |
| if [ -z "$ARCH_INPUT" ] || [ "$ARCH_INPUT" = "both" ]; then | |
| FLAGS="--x64 --arm64" | |
| elif [ "$ARCH_INPUT" = "arm64" ]; then | |
| FLAGS="--arm64" | |
| elif [ "$ARCH_INPUT" = "x64" ]; then | |
| FLAGS="--x64" | |
| else | |
| echo "Unknown arch input: $ARCH_INPUT" && exit 1 | |
| fi | |
| echo "Building for: $FLAGS" | |
| npx electron-builder --mac dmg $FLAGS --publish never | |
| # Optional: upload build artifacts for debugging even if publish fails | |
| # Deliberately skip uploading debug DMGs to avoid confusion | |
| 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 Node 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - 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 | |
| env: | |
| npm_config_python: ${{ env.python }} | |
| run: npm ci | |
| - 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: npm run build | |
| - 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_API_KEY: ${{ env.APPLE_API_KEY }} | |
| APPLE_API_KEY_ID: ${{ env.APPLE_API_KEY_ID }} | |
| APPLE_API_ISSUER: ${{ env.APPLE_API_ISSUER }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| # Guard against legacy env vars overriding cert-import flow | |
| unset CSC_LINK CSC_KEY_PASSWORD CSC_NAME | |
| # Prefer API key notarization when available; otherwise Apple ID with team ID | |
| if [ -n "$APPLE_API_KEY" ] && [ -n "$APPLE_API_KEY_ID" ] && [ -n "$APPLE_API_ISSUER" ]; then | |
| echo "Using API key for notarization" | |
| unset APPLE_ID APPLE_APP_SPECIFIC_PASSWORD | |
| else | |
| echo "Using Apple ID for notarization" | |
| export TEAM_ID="${APPLE_TEAM_ID}" | |
| export NOTARIZE_TEAM_ID="${APPLE_TEAM_ID}" | |
| fi | |
| ARCH_INPUT="${{ github.event.inputs.arch }}" | |
| if [ -z "$ARCH_INPUT" ] || [ "$ARCH_INPUT" = "both" ]; then | |
| FLAGS="--x64 --arm64" | |
| elif [ "$ARCH_INPUT" = "arm64" ]; then | |
| FLAGS="--arm64" | |
| elif [ "$ARCH_INPUT" = "x64" ]; then | |
| FLAGS="--x64" | |
| else | |
| echo "Unknown arch input: $ARCH_INPUT" && exit 1 | |
| fi | |
| echo "Building signed for: $FLAGS" | |
| # Build signed + notarized artifacts locally but DO NOT publish yet. | |
| # We will staple and validate before uploading to the GitHub Release. | |
| npx electron-builder --mac dmg $FLAGS --publish never | |
| - 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 | |
| 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 "spctl assessment" | |
| spctl -a -vv --type execute "$APP" | |
| fi | |
| done | |
| - name: Staple and validate artifacts | |
| run: | | |
| set -euo pipefail | |
| # Staple apps | |
| for APP in release/mac*/emdash.app; do | |
| if [ -d "$APP" ]; then | |
| echo "Stapling $APP"; xcrun stapler staple "$APP" | |
| echo "Validate $APP"; xcrun stapler validate "$APP" | |
| 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 | |
| fi | |
| # Upload stapled DMGs and update files used for autoupdate if present | |
| FILES=(release/emdash-*.dmg) | |
| 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 |