eleven / ocho Public

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

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
53# Sparkle orders releases by <sparkle:version> — the BUILD number, not the
54# marketing version — so a build at or below the live feed's head is "old":
55# generate_appcast quietly shunts the fresh DMG to old_updates and ships
56# nothing, ten minutes and one notarization too late (the 0.9.1-as-build-1
57# mint, 2026-08-28). Refuse up front instead.
58LIVE_HEAD="$(gh release download -R "$RELEASES_REPO" -p appcast.xml -O - 2>/dev/null \
59 | sed -n 's/.*<sparkle:version>\([0-9][0-9]*\)<\/sparkle:version>.*/\1/p' \
60 | sort -n | tail -1)"
61if [ -n "$LIVE_HEAD" ] && [ "$BUILD" -le "$LIVE_HEAD" ]; then
62 echo "error: build $BUILD is not newer than the live appcast's head (build $LIVE_HEAD) — Sparkle would ignore it. Use $((LIVE_HEAD + 1))." >&2
63 exit 1
64fi
65# Keep in sync with the `packages.Sparkle.exactVersion` pin in ios/project.yml
66# — generate_appcast's flags are matched against this exact release's --help.
67SPARKLE_VERSION="2.9.4"
68
69# Optional release notes ("what's new"), surfaced two ways. The HTML is embedded
70# inline as the appcast item's <description> (see embed-release-notes.py) so it
71# renders in Sparkle's in-app updater; the markdown is used as the GitHub release
72# page body. Either var may be unset — that channel falls back to a generic
73# one-liner.
74NOTES_HTML="${LCHAT_RELEASE_NOTES_HTML:-}"
75NOTES_MD="${LCHAT_RELEASE_NOTES_MD:-}"
76command -v dmgbuild >/dev/null || {
77 echo "error: dmgbuild not found — install it with: pip install dmgbuild" >&2
78 exit 1
79}
80
81REPO="$(cd "$(dirname "$0")/.." && pwd)"
82IOS="$REPO/ios"
83TMP="$(mktemp -d)"
84trap 'rm -rf "$TMP"' EXIT
85
86ASC_AUTH=(-authenticationKeyPath "$LCHAT_ASC_KEY_PATH" -authenticationKeyID "$LCHAT_ASC_KEY_ID" -authenticationKeyIssuerID "$LCHAT_ASC_ISSUER_ID")
87# notarytool takes different flag names than xcodebuild's -authenticationKey* set.
88NOTARY_AUTH=(--key "$LCHAT_ASC_KEY_PATH" --key-id "$LCHAT_ASC_KEY_ID" --issuer "$LCHAT_ASC_ISSUER_ID")
89
90echo "Building $APP_NAME $VERSION ($BUILD) — Developer ID / notarized"
91
92cd "$IOS"
93xcodegen generate
94
95xcodebuild archive \
96 -project LChat.xcodeproj -scheme LChatMac -destination 'generic/platform=macOS' \
97 -archivePath "$TMP/LChatMac.xcarchive" -allowProvisioningUpdates \
98 CODE_SIGN_STYLE=Automatic MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD"
99
100# -exportOptionsPlist has no CLI flag form, so template a throwaway one.
101cat > "$TMP/export-options.plist" <<PLIST
102<?xml version="1.0" encoding="UTF-8"?>
103<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
104<plist version="1.0">
105<dict>
106 <key>method</key>
107 <string>developer-id</string>
108 <key>teamID</key>
109 <string>$TEAM_ID</string>
110$(if [ -n "${LCHAT_MAC_SIGNING_PROFILE:-}" ]; then cat <<MANUAL
111 <key>signingStyle</key>
112 <string>manual</string>
113 <key>signingCertificate</key>
114 <string>Developer ID Application</string>
115 <key>provisioningProfiles</key>
116 <dict>
117 <key>com.elevenmessenger.messenger.mac</key>
118 <string>$LCHAT_MAC_SIGNING_PROFILE</string>
119 </dict>
120MANUAL
121else cat <<AUTO
122 <key>signingStyle</key>
123 <string>automatic</string>
124AUTO
125fi)
126</dict>
127</plist>
128PLIST
129
130# The ASC key flags are passed defensively here even though Developer ID
131# signing itself needs no ASC auth — CLAUDE.local.md notes a stale Xcode
132# account session can otherwise break -exportArchive outright (hit this on
133# the TestFlight flow; same landmine could apply here).
134xcodebuild -exportArchive -archivePath "$TMP/LChatMac.xcarchive" -exportPath "$TMP/export" \
135 -exportOptionsPlist "$TMP/export-options.plist" -allowProvisioningUpdates "${ASC_AUTH[@]}"
136
137APP="$TMP/export/$APP_NAME.app"
138
139ditto -c -k --keepParent "$APP" "$TMP/$APP_NAME.zip"
140echo "Notarizing $APP_NAME.app…"
141xcrun notarytool submit "$TMP/$APP_NAME.zip" "${NOTARY_AUTH[@]}" --wait
142xcrun stapler staple "$APP"
143
144# Build the install window with dmgbuild: the app icon, an Applications drop
145# link, and a "1 … 10 →" count on a branded background (ios/dmg/). dmgbuild
146# writes the .DS_Store layout directly, so no flaky Finder scripting is involved.
147DMG="$TMP/$APP_NAME-$VERSION.dmg"
148APP_PATH="$APP" BG_TIFF="$IOS/dmg/background.tiff" \
149 dmgbuild -s "$IOS/dmg/dmg-settings.py" "$APP_NAME" "$DMG"
150
151# Notarize + staple the DMG too, not just the .app inside it: once a member
152# drags the app to /Applications and ejects the DMG, that copy carries no
153# staple of its own unless the DMG's staple already covered it — without
154# this, first launch falls back to an online Gatekeeper check.
155echo "Notarizing $APP_NAME-$VERSION.dmg…"
156xcrun notarytool submit "$DMG" "${NOTARY_AUTH[@]}" --wait
157xcrun stapler staple "$DMG"
158
159# generate_appcast only needs the previous appcast.xml (it preserves entries
160# it can't find a local archive for) plus the newly built DMG — not every
161# historical release's DMG.
162APPCAST_DIR="$TMP/appcast"
163mkdir -p "$APPCAST_DIR"
164gh release download -R "$RELEASES_REPO" -p appcast.xml -D "$APPCAST_DIR" 2>/dev/null || \
165 echo "No previous release found on $RELEASES_REPO — generating the first appcast."
166cp "$DMG" "$APPCAST_DIR/"
167
168echo "Fetching Sparkle $SPARKLE_VERSION command-line tools…"
169curl -sL -o "$TMP/sparkle-tools.tar.xz" \
170 "https://github.com/sparkle-project/Sparkle/releases/download/$SPARKLE_VERSION/Sparkle-$SPARKLE_VERSION.tar.xz"
171mkdir -p "$TMP/sparkle-tools"
172tar -xJf "$TMP/sparkle-tools.tar.xz" -C "$TMP/sparkle-tools" ./bin/generate_appcast
173chmod +x "$TMP/sparkle-tools/bin/generate_appcast"
174
175"$TMP/sparkle-tools/bin/generate_appcast" \
176 --ed-key-file "$LCHAT_SPARKLE_KEY_FILE" \
177 --download-url-prefix "https://github.com/$RELEASES_REPO/releases/download/v$VERSION/" \
178 --maximum-deltas 0 \
179 "$APPCAST_DIR"
180
181# Belt to the preflight's braces: if generate_appcast still didn't write this
182# build (it moves anything it deems old to old_updates and reports success),
183# say so plainly instead of letting the release-create step fail on a missing
184# file two commands later.
185grep -q "<sparkle:version>$BUILD</sparkle:version>" "$APPCAST_DIR/appcast.xml" || {
186 echo "error: generate_appcast did not add build $BUILD to appcast.xml — nothing to publish" >&2
187 exit 1
188}
189
190# Embed the "what's new" HTML inline as this item's <description> so it renders
191# in the in-app updater (see embed-release-notes.py for why inline beats a link).
192if [ -n "$NOTES_HTML" ]; then
193 python3 "$IOS/embed-release-notes.py" "$APPCAST_DIR/appcast.xml" "$VERSION" "$NOTES_HTML"
194fi
195
196# Publish the DMG + appcast; use the markdown notes (when provided) as the
197# GitHub release page body, else a generic one-liner.
198NOTES_ARG=(--notes "$APP_NAME $VERSION for macOS.")
199[ -n "$NOTES_MD" ] && NOTES_ARG=(--notes-file "$NOTES_MD")
200
201# --latest (explicit): Sparkle's SUFeedURL is releases/latest/download/
202# appcast.xml, so the Mac release must always hold the repo's "latest"
203# pointer — server releases publish with --latest=false for the same reason
204# (release-server.sh; the pointer theft 404'd every Mac's update check).
205# The stable-named copy gives the website a permanent one-click URL
206# (releases/latest/download/Eleven.dmg) instead of pinning a version that
207# goes stale; kept out of APPCAST_DIR so generate_appcast never sees a
208# duplicate archive.
209cp "$DMG" "$TMP/$APP_NAME.dmg"
210gh release create "v$VERSION" -R "$RELEASES_REPO" \
211 --title "$APP_NAME $VERSION" "${NOTES_ARG[@]}" --latest \
212 "$APPCAST_DIR/$APP_NAME-$VERSION.dmg" "$APPCAST_DIR/appcast.xml" \
213 "$TMP/$APP_NAME.dmg"
214
215echo
216echo "✅ Published $APP_NAME $VERSION ($BUILD) to $RELEASES_REPO"
217echo " Members on an older build pick this up next automatic check, or"
218echo " immediately via Eleven → Check for Updates…"
219