| 1 | #!/usr/bin/env bash |
| 2 | # Build, sign, notarize, and publish a Developer ID release of the native Mac |
| 3 | # app (LChatMac), then regenerate and publish the Sparkle appcast that |
| 4 | # SUFeedURL (ios/LChatMac/Info.plist, via project.yml) points members at. |
| 5 | # |
| 6 | # messenger is a PRIVATE repo, so its own GitHub Releases aren't publicly |
| 7 | # downloadable — Sparkle's background update check runs unauthenticated on |
| 8 | # members' Macs. The DMG + appcast.xml are published to a separate PUBLIC |
| 9 | # repo instead (default elevenmessenger/releases); see CLAUDE.local.md. |
| 10 | # |
| 11 | # Usage: ios/release-mac.sh <marketing-version> <build-number> |
| 12 | # ios/release-mac.sh 1.1 2 |
| 13 | # |
| 14 | # Required env: |
| 15 | # LCHAT_ASC_KEY_PATH / LCHAT_ASC_KEY_ID / LCHAT_ASC_ISSUER_ID |
| 16 | # The App Store Connect API key already used for TestFlight uploads |
| 17 | # (see CLAUDE.local.md) — notarytool accepts the same API-key auth. |
| 18 | # LCHAT_SPARKLE_KEY_FILE |
| 19 | # Path to the Sparkle EdDSA private key, exported once via |
| 20 | # `generate_keys -x` and stored in 1Password — export it to a local file |
| 21 | # before running this script (never committed, never passed on argv). |
| 22 | # Optional env: |
| 23 | # LCHAT_RELEASES_REPO Default elevenmessenger/releases. |
| 24 | # LCHAT_MAC_SIGNING_PROFILE |
| 25 | # Name of an INSTALLED Developer ID provisioning profile to export with |
| 26 | # (manual signing). Needed when no Xcode-managed "Mac Team Direct" profile |
| 27 | # is cached locally: automatic export then tries to cloud-mint one, which |
| 28 | # an API key cannot do (Developer ID profiles are Account-Holder-only), and |
| 29 | # dies with "Cloud signing permission error" / "No profiles … were found". |
| 30 | # Mint a profile via the ASC API (POST /v1/profiles, profileType |
| 31 | # MAC_APP_DIRECT), drop it in ~/Library/Developer/Xcode/UserData/ |
| 32 | # Provisioning Profiles/, and pass its Name here. |
| 33 | # |
| 34 | # Host tools: xcodebuild + notarytool/stapler (Xcode), and dmgbuild |
| 35 | # (`pip install dmgbuild`), which lays out the install window from |
| 36 | # ios/dmg/dmg-settings.py over ios/dmg/background.tiff. After editing the |
| 37 | # window design, regenerate that background: |
| 38 | # pip install pillow && python3 ios/dmg/genbg.py && \ |
| 39 | # tiffutil -cathidpicheck ios/dmg/background.png ios/dmg/background@2x.png \ |
| 40 | # -out ios/dmg/background.tiff |
| 41 | set -euo pipefail |
| 42 | |
| 43 | VERSION="${1:?usage: release-mac.sh <marketing-version> <build-number>}" |
| 44 | BUILD="${2:?usage: release-mac.sh <marketing-version> <build-number>}" |
| 45 | : "${LCHAT_ASC_KEY_PATH:?LCHAT_ASC_KEY_PATH is required (ASC API key .p8 path)}" |
| 46 | : "${LCHAT_ASC_KEY_ID:?LCHAT_ASC_KEY_ID is required}" |
| 47 | : "${LCHAT_ASC_ISSUER_ID:?LCHAT_ASC_ISSUER_ID is required}" |
| 48 | : "${LCHAT_SPARKLE_KEY_FILE:?LCHAT_SPARKLE_KEY_FILE is required (exported Sparkle EdDSA private key)}" |
| 49 | RELEASES_REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}" |
| 50 | TEAM_ID="J5NDF5PCD8" |
| 51 | APP_NAME="Eleven" |
| 52 | # Keep in sync with the `packages.Sparkle.exactVersion` pin in ios/project.yml |
| 53 | # — generate_appcast's flags are matched against this exact release's --help. |
| 54 | SPARKLE_VERSION="2.9.4" |
| 55 | |
| 56 | # Optional release notes ("what's new"), surfaced two ways. The HTML is embedded |
| 57 | # inline as the appcast item's <description> (see embed-release-notes.py) so it |
| 58 | # renders in Sparkle's in-app updater; the markdown is used as the GitHub release |
| 59 | # page body. Either var may be unset — that channel falls back to a generic |
| 60 | # one-liner. |
| 61 | NOTES_HTML="${LCHAT_RELEASE_NOTES_HTML:-}" |
| 62 | NOTES_MD="${LCHAT_RELEASE_NOTES_MD:-}" |
| 63 | command -v dmgbuild >/dev/null || { |
| 64 | echo "error: dmgbuild not found — install it with: pip install dmgbuild" >&2 |
| 65 | exit 1 |
| 66 | } |
| 67 | |
| 68 | REPO="$(cd "$(dirname "$0")/.." && pwd)" |
| 69 | IOS="$REPO/ios" |
| 70 | TMP="$(mktemp -d)" |
| 71 | trap 'rm -rf "$TMP"' EXIT |
| 72 | |
| 73 | ASC_AUTH=(-authenticationKeyPath "$LCHAT_ASC_KEY_PATH" -authenticationKeyID "$LCHAT_ASC_KEY_ID" -authenticationKeyIssuerID "$LCHAT_ASC_ISSUER_ID") |
| 74 | # notarytool takes different flag names than xcodebuild's -authenticationKey* set. |
| 75 | NOTARY_AUTH=(--key "$LCHAT_ASC_KEY_PATH" --key-id "$LCHAT_ASC_KEY_ID" --issuer "$LCHAT_ASC_ISSUER_ID") |
| 76 | |
| 77 | echo "Building $APP_NAME $VERSION ($BUILD) — Developer ID / notarized" |
| 78 | |
| 79 | cd "$IOS" |
| 80 | xcodegen generate |
| 81 | |
| 82 | xcodebuild archive \ |
| 83 | -project LChat.xcodeproj -scheme LChatMac -destination 'generic/platform=macOS' \ |
| 84 | -archivePath "$TMP/LChatMac.xcarchive" -allowProvisioningUpdates \ |
| 85 | CODE_SIGN_STYLE=Automatic MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD" |
| 86 | |
| 87 | # -exportOptionsPlist has no CLI flag form, so template a throwaway one. |
| 88 | cat > "$TMP/export-options.plist" <<PLIST |
| 89 | <?xml version="1.0" encoding="UTF-8"?> |
| 90 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 91 | <plist version="1.0"> |
| 92 | <dict> |
| 93 | <key>method</key> |
| 94 | <string>developer-id</string> |
| 95 | <key>teamID</key> |
| 96 | <string>$TEAM_ID</string> |
| 97 | $(if [ -n "${LCHAT_MAC_SIGNING_PROFILE:-}" ]; then cat <<MANUAL |
| 98 | <key>signingStyle</key> |
| 99 | <string>manual</string> |
| 100 | <key>signingCertificate</key> |
| 101 | <string>Developer ID Application</string> |
| 102 | <key>provisioningProfiles</key> |
| 103 | <dict> |
| 104 | <key>com.elevenmessenger.messenger.mac</key> |
| 105 | <string>$LCHAT_MAC_SIGNING_PROFILE</string> |
| 106 | </dict> |
| 107 | MANUAL |
| 108 | else cat <<AUTO |
| 109 | <key>signingStyle</key> |
| 110 | <string>automatic</string> |
| 111 | AUTO |
| 112 | fi) |
| 113 | </dict> |
| 114 | </plist> |
| 115 | PLIST |
| 116 | |
| 117 | # The ASC key flags are passed defensively here even though Developer ID |
| 118 | # signing itself needs no ASC auth — CLAUDE.local.md notes a stale Xcode |
| 119 | # account session can otherwise break -exportArchive outright (hit this on |
| 120 | # the TestFlight flow; same landmine could apply here). |
| 121 | xcodebuild -exportArchive -archivePath "$TMP/LChatMac.xcarchive" -exportPath "$TMP/export" \ |
| 122 | -exportOptionsPlist "$TMP/export-options.plist" -allowProvisioningUpdates "${ASC_AUTH[@]}" |
| 123 | |
| 124 | APP="$TMP/export/$APP_NAME.app" |
| 125 | |
| 126 | ditto -c -k --keepParent "$APP" "$TMP/$APP_NAME.zip" |
| 127 | echo "Notarizing $APP_NAME.app…" |
| 128 | xcrun notarytool submit "$TMP/$APP_NAME.zip" "${NOTARY_AUTH[@]}" --wait |
| 129 | xcrun stapler staple "$APP" |
| 130 | |
| 131 | # Build the install window with dmgbuild: the app icon, an Applications drop |
| 132 | # link, and a "1 … 10 →" count on a branded background (ios/dmg/). dmgbuild |
| 133 | # writes the .DS_Store layout directly, so no flaky Finder scripting is involved. |
| 134 | DMG="$TMP/$APP_NAME-$VERSION.dmg" |
| 135 | APP_PATH="$APP" BG_TIFF="$IOS/dmg/background.tiff" \ |
| 136 | dmgbuild -s "$IOS/dmg/dmg-settings.py" "$APP_NAME" "$DMG" |
| 137 | |
| 138 | # Notarize + staple the DMG too, not just the .app inside it: once a member |
| 139 | # drags the app to /Applications and ejects the DMG, that copy carries no |
| 140 | # staple of its own unless the DMG's staple already covered it — without |
| 141 | # this, first launch falls back to an online Gatekeeper check. |
| 142 | echo "Notarizing $APP_NAME-$VERSION.dmg…" |
| 143 | xcrun notarytool submit "$DMG" "${NOTARY_AUTH[@]}" --wait |
| 144 | xcrun stapler staple "$DMG" |
| 145 | |
| 146 | # generate_appcast only needs the previous appcast.xml (it preserves entries |
| 147 | # it can't find a local archive for) plus the newly built DMG — not every |
| 148 | # historical release's DMG. |
| 149 | APPCAST_DIR="$TMP/appcast" |
| 150 | mkdir -p "$APPCAST_DIR" |
| 151 | gh release download -R "$RELEASES_REPO" -p appcast.xml -D "$APPCAST_DIR" 2>/dev/null || \ |
| 152 | echo "No previous release found on $RELEASES_REPO — generating the first appcast." |
| 153 | cp "$DMG" "$APPCAST_DIR/" |
| 154 | |
| 155 | echo "Fetching Sparkle $SPARKLE_VERSION command-line tools…" |
| 156 | curl -sL -o "$TMP/sparkle-tools.tar.xz" \ |
| 157 | "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz" |
| 158 | mkdir -p "$TMP/sparkle-tools" |
| 159 | tar -xJf "$TMP/sparkle-tools.tar.xz" -C "$TMP/sparkle-tools" ./bin/generate_appcast |
| 160 | chmod +x "$TMP/sparkle-tools/bin/generate_appcast" |
| 161 | |
| 162 | "$TMP/sparkle-tools/bin/generate_appcast" \ |
| 163 | --ed-key-file "$LCHAT_SPARKLE_KEY_FILE" \ |
| 164 | --download-url-prefix "https://github.com/$RELEASES_REPO/releases/download/v$VERSION/" \ |
| 165 | --maximum-deltas 0 \ |
| 166 | "$APPCAST_DIR" |
| 167 | |
| 168 | # Embed the "what's new" HTML inline as this item's <description> so it renders |
| 169 | # in the in-app updater (see embed-release-notes.py for why inline beats a link). |
| 170 | if [ -n "$NOTES_HTML" ]; then |
| 171 | python3 "$IOS/embed-release-notes.py" "$APPCAST_DIR/appcast.xml" "$VERSION" "$NOTES_HTML" |
| 172 | fi |
| 173 | |
| 174 | # Publish the DMG + appcast; use the markdown notes (when provided) as the |
| 175 | # GitHub release page body, else a generic one-liner. |
| 176 | NOTES_ARG=(--notes "$APP_NAME $VERSION for macOS.") |
| 177 | [ -n "$NOTES_MD" ] && NOTES_ARG=(--notes-file "$NOTES_MD") |
| 178 | |
| 179 | # --latest (explicit): Sparkle's SUFeedURL is releases/latest/download/ |
| 180 | # appcast.xml, so the Mac release must always hold the repo's "latest" |
| 181 | # pointer — server releases publish with --latest=false for the same reason |
| 182 | # (release-server.sh; the pointer theft 404'd every Mac's update check). |
| 183 | # The stable-named copy gives the website a permanent one-click URL |
| 184 | # (releases/latest/download/Eleven.dmg) instead of pinning a version that |
| 185 | # goes stale; kept out of APPCAST_DIR so generate_appcast never sees a |
| 186 | # duplicate archive. |
| 187 | cp "$DMG" "$TMP/$APP_NAME.dmg" |
| 188 | gh release create "v$VERSION" -R "$RELEASES_REPO" \ |
| 189 | --title "$APP_NAME $VERSION" "${NOTES_ARG[@]}" --latest \ |
| 190 | "$APPCAST_DIR/$APP_NAME-$VERSION.dmg" "$APPCAST_DIR/appcast.xml" \ |
| 191 | "$TMP/$APP_NAME.dmg" |
| 192 | |
| 193 | echo |
| 194 | echo "✅ Published $APP_NAME $VERSION ($BUILD) to $RELEASES_REPO" |
| 195 | echo " Members on an older build pick this up next automatic check, or" |
| 196 | echo " immediately via Eleven → Check for Updates…" |
| 197 | |