fix(release): embed Developer ID provisioning profile in app bundle #65
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[0-9]*' | |
| permissions: | |
| contents: write | |
| jobs: | |
| release: | |
| name: Build & Release | |
| runs-on: macos-26 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| fetch-depth: 0 | |
| - name: Set version from tag | |
| run: | | |
| VERSION="${GITHUB_REF_NAME#v}" | |
| IFS='.' read -r _MAJOR _MINOR _PATCH <<< "$VERSION" | |
| BUILD=$(( _MAJOR * 1000 + _MINOR * 100 + _PATCH )) | |
| echo "VERSION=${VERSION}" >> $GITHUB_ENV | |
| echo "BUILD=${BUILD}" >> $GITHUB_ENV | |
| echo "TAG=${GITHUB_REF_NAME}" >> $GITHUB_ENV | |
| - name: Select Xcode | |
| run: | | |
| if [ -d /Applications/Xcode.app ]; then | |
| sudo xcode-select -s /Applications/Xcode.app | |
| else | |
| XCODE_PATH=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1) | |
| echo "Using Xcode at: ${XCODE_PATH}" | |
| sudo xcode-select -s "${XCODE_PATH}" | |
| fi | |
| xcodebuild -version | |
| - name: Update version in project | |
| run: | | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${{ env.VERSION }}" DoomCoder/Info.plist | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${{ env.BUILD }}" DoomCoder/Info.plist | |
| - name: Import Developer ID certificate | |
| env: | |
| APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} | |
| run: | | |
| echo "${APPLE_CERTIFICATE}" | base64 --decode > certificate.p12 | |
| security create-keychain -p "${KEYCHAIN_PASSWORD}" build.keychain | |
| security default-keychain -s build.keychain | |
| security unlock-keychain -p "${KEYCHAIN_PASSWORD}" build.keychain | |
| security set-keychain-settings -t 7200 -u build.keychain | |
| # Import cert — allow codesign, xcodebuild, and security to use it | |
| security import certificate.p12 \ | |
| -k build.keychain \ | |
| -P "${APPLE_CERTIFICATE_PASSWORD}" \ | |
| -T /usr/bin/codesign \ | |
| -T /usr/bin/xcodebuild \ | |
| -T /usr/bin/security | |
| # Allow all Apple tools to use the key without prompts | |
| security set-key-partition-list \ | |
| -S apple-tool:,apple:,codesign: \ | |
| -s -k "${KEYCHAIN_PASSWORD}" build.keychain | |
| # Add build.keychain to the search list (default-keychain ≠ search list) | |
| security list-keychains -d user -s \ | |
| build.keychain \ | |
| ~/Library/Keychains/login.keychain-db | |
| security find-identity -v -p codesigning build.keychain | |
| - name: Install Mac Developer ID provisioning profile | |
| env: | |
| MAC_PROFILE: ${{ secrets.MAC_PROVISIONING_PROFILE }} | |
| run: | | |
| if [ -z "${MAC_PROFILE:-}" ]; then | |
| echo "::error::MAC_PROVISIONING_PROFILE secret is empty" | |
| exit 1 | |
| fi | |
| mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles | |
| PROFILE_PATH="$HOME/Library/MobileDevice/Provisioning Profiles/DoomCoder_Mac_DevID.provisionprofile" | |
| printf '%s' "$MAC_PROFILE" | base64 -D > "$PROFILE_PATH" | |
| # Save the path so later steps can embed it | |
| echo "MAC_PROFILE_PATH=$PROFILE_PATH" >> "$GITHUB_ENV" | |
| # Validate it parses + show its name | |
| echo "Installed profile:" | |
| security cms -D -i "$PROFILE_PATH" | plutil -extract Name xml1 -o - - | grep '<string>' | |
| echo "Profile platform:" | |
| security cms -D -i "$PROFILE_PATH" | plutil -extract Platform xml1 -o - - | grep '<string>' | |
| - name: Resolve Swift Packages | |
| run: | | |
| xcodebuild -resolvePackageDependencies \ | |
| -project DoomCoder.xcodeproj \ | |
| -scheme DoomCoder | |
| - name: Write App Store Connect API key | |
| id: write-asc-key | |
| env: | |
| API_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} | |
| API_KEY_ID_RAW: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} | |
| API_ISSUER_RAW: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
| run: | | |
| set -euo pipefail | |
| API_KEY_ID=$(printf '%s' "$API_KEY_ID_RAW" | tr -d '[:space:]') | |
| API_ISSUER=$(printf '%s' "$API_ISSUER_RAW" | tr -d '[:space:]') | |
| mkdir -p "$HOME/.appstoreconnect/private_keys" | |
| RAW_FILE="$RUNNER_TEMP/api_key_raw.p8" | |
| KEY_FILE="$HOME/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8" | |
| if printf '%s' "$API_KEY" | head -c 20 | grep -q "^-----BEGIN"; then | |
| printf '%s' "$API_KEY" | tr -d '\r' > "$RAW_FILE" | |
| else | |
| printf '%s' "$API_KEY" | tr -d '\r ' | base64 -D > "$RAW_FILE" | |
| fi | |
| RAW_CONTENT=$(cat "$RAW_FILE" | tr -d '\r') | |
| KEY_TYPE=$(printf '%s' "$RAW_CONTENT" | grep -o -- "-----BEGIN [^-]*-----" | head -1 | sed 's/-----BEGIN //;s/-----//') | |
| B64=$(printf '%s' "$RAW_CONTENT" | grep -v "^-----" | tr -d '\n\r ') | |
| { printf -- "-----BEGIN %s-----\n" "$KEY_TYPE" | |
| printf '%s' "$B64" | fold -w 64 | |
| printf "\n-----END %s-----\n" "$KEY_TYPE"; } > "$KEY_FILE" | |
| chmod 600 "$KEY_FILE" | |
| rm -f "$RAW_FILE" | |
| echo "key_id=${API_KEY_ID}" >> "$GITHUB_OUTPUT" | |
| echo "issuer_id=${API_ISSUER}" >> "$GITHUB_OUTPUT" | |
| echo "key_path=${KEY_FILE}" >> "$GITHUB_OUTPUT" | |
| echo "ASC API key written ($(wc -l < "$KEY_FILE") lines) for key ID: ${API_KEY_ID}" | |
| - name: Build Release Archive | |
| run: | | |
| # Archive with Automatic signing + allowProvisioningUpdates. | |
| # The API key lets Xcode auto-create/download the Mac Development | |
| # provisioning profile for iCloud/App Groups entitlements. | |
| # CODE_SIGN_IDENTITY is NOT overridden here — that conflicts with | |
| # Automatic style. The export + re-sign steps apply Developer ID. | |
| xcodebuild \ | |
| -project DoomCoder.xcodeproj \ | |
| -scheme DoomCoder \ | |
| -configuration Release \ | |
| -archivePath build/DoomCoder.xcarchive \ | |
| -allowProvisioningUpdates \ | |
| -authenticationKeyPath "${{ steps.write-asc-key.outputs.key_path }}" \ | |
| -authenticationKeyID "${{ steps.write-asc-key.outputs.key_id }}" \ | |
| -authenticationKeyIssuerID "${{ steps.write-asc-key.outputs.issuer_id }}" \ | |
| archive \ | |
| CODE_SIGN_STYLE=Automatic \ | |
| DEVELOPMENT_TEAM="${{ secrets.APPLE_TEAM_ID }}" \ | |
| MARKETING_VERSION="${{ env.VERSION }}" \ | |
| CURRENT_PROJECT_VERSION="${{ env.BUILD }}" | |
| - name: Extract app from archive | |
| run: | | |
| # Skip xcodebuild -exportArchive — it requires downloading a | |
| # Developer ID provisioning profile via cloud signing (needs Admin | |
| # role). The archive already contains the built .app; we extract it | |
| # directly and the re-sign step will apply the correct Developer ID. | |
| mkdir -p build/export | |
| APP_IN_ARCHIVE="build/DoomCoder.xcarchive/Products/Applications/DoomCoder.app" | |
| if [ ! -d "$APP_IN_ARCHIVE" ]; then | |
| echo "::error::App not found in archive at $APP_IN_ARCHIVE" | |
| ls -laR build/DoomCoder.xcarchive/Products/ || true | |
| exit 1 | |
| fi | |
| cp -R "$APP_IN_ARCHIVE" build/export/DoomCoder.app | |
| echo "✅ Extracted DoomCoder.app from archive" | |
| echo " Size: $(du -sh build/export/DoomCoder.app | cut -f1)" | |
| - name: Embed Developer ID provisioning profile | |
| run: | | |
| APP="build/export/DoomCoder.app" | |
| # Replace the auto-generated Mac Development profile (from the | |
| # archive's Automatic signing) with our Developer ID profile. | |
| # Gatekeeper verifies signing identity is listed in this profile. | |
| cp "${MAC_PROFILE_PATH}" "${APP}/Contents/embedded.provisionprofile" | |
| echo "✅ Embedded Developer ID profile" | |
| security cms -D -i "${APP}/Contents/embedded.provisionprofile" \ | |
| | plutil -extract Name xml1 -o - - | grep '<string>' | |
| - name: Re-sign all embedded code (inside-out) | |
| run: | | |
| APP="build/export/DoomCoder.app" | |
| IDENTITY="Developer ID Application" | |
| ENTS="DoomCoder/DoomCoder.Release.entitlements" | |
| # Fail loudly if the app wasn't exported where we expect | |
| if [ ! -d "$APP" ]; then | |
| echo "::error::Exported app not found at $APP — contents of build/export/:" | |
| ls -laR build/export/ || true | |
| exit 1 | |
| fi | |
| sign_file() { | |
| echo " → $(basename "$1")" | |
| codesign --force --sign "$IDENTITY" --timestamp --options runtime "$1" | |
| } | |
| # ── Pass 1: every Mach-O binary (deepest path first) ────────────── | |
| # Use process substitution (<) so the while loop runs in the CURRENT | |
| # shell — pipe-to-while runs in a subshell where errors are swallowed. | |
| echo "=== Pass 1: Mach-O binaries ===" | |
| FAIL=0 | |
| while IFS= read -r f; do | |
| if file "$f" 2>/dev/null | grep -qE "Mach-O|shared library"; then | |
| sign_file "$f" || FAIL=1 | |
| fi | |
| done < <(find "$APP" -type f \ | |
| | awk '{ printf "%d\t%s\n", gsub("/","/"), $0 }' \ | |
| | sort -rn | cut -f2-) | |
| [ "$FAIL" -eq 0 ] || { echo "::error::One or more Mach-O binaries failed to sign"; exit 1; } | |
| # ── Pass 2: XPC service bundles ──────────────────────────────────── | |
| echo "=== Pass 2: XPC service bundles ===" | |
| while IFS= read -r xpc; do | |
| sign_file "$xpc" | |
| done < <(find "$APP" -type d -name "*.xpc" \ | |
| | awk '{ printf "%d\t%s\n", gsub("/","/"), $0 }' \ | |
| | sort -rn | cut -f2-) | |
| # ── Pass 3: nested .app bundles (e.g. Sparkle's Updater.app) ────── | |
| echo "=== Pass 3: nested .app bundles ===" | |
| while IFS= read -r nested; do | |
| sign_file "$nested" | |
| done < <(find "$APP" -mindepth 2 -type d -name "*.app" \ | |
| | awk '{ printf "%d\t%s\n", gsub("/","/"), $0 }' \ | |
| | sort -rn | cut -f2-) | |
| # ── Pass 4: frameworks ───────────────────────────────────────────── | |
| echo "=== Pass 4: frameworks ===" | |
| while IFS= read -r fw; do | |
| sign_file "$fw" | |
| done < <(find "$APP" -type d -name "*.framework" \ | |
| | awk '{ printf "%d\t%s\n", gsub("/","/"), $0 }' \ | |
| | sort -rn | cut -f2-) | |
| # ── Pass 5: main app bundle (with entitlements, always last) ─────── | |
| echo "=== Pass 5: main app ===" | |
| codesign --force --sign "$IDENTITY" --timestamp --options runtime \ | |
| --entitlements "$ENTS" "$APP" | |
| echo "=== Verify bundle ===" | |
| codesign --verify --deep --strict --verbose=2 "$APP" | |
| echo "✅ Re-sign complete" | |
| - name: Audit all binary signatures | |
| run: | | |
| APP="build/export/DoomCoder.app" | |
| echo "=== Signing identity for every Mach-O in the bundle ===" | |
| find "$APP" -type f | while IFS= read -r f; do | |
| if file "$f" 2>/dev/null | grep -qE "Mach-O|shared library"; then | |
| AUTHORITY=$(codesign -dv "$f" 2>&1 | grep "^Authority=" | head -1 || echo "UNSIGNED") | |
| printf " %-60s %s\n" "$(basename "$f")" "$AUTHORITY" | |
| fi | |
| done | |
| echo "" | |
| echo "=== spctl assessment ===" | |
| # spctl may fail on CI runners — that's expected; log it for debugging only | |
| spctl --assess --type execute --verbose "$APP" 2>&1 || true | |
| - name: Notarize app | |
| env: | |
| NOTARIZE_KEY_P8: ${{ secrets.NOTARIZE_KEY_P8 }} | |
| NOTARIZE_KEY_ID: ${{ secrets.NOTARIZE_KEY_ID }} | |
| NOTARIZE_ISSUER_ID: ${{ secrets.NOTARIZE_ISSUER_ID }} | |
| run: | | |
| echo "${NOTARIZE_KEY_P8}" | base64 --decode > /tmp/AuthKey.p8 | |
| chmod 600 /tmp/AuthKey.p8 | |
| # Create the ZIP Apple requires for submission | |
| ditto -c -k --sequesterRsrc --keepParent \ | |
| "build/export/DoomCoder.app" \ | |
| "build/DoomCoder-notarize.zip" | |
| # ── Submit (no-wait — just upload and get an ID) ─────────────────── | |
| echo "Submitting to Apple notarization..." | |
| SUBMIT_JSON=$(xcrun notarytool submit "build/DoomCoder-notarize.zip" \ | |
| --key /tmp/AuthKey.p8 \ | |
| --key-id "${NOTARIZE_KEY_ID}" \ | |
| --issuer "${NOTARIZE_ISSUER_ID}" \ | |
| --no-wait \ | |
| --output-format json) | |
| echo "Submit response: ${SUBMIT_JSON}" | |
| SUBMISSION_ID=$(echo "${SUBMIT_JSON}" | \ | |
| python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") | |
| echo "Submission ID: ${SUBMISSION_ID}" | |
| # ── Poll with notarytool info (every 30 s, max 120 polls = 60 min) ── | |
| # We use short-lived `notarytool info` calls instead of the long-running | |
| # `notarytool wait`, which has hung indefinitely on macos-26 beta runners | |
| # when the underlying HTTPS connection drops mid-stream. | |
| # | |
| # NOTE: We deliberately do NOT wrap with `timeout` — that binary does not | |
| # exist on macOS (it's GNU coreutils) and silently failed every poll in | |
| # earlier runs, producing 40 consecutive `poll_error` with no diagnostics. | |
| # `notarytool info` is a short HTTPS call and returns quickly on its own. | |
| echo "Polling notarization status (up to 60 min)..." | |
| FINAL_STATUS="in_progress" | |
| for ATTEMPT in $(seq 1 120); do | |
| sleep 30 | |
| INFO_STDERR=$(mktemp) | |
| INFO_JSON=$(xcrun notarytool info "${SUBMISSION_ID}" \ | |
| --key /tmp/AuthKey.p8 \ | |
| --key-id "${NOTARIZE_KEY_ID}" \ | |
| --issuer "${NOTARIZE_ISSUER_ID}" \ | |
| --output-format json 2>"${INFO_STDERR}") || INFO_JSON='{"status":"poll_error"}' | |
| FINAL_STATUS=$(echo "${INFO_JSON}" | \ | |
| python3 -c "import json,sys; print(json.load(sys.stdin).get('status','unknown'))" \ | |
| 2>/dev/null || echo "parse_error") | |
| echo " [$(printf '%03d' $ATTEMPT)/120] $(date -u '+%H:%M:%S UTC') — ${FINAL_STATUS}" | |
| # Surface stderr from notarytool whenever the call failed so we can | |
| # actually diagnose problems (previously suppressed with 2>/dev/null). | |
| if [ "${FINAL_STATUS}" = "poll_error" ] || [ "${FINAL_STATUS}" = "parse_error" ] || [ "${FINAL_STATUS}" = "unknown" ]; then | |
| if [ -s "${INFO_STDERR}" ]; then | |
| echo " ↳ notarytool stderr:" | |
| sed 's/^/ /' "${INFO_STDERR}" | |
| fi | |
| if [ -n "${INFO_JSON}" ]; then | |
| echo " ↳ notarytool stdout: ${INFO_JSON}" | |
| fi | |
| fi | |
| rm -f "${INFO_STDERR}" | |
| case "${FINAL_STATUS}" in | |
| Accepted) | |
| echo "✅ Notarization accepted by Apple" | |
| break | |
| ;; | |
| Invalid) | |
| echo "❌ Apple rejected the submission" | |
| echo "=== Apple Notarization Log ===" | |
| xcrun notarytool log "${SUBMISSION_ID}" \ | |
| --key /tmp/AuthKey.p8 \ | |
| --key-id "${NOTARIZE_KEY_ID}" \ | |
| --issuer "${NOTARIZE_ISSUER_ID}" || true | |
| rm -f /tmp/AuthKey.p8 | |
| exit 1 | |
| ;; | |
| # "in_progress" | "poll_error" | "parse_error" → keep polling | |
| esac | |
| done | |
| # ── Final verdict ────────────────────────────────────────────────── | |
| if [ "${FINAL_STATUS}" != "Accepted" ]; then | |
| echo "::error::Notarization did not complete within 60 min. Final status: ${FINAL_STATUS}" | |
| echo "=== Apple Notarization Log ===" | |
| xcrun notarytool log "${SUBMISSION_ID}" \ | |
| --key /tmp/AuthKey.p8 \ | |
| --key-id "${NOTARIZE_KEY_ID}" \ | |
| --issuer "${NOTARIZE_ISSUER_ID}" || true | |
| rm -f /tmp/AuthKey.p8 | |
| exit 1 | |
| fi | |
| rm -f /tmp/AuthKey.p8 | |
| - name: Staple notarization ticket | |
| run: | | |
| xcrun stapler staple "build/export/DoomCoder.app" | |
| xcrun stapler validate "build/export/DoomCoder.app" | |
| echo "✅ Notarization ticket stapled" | |
| - name: Create DMG | |
| run: | | |
| brew install create-dmg --quiet 2>/dev/null || true | |
| create-dmg \ | |
| --volname "DoomCoder" \ | |
| --window-pos 200 120 \ | |
| --window-size 600 400 \ | |
| --icon-size 128 \ | |
| --icon "DoomCoder.app" 175 190 \ | |
| --hide-extension "DoomCoder.app" \ | |
| --app-drop-link 425 185 \ | |
| "DoomCoder-${{ env.VERSION }}.dmg" \ | |
| "build/export/" | |
| codesign --sign "Developer ID Application" --timestamp \ | |
| "DoomCoder-${{ env.VERSION }}.dmg" | |
| echo "✅ DMG created and signed" | |
| - name: Create distribution ZIP | |
| run: | | |
| ditto -c -k --sequesterRsrc --keepParent \ | |
| "build/export/DoomCoder.app" \ | |
| "DoomCoder-${{ env.VERSION }}.zip" | |
| ZIP_SIZE=$(stat -f%z "DoomCoder-${{ env.VERSION }}.zip") | |
| echo "ZIP_SIZE=${ZIP_SIZE}" >> $GITHUB_ENV | |
| echo "ZIP: ${ZIP_SIZE} bytes" | |
| - name: Download Sparkle tools | |
| run: | | |
| SPARKLE_VER="2.9.1" | |
| curl -sL "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VER}/Sparkle-${SPARKLE_VER}.tar.xz" \ | |
| -o sparkle.tar.xz | |
| mkdir -p sparkle-tools | |
| tar xf sparkle.tar.xz -C sparkle-tools | |
| chmod +x sparkle-tools/bin/* | |
| - name: Sign ZIP with Sparkle EdDSA | |
| env: | |
| SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} | |
| run: | | |
| SPARKLE_SIG=$(echo "${SPARKLE_PRIVATE_KEY}" | \ | |
| sparkle-tools/bin/sign_update --ed-key-file - -p \ | |
| "DoomCoder-${{ env.VERSION }}.zip") | |
| echo "Signature: ${SPARKLE_SIG}" | |
| echo "SPARKLE_SIG=${SPARKLE_SIG}" >> $GITHUB_ENV | |
| - name: Update appcast.xml | |
| run: | | |
| python3 scripts/update_appcast.py \ | |
| --version "${{ env.VERSION }}" \ | |
| --build "${{ env.BUILD }}" \ | |
| --signature "${{ env.SPARKLE_SIG }}" \ | |
| --size "${{ env.ZIP_SIZE }}" \ | |
| --download-url "https://github.com/katipally/Doom-Coder/releases/download/${{ env.TAG }}/DoomCoder-${{ env.VERSION }}.zip" | |
| - name: Commit updated appcast.xml | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| for attempt in 1 2 3 4 5; do | |
| echo "--- Commit attempt ${attempt} ---" | |
| git add -A | |
| if ! git diff --cached --quiet; then | |
| git commit -m "chore: update appcast.xml for ${{ env.TAG }}" | |
| fi | |
| git fetch origin main | |
| if ! git pull --rebase -X theirs origin main; then | |
| git rebase --abort || true | |
| git reset --hard "origin/main" | |
| python3 scripts/update_appcast.py \ | |
| --version "${{ env.VERSION }}" \ | |
| --build "${{ env.BUILD }}" \ | |
| --signature "${{ env.SPARKLE_SIG }}" \ | |
| --size "${{ env.ZIP_SIZE }}" \ | |
| --download-url "https://github.com/katipally/Doom-Coder/releases/download/${{ env.TAG }}/DoomCoder-${{ env.VERSION }}.zip" | |
| continue | |
| fi | |
| if git push origin HEAD:main; then | |
| echo "Push succeeded on attempt ${attempt}" | |
| exit 0 | |
| fi | |
| sleep $((attempt * 2)) | |
| done | |
| echo "::error::Failed to push appcast.xml after 5 attempts" | |
| exit 1 | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2.3.2 | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| with: | |
| name: "Doom Coder ${{ env.TAG }}" | |
| files: | | |
| DoomCoder-${{ env.VERSION }}.dmg | |
| DoomCoder-${{ env.VERSION }}.zip | |
| generate_release_notes: true | |
| body: | | |
| ## 📦 Installation | |
| **DMG (recommended):** | |
| 1. Download **`DoomCoder-${{ env.VERSION }}.dmg`** below | |
| 2. Open it and drag **DoomCoder** into your Applications folder | |
| 3. Launch — no Gatekeeper warnings, no "unidentified developer" prompts | |
| **ZIP:** | |
| 1. Download **`DoomCoder-${{ env.VERSION }}.zip`** | |
| 2. Unzip and move `DoomCoder.app` to `/Applications` | |
| DoomCoder is **signed with a Developer ID and notarized by Apple**. | |
| Existing users receive automatic updates via the built-in Sparkle updater. | |
| --- | |
| Full changelog: [CHANGELOG.md](https://github.com/katipally/Doom-Coder/blob/main/CHANGELOG.md) | |
| - name: Clean up keychain | |
| if: always() | |
| run: | | |
| security delete-keychain build.keychain 2>/dev/null || true | |
| rm -f certificate.p12 /tmp/AuthKey.p8 | |
| rm -rf ~/.appstoreconnect/private_keys | |