#!/usr/bin/env bash # Build, sign, notarize, and publish a Developer ID release of the native Mac # app (LChatMac), then regenerate and publish the Sparkle appcast that # SUFeedURL (ios/LChatMac/Info.plist, via project.yml) points members at. # # messenger is a PRIVATE repo, so its own GitHub Releases aren't publicly # downloadable — Sparkle's background update check runs unauthenticated on # members' Macs. The DMG + appcast.xml are published to a separate PUBLIC # repo instead (default elevenmessenger/releases); see CLAUDE.local.md. # # Usage: ios/release-mac.sh # ios/release-mac.sh 1.1 2 # # Required env: # LCHAT_ASC_KEY_PATH / LCHAT_ASC_KEY_ID / LCHAT_ASC_ISSUER_ID # The App Store Connect API key already used for TestFlight uploads # (see CLAUDE.local.md) — notarytool accepts the same API-key auth. # LCHAT_SPARKLE_KEY_FILE # Path to the Sparkle EdDSA private key, exported once via # `generate_keys -x` and stored in 1Password — export it to a local file # before running this script (never committed, never passed on argv). # Optional env: # LCHAT_RELEASES_REPO Default elevenmessenger/releases. # LCHAT_MAC_SIGNING_PROFILE # Name of an INSTALLED Developer ID provisioning profile to export with # (manual signing). Needed when no Xcode-managed "Mac Team Direct" profile # is cached locally: automatic export then tries to cloud-mint one, which # an API key cannot do (Developer ID profiles are Account-Holder-only), and # dies with "Cloud signing permission error" / "No profiles … were found". # Mint a profile via the ASC API (POST /v1/profiles, profileType # MAC_APP_DIRECT), drop it in ~/Library/Developer/Xcode/UserData/ # Provisioning Profiles/, and pass its Name here. # # Host tools: xcodebuild + notarytool/stapler (Xcode), and dmgbuild # (`pip install dmgbuild`), which lays out the install window from # ios/dmg/dmg-settings.py over ios/dmg/background.tiff. After editing the # window design, regenerate that background: # pip install pillow && python3 ios/dmg/genbg.py && \ # tiffutil -cathidpicheck ios/dmg/background.png ios/dmg/background@2x.png \ # -out ios/dmg/background.tiff set -euo pipefail VERSION="${1:?usage: release-mac.sh }" BUILD="${2:?usage: release-mac.sh }" : "${LCHAT_ASC_KEY_PATH:?LCHAT_ASC_KEY_PATH is required (ASC API key .p8 path)}" : "${LCHAT_ASC_KEY_ID:?LCHAT_ASC_KEY_ID is required}" : "${LCHAT_ASC_ISSUER_ID:?LCHAT_ASC_ISSUER_ID is required}" : "${LCHAT_SPARKLE_KEY_FILE:?LCHAT_SPARKLE_KEY_FILE is required (exported Sparkle EdDSA private key)}" RELEASES_REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}" TEAM_ID="J5NDF5PCD8" APP_NAME="Eleven" # Keep in sync with the `packages.Sparkle.exactVersion` pin in ios/project.yml # — generate_appcast's flags are matched against this exact release's --help. SPARKLE_VERSION="2.9.4" # Optional release notes ("what's new"), surfaced two ways. The HTML is embedded # inline as the appcast item's (see embed-release-notes.py) so it # renders in Sparkle's in-app updater; the markdown is used as the GitHub release # page body. Either var may be unset — that channel falls back to a generic # one-liner. NOTES_HTML="${LCHAT_RELEASE_NOTES_HTML:-}" NOTES_MD="${LCHAT_RELEASE_NOTES_MD:-}" command -v dmgbuild >/dev/null || { echo "error: dmgbuild not found — install it with: pip install dmgbuild" >&2 exit 1 } REPO="$(cd "$(dirname "$0")/.." && pwd)" IOS="$REPO/ios" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT ASC_AUTH=(-authenticationKeyPath "$LCHAT_ASC_KEY_PATH" -authenticationKeyID "$LCHAT_ASC_KEY_ID" -authenticationKeyIssuerID "$LCHAT_ASC_ISSUER_ID") # notarytool takes different flag names than xcodebuild's -authenticationKey* set. NOTARY_AUTH=(--key "$LCHAT_ASC_KEY_PATH" --key-id "$LCHAT_ASC_KEY_ID" --issuer "$LCHAT_ASC_ISSUER_ID") echo "Building $APP_NAME $VERSION ($BUILD) — Developer ID / notarized" cd "$IOS" xcodegen generate xcodebuild archive \ -project LChat.xcodeproj -scheme LChatMac -destination 'generic/platform=macOS' \ -archivePath "$TMP/LChatMac.xcarchive" -allowProvisioningUpdates \ CODE_SIGN_STYLE=Automatic MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD" # -exportOptionsPlist has no CLI flag form, so template a throwaway one. cat > "$TMP/export-options.plist" < method developer-id teamID $TEAM_ID $(if [ -n "${LCHAT_MAC_SIGNING_PROFILE:-}" ]; then cat <signingStyle manual signingCertificate Developer ID Application provisioningProfiles com.elevenmessenger.messenger.mac $LCHAT_MAC_SIGNING_PROFILE MANUAL else cat <signingStyle automatic AUTO fi) PLIST # The ASC key flags are passed defensively here even though Developer ID # signing itself needs no ASC auth — CLAUDE.local.md notes a stale Xcode # account session can otherwise break -exportArchive outright (hit this on # the TestFlight flow; same landmine could apply here). xcodebuild -exportArchive -archivePath "$TMP/LChatMac.xcarchive" -exportPath "$TMP/export" \ -exportOptionsPlist "$TMP/export-options.plist" -allowProvisioningUpdates "${ASC_AUTH[@]}" APP="$TMP/export/$APP_NAME.app" ditto -c -k --keepParent "$APP" "$TMP/$APP_NAME.zip" echo "Notarizing $APP_NAME.app…" xcrun notarytool submit "$TMP/$APP_NAME.zip" "${NOTARY_AUTH[@]}" --wait xcrun stapler staple "$APP" # Build the install window with dmgbuild: the app icon, an Applications drop # link, and a "1 … 10 →" count on a branded background (ios/dmg/). dmgbuild # writes the .DS_Store layout directly, so no flaky Finder scripting is involved. DMG="$TMP/$APP_NAME-$VERSION.dmg" APP_PATH="$APP" BG_TIFF="$IOS/dmg/background.tiff" \ dmgbuild -s "$IOS/dmg/dmg-settings.py" "$APP_NAME" "$DMG" # Notarize + staple the DMG too, not just the .app inside it: once a member # drags the app to /Applications and ejects the DMG, that copy carries no # staple of its own unless the DMG's staple already covered it — without # this, first launch falls back to an online Gatekeeper check. echo "Notarizing $APP_NAME-$VERSION.dmg…" xcrun notarytool submit "$DMG" "${NOTARY_AUTH[@]}" --wait xcrun stapler staple "$DMG" # generate_appcast only needs the previous appcast.xml (it preserves entries # it can't find a local archive for) plus the newly built DMG — not every # historical release's DMG. APPCAST_DIR="$TMP/appcast" mkdir -p "$APPCAST_DIR" gh release download -R "$RELEASES_REPO" -p appcast.xml -D "$APPCAST_DIR" 2>/dev/null || \ echo "No previous release found on $RELEASES_REPO — generating the first appcast." cp "$DMG" "$APPCAST_DIR/" echo "Fetching Sparkle $SPARKLE_VERSION command-line tools…" curl -sL -o "$TMP/sparkle-tools.tar.xz" \ "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" mkdir -p "$TMP/sparkle-tools" tar -xJf "$TMP/sparkle-tools.tar.xz" -C "$TMP/sparkle-tools" ./bin/generate_appcast chmod +x "$TMP/sparkle-tools/bin/generate_appcast" "$TMP/sparkle-tools/bin/generate_appcast" \ --ed-key-file "$LCHAT_SPARKLE_KEY_FILE" \ --download-url-prefix "https://github.com/$RELEASES_REPO/releases/download/v$VERSION/" \ --maximum-deltas 0 \ "$APPCAST_DIR" # Embed the "what's new" HTML inline as this item's so it renders # in the in-app updater (see embed-release-notes.py for why inline beats a link). if [ -n "$NOTES_HTML" ]; then python3 "$IOS/embed-release-notes.py" "$APPCAST_DIR/appcast.xml" "$VERSION" "$NOTES_HTML" fi # Publish the DMG + appcast; use the markdown notes (when provided) as the # GitHub release page body, else a generic one-liner. NOTES_ARG=(--notes "$APP_NAME $VERSION for macOS.") [ -n "$NOTES_MD" ] && NOTES_ARG=(--notes-file "$NOTES_MD") # --latest (explicit): Sparkle's SUFeedURL is releases/latest/download/ # appcast.xml, so the Mac release must always hold the repo's "latest" # pointer — server releases publish with --latest=false for the same reason # (release-server.sh; the pointer theft 404'd every Mac's update check). # The stable-named copy gives the website a permanent one-click URL # (releases/latest/download/Eleven.dmg) instead of pinning a version that # goes stale; kept out of APPCAST_DIR so generate_appcast never sees a # duplicate archive. cp "$DMG" "$TMP/$APP_NAME.dmg" gh release create "v$VERSION" -R "$RELEASES_REPO" \ --title "$APP_NAME $VERSION" "${NOTES_ARG[@]}" --latest \ "$APPCAST_DIR/$APP_NAME-$VERSION.dmg" "$APPCAST_DIR/appcast.xml" \ "$TMP/$APP_NAME.dmg" echo echo "✅ Published $APP_NAME $VERSION ($BUILD) to $RELEASES_REPO" echo " Members on an older build pick this up next automatic check, or" echo " immediately via Eleven → Check for Updates…"