eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
41set -euo pipefail
42
43VERSION="${1:?usage: release-mac.sh <marketing-version> <build-number>}"
44BUILD="${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)}"
49RELEASES_REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}"
50TEAM_ID="J5NDF5PCD8"
51APP_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.
54SPARKLE_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.
61NOTES_HTML="${LCHAT_RELEASE_NOTES_HTML:-}"
62NOTES_MD="${LCHAT_RELEASE_NOTES_MD:-}"
63command -v dmgbuild >/dev/null || {
64 echo "error: dmgbuild not found — install it with: pip install dmgbuild" >&2
65 exit 1
66}
67
68REPO="$(cd "$(dirname "$0")/.." && pwd)"
69IOS="$REPO/ios"
70TMP="$(mktemp -d)"
71trap 'rm -rf "$TMP"' EXIT
72
73ASC_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.
75NOTARY_AUTH=(--key "$LCHAT_ASC_KEY_PATH" --key-id "$LCHAT_ASC_KEY_ID" --issuer "$LCHAT_ASC_ISSUER_ID")
76
77echo "Building $APP_NAME $VERSION ($BUILD) — Developer ID / notarized"
78
79cd "$IOS"
80xcodegen generate
81
82xcodebuild 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.
88cat > "$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>
107MANUAL
108else cat <<AUTO
109 <key>signingStyle</key>
110 <string>automatic</string>
111AUTO
112fi)
113</dict>
114</plist>
115PLIST
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).
121xcodebuild -exportArchive -archivePath "$TMP/LChatMac.xcarchive" -exportPath "$TMP/export" \
122 -exportOptionsPlist "$TMP/export-options.plist" -allowProvisioningUpdates "${ASC_AUTH[@]}"
123
124APP="$TMP/export/$APP_NAME.app"
125
126ditto -c -k --keepParent "$APP" "$TMP/$APP_NAME.zip"
127echo "Notarizing $APP_NAME.app…"
128xcrun notarytool submit "$TMP/$APP_NAME.zip" "${NOTARY_AUTH[@]}" --wait
129xcrun 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.
134DMG="$TMP/$APP_NAME-$VERSION.dmg"
135APP_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.
142echo "Notarizing $APP_NAME-$VERSION.dmg…"
143xcrun notarytool submit "$DMG" "${NOTARY_AUTH[@]}" --wait
144xcrun 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.
149APPCAST_DIR="$TMP/appcast"
150mkdir -p "$APPCAST_DIR"
151gh 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."
153cp "$DMG" "$APPCAST_DIR/"
154
155echo "Fetching Sparkle $SPARKLE_VERSION command-line tools…"
156curl -sL -o "$TMP/sparkle-tools.tar.xz" \
157 "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz"
158mkdir -p "$TMP/sparkle-tools"
159tar -xJf "$TMP/sparkle-tools.tar.xz" -C "$TMP/sparkle-tools" ./bin/generate_appcast
160chmod +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).
170if [ -n "$NOTES_HTML" ]; then
171 python3 "$IOS/embed-release-notes.py" "$APPCAST_DIR/appcast.xml" "$VERSION" "$NOTES_HTML"
172fi
173
174# Publish the DMG + appcast; use the markdown notes (when provided) as the
175# GitHub release page body, else a generic one-liner.
176NOTES_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.
187cp "$DMG" "$TMP/$APP_NAME.dmg"
188gh 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
193echo
194echo "✅ Published $APP_NAME $VERSION ($BUILD) to $RELEASES_REPO"
195echo " Members on an older build pick this up next automatic check, or"
196echo " immediately via Eleven → Check for Updates…"
197