#!/bin/bash # Usage: bundle-macos.sh # Package the release binary into dist/tty7.app, then publish both: # dist/tty7--macos-.zip (in-app updater) # dist/tty7--macos-.dmg (drag-to-Applications install) # # Signing posture is chosen from the environment: # * Developer ID secrets present (APPLE_SIGNING_IDENTITY + APPLE_CERTIFICATE) # -> hardened-runtime signature, then notarize + staple. Passes Gatekeeper. # * Otherwise -> adhoc signature, same as before. Fine for local dev, but the # OS will quarantine it on other machines. set -euo pipefail TARGET="$1" ARCH="$2" # Anchored on `= "` because the root manifest's `[package]` section leads with # `version.workspace = true` — a bare `^version` match grabs that line, finds no # quotes to substitute, and passes it through as the "version", which then lands # in CFBundleVersion and the .dmg filename. Guard against a silent recurrence. VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then echo "bundle-macos: could not read a version from Cargo.toml (got '$VERSION')" >&2 exit 1 fi PACKAGE_UPDATE_ZIP="${TTY7_PACKAGE_UPDATE_ZIP:-1}" APP="dist/tty7.app" rm -rf dist mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "target/${TARGET}/release/tty7-app" "$APP/Contents/MacOS/tty7-app" chmod +x "$APP/Contents/MacOS/tty7-app" # The CLI rides inside the bundle rather than beside it: a DMG is drag-to- # Applications, so anything not in the .app never reaches the user's disk. The # GUI symlinks it onto PATH at launch (see core::cli_install), which is why it # sits next to tty7-app under MacOS/ — that is the directory the GUI resolves # relative to its own executable. cp "target/${TARGET}/release/tty7" "$APP/Contents/MacOS/tty7" chmod +x "$APP/Contents/MacOS/tty7" if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then # A focused out-of-process updater can replace the bundle after the GUI # exits, then relaunch or roll back without teaching the GUI to mutate # itself. Every macOS build carries it beside the app/CLI so its signature # is covered by the outer bundle — including Nightly, whose users are # offered the stable release that supersedes their prerelease and need a # working helper to get there. cp "target/${TARGET}/release/tty7-updater" "$APP/Contents/MacOS/tty7-updater" chmod +x "$APP/Contents/MacOS/tty7-updater" fi cp assets/tty7.icns "$APP/Contents/Resources/tty7.icns" # Completion signatures are loaded at runtime (not embedded), resolved relative # to the executable as ../Resources/completions — see terminal::signature. mkdir -p "$APP/Contents/Resources/completions" cp assets/completions/*.json "$APP/Contents/Resources/completions/" printf 'APPL????' > "$APP/Contents/PkgInfo" cat > "$APP/Contents/Info.plist" < CFBundleNametty7 CFBundleDisplayNametty7 CFBundleIdentifiercom.github.tty7 CFBundleVersion${VERSION} CFBundleShortVersionString${VERSION} CFBundleExecutabletty7-app CFBundleIconFiletty7 CFBundlePackageTypeAPPL NSHighResolutionCapable NSPrincipalClassNSApplication PLIST SIGN_ID="${APPLE_SIGNING_IDENTITY:-}" if [[ -n "$SIGN_ID" && -n "${APPLE_CERTIFICATE:-}" ]]; then # ---- Developer ID signing ------------------------------------------------ # Import the cert into a throwaway keychain so we never touch the login one. KEYCHAIN="${RUNNER_TEMP:-/tmp}/tty7-sign.keychain-db" CERT_PATH="${RUNNER_TEMP:-/tmp}/tty7-cert.p12" KEYCHAIN_PASSWORD="${KEYCHAIN_PASSWORD:-tty7-ci}" # Scrub the decoded cert + temp keychain on any exit path. cleanup() { security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || true rm -f "$CERT_PATH" } trap cleanup EXIT security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH" security import "$CERT_PATH" -P "${APPLE_CERTIFICATE_PASSWORD:-}" \ -A -t cert -f pkcs12 -k "$KEYCHAIN" security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null security list-keychains -d user -s "$KEYCHAIN" login.keychain # Hardened runtime forbids JIT / unsigned executable memory by default; the # GPU/Metal path gpui uses needs them, so grant them explicitly or the # notarized build crashes on launch. ENTITLEMENTS="dist/entitlements.plist" cat > "$ENTITLEMENTS" <<'ENT' com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation ENT # Sign inner-out: the executables first, then the bundle. The CLI must be # signed explicitly — notarization rejects a bundle carrying an unsigned # Mach-O, and the outer `codesign "$APP"` does not descend into MacOS/ for # anything but CFBundleExecutable. # # It gets hardened runtime (notarization requires it) but none of the GUI's # entitlements: the JIT and library-validation exemptions exist for gpui's # Metal path, and a CLI that never renders anything has no business holding # them. codesign --force --options runtime --timestamp \ --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7" if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then codesign --force --options runtime --timestamp \ --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7-updater" fi codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7-app" codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ --sign "$SIGN_ID" "$APP" codesign --verify --strict --verbose=2 "$APP" # ---- Notarization -------------------------------------------------------- if [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then # Submit a zip of the .app; on success staple the ticket onto the bundle # so it validates offline (the distributed zip below then carries it). ditto -c -k --keepParent "$APP" "dist/notarize.zip" xcrun notarytool submit "dist/notarize.zip" \ --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \ --team-id "$APPLE_TEAM_ID" --wait xcrun stapler staple "$APP" rm -f "dist/notarize.zip" echo "✅ signed + notarized + stapled" else echo "⚠️ signed with Developer ID but notarization secrets missing — skipping notarize" fi else echo "⚠️ no Developer ID secrets — adhoc signing (won't pass Gatekeeper on other machines)" codesign --force --deep --sign - "$APP" fi # The in-app updater needs the signed, notarized .app itself rather than a disk # image that requires Finder interaction. The helper re-reads the full embedded # version out of the staged bundle and refuses anything that is not the release # it was told to install. ZIP="" if [[ "$PACKAGE_UPDATE_ZIP" != "0" ]]; then ZIP="dist/tty7-${VERSION}-macos-${ARCH}.zip" ditto -c -k --keepParent "$APP" "$ZIP" fi # Package the (now stapled) bundle as a drag-to-Applications DMG. DMG="dist/tty7-${VERSION}-macos-${ARCH}.dmg" STAGE="dist/dmg-stage" rm -rf "$STAGE" mkdir "$STAGE" cp -R "$APP" "$STAGE/" ln -s /Applications "$STAGE/Applications" hdiutil create -volname "tty7" -srcfolder "$STAGE" -ov -format UDZO "$DMG" rm -rf "$STAGE" if [[ -n "$SIGN_ID" && -n "${APPLE_CERTIFICATE:-}" ]]; then codesign --force --timestamp --sign "$SIGN_ID" "$DMG" fi if [[ -n "$ZIP" ]]; then echo "✅ $ZIP" fi echo "✅ $DMG"