Skip to content

release: v1.1.17

release: v1.1.17 #134

Workflow file for this run

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.