-
Notifications
You must be signed in to change notification settings - Fork 23
404 lines (346 loc) · 19.4 KB
/
Copy pathrelease.yml
File metadata and controls
404 lines (346 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
build-app:
runs-on: macos-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install build deps
run: |
python -m pip install --upgrade pip
pip install py2app "setuptools<80"
# Install Heard + its runtime deps so py2app can collect them.
pip install -e ".[dev]"
- name: Test gate (don't ship a broken build)
run: |
ruff check heard/ tests/
pytest -q
- name: Build Heard.app
env:
PYTHON: python
run: |
cd packaging
./build-app.sh
# ──────────────────────────────────────────────────────────────
# Codesign + notarize
# ──────────────────────────────────────────────────────────────
# Disabled 2026-05-06 → re-enabled 2026-05-20. Apple's new-account
# fraud-review hold cleared on its own; all 4 stuck submissions
# from May 5-6 came back Accepted by the 20th. Pipeline was
# correct end-to-end the whole time.
- name: Import codesigning cert into a temporary keychain
env:
APPLE_CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
run: |
# Build inside a throwaway keychain so we don't pollute the
# runner's default. The keychain dies with the runner; the
# .p12 is decoded to a tmp path and unlinked the moment it's
# imported.
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
P12_PATH="$RUNNER_TEMP/cert.p12"
echo "$APPLE_CERT_P12_BASE64" | base64 --decode > "$P12_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$P12_PATH" \
-P "$APPLE_CERT_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
# Add to the search list so codesign can find it without
# an explicit -k flag, and authorise codesign to use the
# private key non-interactively.
security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychain -d user | tr -d '"')
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
rm -f "$P12_PATH"
# Sanity check — confirm the identity is visible to codesign.
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Sign Heard.app with hardened runtime
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
APP="packaging/dist/Heard.app"
IDENTITY="Developer ID Application: Jianing Sun ($APPLE_TEAM_ID)"
# py2app bundles a chunk of the stdlib + site-packages into
# python313.zip for faster cold start. Apple's notary service
# peeks inside that zip and rejects any unsigned Mach-O
# binaries it finds — but `find` and `codesign` can't reach
# them while archived.
#
# v0.5.13 expanded the zip into lib/python3.13 and left it
# expanded; that worked for signing but turned 1 file into
# ~20K loose ones, and Apple's notary scanner stalled past
# 6h enumerating them all. Round-trip instead: extract,
# sign Mach-O contents, re-archive into python313.zip in
# place. Apple unzips during scanning (proven by v0.5.12's
# reject path quoting "python313.zip/google/_upb/...") so
# signatures are still seen, but the wire shape stays small
# and the scan is back to ~2 min.
ZIP_PATH="$APP/Contents/Resources/lib/python313.zip"
UNZIP_TMP="$RUNNER_TEMP/python313-unzipped"
if [ -f "$ZIP_PATH" ]; then
rm -rf "$UNZIP_TMP"
mkdir -p "$UNZIP_TMP"
unzip -oq "$ZIP_PATH" -d "$UNZIP_TMP"
echo "Expanded $(unzip -l "$ZIP_PATH" | tail -1 | awk '{print $2}') files from python313.zip for inner-binary signing."
fi
# Sign every Mach-O binary by content type — extension-only
# matching would miss the bundled Python interp at
# Frameworks/Python.framework/Versions/3.13/Python. Walk
# both the bundle and the unzipped python313.zip workspace.
# find -d emits leaves first so container seals chain
# bottom-up.
MACHO_LIST="$RUNNER_TEMP/macho-list.txt"
: > "$MACHO_LIST"
for ROOT in "$APP" "$UNZIP_TMP"; do
[ -d "$ROOT" ] || continue
while IFS= read -r -d '' f; do
if file -b "$f" 2>/dev/null | grep -qE 'Mach-O.*(executable|bundle|dynamically linked shared library)'; then
printf '%s\0' "$f" >> "$MACHO_LIST"
fi
done < <(find -d "$ROOT" -type f -print0)
done
echo "Signing $(tr -cd '\0' < "$MACHO_LIST" | wc -c | tr -d ' ') Mach-O files..."
while IFS= read -r -d '' f; do
codesign --force --options runtime --timestamp \
--sign "$IDENTITY" "$f"
done < "$MACHO_LIST"
# Re-archive the (now-signed) python313.zip contents in
# place. zip -X strips OS metadata so the archive is
# reproducible across runs. ZIP_PATH is relative to the
# workspace, so resolve to absolute before cd-ing into the
# unzip workspace.
if [ -d "$UNZIP_TMP" ]; then
ZIP_PATH_ABS="$PWD/$ZIP_PATH"
rm -f "$ZIP_PATH_ABS"
( cd "$UNZIP_TMP" && zip -rqX "$ZIP_PATH_ABS" . )
rm -rf "$UNZIP_TMP"
echo "Re-archived signed contents into python313.zip ($(du -h "$ZIP_PATH_ABS" | cut -f1))."
fi
# Sign nested framework bundles (the .framework directory
# itself, after all its inner binaries are signed).
if [ -d "$APP/Contents/Frameworks/Python.framework" ]; then
codesign --force --options runtime --timestamp \
--sign "$IDENTITY" \
"$APP/Contents/Frameworks/Python.framework"
fi
# Finally seal the app bundle with our entitlements. This
# also signs Contents/MacOS/Heard automatically (codesign
# always signs the bundle's main executable when sealing
# the bundle).
codesign --force --options runtime --timestamp \
--entitlements packaging/entitlements.plist \
--sign "$IDENTITY" "$APP"
# Verify the seal is intact end-to-end.
codesign --verify --strict --verbose=2 "$APP"
- name: Notarize and staple
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
set -euo pipefail
APP="packaging/dist/Heard.app"
NOTARIZE_ZIP="$RUNNER_TEMP/Heard-for-notarization.zip"
# notarytool wants a .zip submission. ditto preserves the
# bundle's symlinks (Python.framework/Current → 3.13) which
# plain zip mangles into duplicate files.
ditto -c -k --keepParent "$APP" "$NOTARIZE_ZIP"
# Submit WITHOUT --wait so we own the polling loop. Apple's
# --wait long-polls indefinitely on their side, and a stalled
# response stream gives no signal back to us — v0.5.13 sat
# in --wait for 6 hours before GitHub force-cancelled the
# job at the runner timeout, with zero diagnostics. Owning
# the loop ourselves caps blast radius at TIMEOUT_S below.
SUBMIT_OUT="$RUNNER_TEMP/notarytool-submit.json"
xcrun notarytool submit "$NOTARIZE_ZIP" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--output-format json > "$SUBMIT_OUT"
cat "$SUBMIT_OUT"
SUBMISSION_ID=$(python3 -c "import json; print(json.load(open('$SUBMIT_OUT'))['id'])")
# Poll info until we get a terminal status (Accepted /
# Invalid / Rejected) or hit the cap. Apple's stated SLA
# is "minutes"; 30 min is generous and bounds the run so a
# stalled queue fails fast instead of dangling.
TIMEOUT_S=1800
POLL_INTERVAL_S=30
DEADLINE=$(( $(date +%s) + TIMEOUT_S ))
STATUS="Unknown"
INFO_OUT="$RUNNER_TEMP/notarytool-info.json"
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
xcrun notarytool info "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--output-format json > "$INFO_OUT" || true
STATUS=$(python3 -c "import json; print(json.load(open('$INFO_OUT')).get('status','Unknown'))" 2>/dev/null || echo "Unknown")
echo " [$(date +%H:%M:%S)] notary status: $STATUS"
case "$STATUS" in
Accepted|Invalid|Rejected) break ;;
esac
sleep $POLL_INTERVAL_S
done
# Always fetch the per-issue log — even on Accepted Apple
# may have warnings worth surfacing. On Invalid this is the
# only way to see why.
echo "::group::Apple notary log for $SUBMISSION_ID"
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" || true
echo "::endgroup::"
if [ "$STATUS" != "Accepted" ]; then
# Three failure modes funnel here: Invalid (Apple has a
# specific reason — see log above), Rejected (rare; Apple's
# malware scan flagged something), and our own timeout
# (status still In Progress at the cap, treat as queue
# backlog and surface a clear retry signal).
echo "::error::Notarization status was $STATUS — see Apple notary log above (or retry later if the queue is stuck)"
exit 1
fi
# Staple the notarization ticket onto the .app so Gatekeeper
# accepts it offline (no network call needed at first launch).
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
# Final Gatekeeper smoke test — should print "accepted".
spctl --assess --type execute --verbose "$APP"
- name: Install dmgbuild
# dmgbuild writes the DMG's .DS_Store (window size, icon
# positions, background image) directly via macOS plist APIs,
# not via AppleScript talking to Finder. That matters because
# GitHub Actions runners don't have a logged-in graphical
# Finder — AppleScript-based tools (create-dmg, appdmg)
# silently fail to apply window styling on CI and ship
# default-looking DMGs. dmgbuild writes the same bytes
# locally and in CI, no AppleScript required.
run: pip install --quiet dmgbuild
- name: Build Heard.dmg
# Produces the standard "drag the app onto Applications" install
# experience users see from Discord / Spotify / Notion / etc.
# Eliminates the App Translocation footgun — when a user double-
# clicks the .app from Downloads, macOS runs it from a randomized
# temp folder and hooks break because their PYTHONHOME path
# points at /Applications. A DMG forces the install to happen
# from /Applications before launch.
env:
# dmgbuild's settings file reads ``HEARD_APP_PATH`` /
# ``HEARD_DMG_BG`` so the file stays portable between local
# dev and CI without hardcoding paths.
HEARD_APP_PATH: packaging/dist/Heard.app
HEARD_DMG_BG: packaging/dmg-background.png
run: |
set -euo pipefail
DMG_OUT="packaging/dist/Heard-${GITHUB_REF_NAME:-dev}.dmg"
# Build only the versioned file here — the unversioned alias
# is produced *after* notarization + stapling so it carries
# the embedded ticket. (v0.8.10 try 1 cp'd before notary; CI
# failed because stapling the never-submitted unversioned
# copy raised "Record not found" from Apple's CloudKit.)
dmgbuild \
--settings packaging/dmg-settings.py \
"Heard Installer" \
"$DMG_OUT"
- name: Sign the DMG
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
IDENTITY="Developer ID Application: Jianing Sun ($APPLE_TEAM_ID)"
VERSIONED="packaging/dist/Heard-${GITHUB_REF_NAME:-dev}.dmg"
codesign --force --sign "$IDENTITY" --timestamp "$VERSIONED"
codesign --verify --strict --verbose=2 "$VERSIONED"
- name: Notarize, staple, and produce unversioned alias
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
set -euo pipefail
VERSIONED="packaging/dist/Heard-${GITHUB_REF_NAME:-dev}.dmg"
xcrun notarytool submit "$VERSIONED" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait
xcrun stapler staple "$VERSIONED"
xcrun stapler validate "$VERSIONED"
# Gatekeeper smoke test on the DMG (the .app inside is
# already separately notarized + stapled from the earlier
# step; this checks the outer container).
spctl --assess --type open --context context:primary-signature \
--verbose "$VERSIONED"
# Produce the unversioned ``Heard.dmg`` alias now that the
# versioned file carries its embedded notary ticket. cp
# preserves the ticket since it's part of the .dmg byte
# stream — no separate notarytool submission needed.
cp "$VERSIONED" "packaging/dist/Heard.dmg"
- name: Zip the bundle
# Build two zips with identical contents:
# Heard-vX.Y.Z.zip → versioned, for archival download
# Heard.zip → unversioned alias, so the lazy-install
# curl URL never has to know the version
run: |
cd packaging/dist
zip -ry "Heard-${GITHUB_REF_NAME:-dev}.zip" Heard.app
cp "Heard-${GITHUB_REF_NAME:-dev}.zip" Heard.zip
- name: Upload artifact (for workflow_dispatch / non-tag runs)
if: "!startsWith(github.ref, 'refs/tags/')"
uses: actions/upload-artifact@v4
with:
name: heard-app
path: |
packaging/dist/Heard-*.zip
packaging/dist/Heard.zip
packaging/dist/Heard-*.dmg
packaging/dist/Heard.dmg
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
packaging/dist/Heard-*.zip
packaging/dist/Heard.zip
packaging/dist/Heard-*.dmg
packaging/dist/Heard.dmg
body: |
### Install with one prompt (lazy mode)
Paste this into Claude Code, Codex, or your agent of choice.
**Fresh install** (no Heard yet on this machine):
> Install Heard so you narrate your responses to me. Run: `curl -L https://github.com/heardlabs/heard/releases/latest/download/Heard.zip -o /tmp/heard.zip && unzip -o /tmp/heard.zip -d /Applications && xattr -dr com.apple.quarantine /Applications/Heard.app && open /Applications/Heard.app` — a window will pop up and I'll fill it in.
**Upgrade from an older version** (Heard is currently running). The fresh-install command above races the still-shutting-down app and can leave a partial bundle; this version kills the running app, waits for it to release its file handles, then swaps in the new bundle cleanly:
> Upgrade Heard so I keep narrating your responses. Run: `killall Heard 2>/dev/null; while pgrep -x Heard >/dev/null; do sleep 0.2; done; rm -f ~/Library/Application\ Support/heard/daemon.sock ~/Library/Application\ Support/heard/daemon.pid; rm -rf /Applications/Heard.app && curl -L https://github.com/heardlabs/heard/releases/latest/download/Heard.zip -o /tmp/heard.zip && unzip -o /tmp/heard.zip -d /Applications && xattr -dr com.apple.quarantine /Applications/Heard.app && open /Applications/Heard.app` — Heard will quit and relaunch as the new version.
> _Note: starting in v0.8.2, the menu-bar "↑ Update to vX.Y.Z →" item does this swap for you in one click — no terminal needed. The upgrade prompt above is here for cases where the in-app updater isn't reachable (older versions, daemon stopped, etc.)._
---
### Install manually (menu bar app)
**Recommended — `Heard.dmg`:**
1. Download `Heard-${{ github.ref_name }}.dmg` below (or the unversioned `Heard.dmg`)
2. Double-click the DMG — a window opens with `Heard.app` on the left and an `Applications` shortcut on the right
3. Drag `Heard.app` onto `Applications`. Eject the disk image when done.
4. Open Heard from `/Applications` (or Launchpad / Spotlight)
5. The onboarding window walks you through four screens: API key (optional, for in-character persona rewrites), voice, the pause/continue hotkeys, and which agents to wire up
6. Done — Heard is in the menu bar, hooks installed, ready to narrate
**Alternative — `Heard.zip`** (use this only if you specifically need an unsigned-style flow; the DMG is more reliable):
Download the zip, drag `Heard.app` into `/Applications`, then double-click. **Important:** unzipping inside Downloads and double-clicking from there triggers macOS App Translocation — Heard runs from a randomized temp folder and its hooks break. Always move the `.app` to `/Applications` *before* launching it.
### Voice — two options
- **Free local (Kokoro)** — skip the ElevenLabs key field in onboarding. Downloads a ~337 MB voice model on first synth call. Best on Macs with 12 GB+ RAM.
- **Premium (ElevenLabs)** — paste your `sk_…` key on screen 2 of onboarding. Tiny daemon (~80 MB), pay-per-character (typically pennies a day), best voice quality.
### Hotkeys
Two combo hotkeys, both rebindable in Settings → Shortcuts:
- **⇧⌥.** (`Shift+Option+.`) — Pause narration
- **⇧⌥,** (`Shift+Option+,`) — Continue narration. If anything was buffered while paused, the persona asks whether to catch you up or start fresh.
macOS will ask once for Accessibility access — click Allow.
See the [README](https://github.com/heardlabs/heard#readme) for full docs and troubleshooting.