release: v1.1.17 #134
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |