Skip to content

fix(release): embed Developer ID provisioning profile in app bundle #65

fix(release): embed Developer ID provisioning profile in app bundle

fix(release): embed Developer ID provisioning profile in app bundle #65

Workflow file for this run

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