From f2001169fa6b4b6e56a1e9bc5abc57aa4778406f Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Sun, 5 Apr 2026 15:33:19 -0500 Subject: [PATCH] Add code signing, notarization, and release workflow (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign and notarize the Crow app for macOS distribution so users can install without Gatekeeper warnings. Adds entitlements for hardened runtime (no sandbox — required for spawning arbitrary dev tools), a signing/notarization script, DMG creation, dynamic versioning from git tags, and a GitHub Actions release workflow triggered on v* tags. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 125 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + CONTRIBUTING.md | 2 + Crow.entitlements | 17 +++++ Makefile | 6 +- README.md | 10 +++ mise.toml | 5 ++ scripts/sign-and-notarize.sh | 105 ++++++++++++++++++++++++++++ 8 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 Crow.entitlements create mode 100755 scripts/sign-and-notarize.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..deb3128 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +env: + ZIG_VERSION: '0.15.2' + +jobs: + release: + name: Build, Sign & Release + runs-on: macos-15 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16' + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: ${{ env.ZIG_VERSION }} + + - name: Extract version from tag + run: echo "CROW_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + + - name: Import signing certificate + env: + CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_CERTIFICATE_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_CERTIFICATE_PASSWORD }} + run: | + CERTIFICATE_PATH="$RUNNER_TEMP/certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 16)" + + # Decode certificate + echo "$CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH" + + # Create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + security import "$CERTIFICATE_PATH" \ + -P "$CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Allow codesign to access the keychain without UI prompt + security set-key-partition-list \ + -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + + # Add to keychain search list + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + + # Clean up certificate file + rm -f "$CERTIFICATE_PATH" + + - name: Get Ghostty submodule SHA + id: ghostty-sha + run: echo "sha=$(git -C vendor/ghostty rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Cache Ghostty framework + id: ghostty-cache + uses: actions/cache@v4 + with: + path: Frameworks + key: ghostty-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-sha.outputs.sha }} + + - name: Build GhosttyKit + if: steps.ghostty-cache.outputs.cache-hit != 'true' + run: | + unset ZIG_LOCAL_CACHE_DIR ZIG_GLOBAL_CACHE_DIR + make ghostty + + - name: Bundle Crow.app + env: + CROW_VERSION: ${{ env.CROW_VERSION }} + run: | + bash scripts/generate-build-info.sh + bash scripts/bundle.sh + + - name: Sign, create DMG, and notarize + env: + DEVELOPER_ID_APPLICATION: ${{ secrets.DEVELOPER_ID_APPLICATION }} + CROW_VERSION: ${{ env.CROW_VERSION }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: bash scripts/sign-and-notarize.sh + + - name: Build CLI + run: | + swift build -c release --product crow + cp .build/release/crow crow-cli-macos-arm64 + + - name: Cleanup keychain + if: always() + run: security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + Crow-${{ env.CROW_VERSION }}.dmg + crow-cli-macos-arm64 + generate_release_notes: true + prerelease: ${{ contains(github.ref_name, '-') }} diff --git a/.gitignore b/.gitignore index d7bd74c..44d0f11 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ Sources/CrowCLI/Generated/ # App bundle *.app *.dmg +*.p12 # Frameworks (built locally) Frameworks/GhosttyKit.xcframework/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af6644c..a81fc1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,8 @@ cd crow make build ``` +**Note:** Code signing is not required for development. Unsigned builds work normally for local testing. Official releases are signed and notarized automatically via GitHub Actions. + ### Running Tests ```bash diff --git a/Crow.entitlements b/Crow.entitlements new file mode 100644 index 0000000..4286b92 --- /dev/null +++ b/Crow.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.cs.allow-jit + + + diff --git a/Makefile b/Makefile index a0fcc68..d5a3582 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build setup ghostty app release clean clean-all check help +.PHONY: build setup ghostty app release sign clean clean-all check help FRAMEWORKS_DIR := Frameworks XCFW := $(FRAMEWORKS_DIR)/GhosttyKit.xcframework @@ -17,6 +17,7 @@ help: @echo " ghostty Build GhosttyKit framework" @echo " app Swift build only (debug)" @echo " release Release build + .app bundle" + @echo " sign Sign, create DMG, and notarize (requires DEVELOPER_ID_APPLICATION)" @echo " clean Remove .build/ (keeps ghostty framework)" @echo " clean-all Remove .build/ and Frameworks/ (full rebuild)" @echo "" @@ -53,6 +54,9 @@ release: $(XCFW) bash scripts/generate-build-info.sh bash scripts/bundle.sh +sign: release + bash scripts/sign-and-notarize.sh + # --- Clean --- clean: diff --git a/README.md b/README.md index c0ecfe3..c9ae2a0 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,16 @@ Run with log filtering: We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on reporting bugs, suggesting features, and submitting pull requests. +## Releases + +Official releases are signed and notarized via GitHub Actions. Download the latest DMG from the [Releases](https://github.com/radiusmethod/crow/releases) page — it will install without Gatekeeper warnings. + +**Building from source:** Code signing is only required for distribution. Developers building from source do not need a signing certificate — `make build` and `make release` produce unsigned but fully functional builds. If macOS quarantines an unsigned .app, remove it with: + +```bash +xattr -cr Crow.app +``` + ## License Apache 2.0 — see [LICENSE](LICENSE) for details. diff --git a/mise.toml b/mise.toml index 4dc8d13..db1a23a 100644 --- a/mise.toml +++ b/mise.toml @@ -29,6 +29,11 @@ run = "swift test" description = "Create .app bundle" run = "bash scripts/bundle.sh" +[tasks.sign] +description = "Sign, create DMG, and notarize (requires DEVELOPER_ID_APPLICATION)" +depends = ["bundle"] +run = "bash scripts/sign-and-notarize.sh" + [tasks.clean] description = "Clean build artifacts" run = "rm -rf .build .derived-data Crow.app" diff --git a/scripts/sign-and-notarize.sh b/scripts/sign-and-notarize.sh new file mode 100755 index 0000000..1a3f62a --- /dev/null +++ b/scripts/sign-and-notarize.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Sign, create DMG, notarize, and staple the Crow app bundle. +# +# Required: +# DEVELOPER_ID_APPLICATION — signing identity (e.g. "Developer ID Application: Radius Method (TEAMID)") +# +# Optional: +# CROW_VERSION — version string for DMG filename (default: "dev") +# APPLE_ID — Apple ID for notarization +# APPLE_APP_SPECIFIC_PASSWORD — app-specific password for notarization +# APPLE_TEAM_ID — Apple Developer Team ID for notarization +# +# If all three notarization vars are set, the DMG is submitted for notarization and stapled. +# Otherwise, signing and DMG creation proceed without notarization. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +ENTITLEMENTS="$ROOT_DIR/Crow.entitlements" +VERSION="${CROW_VERSION:-dev}" + +# --- Validate inputs --- + +APP_PATH="${1:-$ROOT_DIR/Crow.app}" + +if [ ! -d "$APP_PATH" ]; then + echo "ERROR: App bundle not found at $APP_PATH" + echo "Run 'make release' first to build the app bundle." + exit 1 +fi + +if [ -z "${DEVELOPER_ID_APPLICATION:-}" ]; then + echo "ERROR: DEVELOPER_ID_APPLICATION is not set." + echo "Set it to your signing identity, e.g.:" + echo " export DEVELOPER_ID_APPLICATION=\"Developer ID Application: Your Name (TEAMID)\"" + exit 1 +fi + +if [ ! -f "$ENTITLEMENTS" ]; then + echo "ERROR: Entitlements file not found at $ENTITLEMENTS" + exit 1 +fi + +IDENTITY="$DEVELOPER_ID_APPLICATION" + +# --- Step 1: Code sign the .app bundle --- + +echo "==> Signing $APP_PATH..." +codesign --force --deep \ + --sign "$IDENTITY" \ + --entitlements "$ENTITLEMENTS" \ + --options runtime \ + --timestamp \ + "$APP_PATH" + +echo "==> Verifying signature..." +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +echo " Signature OK" + +# --- Step 2: Create DMG --- + +DMG_NAME="Crow-${VERSION}.dmg" +DMG_PATH="$ROOT_DIR/$DMG_NAME" +STAGING_DIR="$ROOT_DIR/.build/dmg-staging" + +echo "==> Creating DMG: $DMG_NAME..." +rm -rf "$STAGING_DIR" +mkdir -p "$STAGING_DIR" +cp -R "$APP_PATH" "$STAGING_DIR/" +ln -s /Applications "$STAGING_DIR/Applications" + +rm -f "$DMG_PATH" +hdiutil create \ + -volname "Crow" \ + -srcfolder "$STAGING_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +rm -rf "$STAGING_DIR" + +# Sign the DMG +codesign --force --sign "$IDENTITY" --timestamp "$DMG_PATH" +echo " DMG created and signed: $DMG_PATH" + +# --- Step 3: Notarize (conditional) --- + +if [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then + echo "==> Submitting for notarization..." + xcrun notarytool submit "$DMG_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + + # --- Step 4: Staple --- + + echo "==> Stapling notarization ticket..." + xcrun stapler staple "$DMG_PATH" + echo " Notarization complete and stapled" +else + echo "==> Skipping notarization (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, or APPLE_TEAM_ID not set)" +fi + +echo "==> Done! Output: $DMG_PATH"