Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: write

jobs:
release:
name: Build, sign, notarize
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal

- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
rust/target
key: ${{ runner.os }}-release-cargo-${{ hashFiles('rust/Cargo.lock') }}

- name: Install create-dmg
run: brew install create-dmg

# The Developer ID certificate is imported into a throwaway keychain so it
# never persists on the runner beyond this job.
- name: Import signing certificate
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN=$RUNNER_TEMP/build.keychain
echo "$MACOS_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/cert.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import $RUNNER_TEMP/cert.p12 -k "$KEYCHAIN" \
-P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychain -d user -s "$KEYCHAIN" login.keychain
rm $RUNNER_TEMP/cert.p12

- name: Store notarytool credentials
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
echo "$APPLE_API_KEY" | base64 --decode > $RUNNER_TEMP/AuthKey.p8
xcrun notarytool store-credentials "patcha-notary" \
--key $RUNNER_TEMP/AuthKey.p8 \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER" \
--keychain $RUNNER_TEMP/build.keychain
rm $RUNNER_TEMP/AuthKey.p8

- name: Build, sign, notarize
env:
PATCHA_SIGN_IDENTITY: ${{ secrets.PATCHA_SIGN_IDENTITY }}
PATCHA_NOTARY_PROFILE: patcha-notary
run: ./build.sh

- name: Verify notarization
run: |
DMG=$(ls dist/patcha-*.dmg)
spctl -a -vvv -t install "$DMG"
xcrun stapler validate "$DMG"

- name: Publish release
uses: softprops/action-gh-release@v2
with:
files: dist/patcha-*.dmg
generate_release_notes: true
173 changes: 134 additions & 39 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,52 @@ for arg in "$@"; do
[[ "$arg" == "--skip-app" ]] && SKIP_APP=true
done

# Signing configuration.
# PATCHA_SIGN_IDENTITY - "Developer ID Application: NAME (TEAMID)". When
# unset the build is signed ad-hoc for local testing
# and is NOT distributable.
# PATCHA_NOTARY_PROFILE - notarytool keychain profile name. When unset,
# notarization is skipped.
SIGN_IDENTITY="${PATCHA_SIGN_IDENTITY:-}"
NOTARY_PROFILE="${PATCHA_NOTARY_PROFILE:-}"
APP_ENTITLEMENTS="swift-xcode/patcha/patcha/patcha.entitlements"
HELPER_ENTITLEMENTS="swift-xcode/patcha/patcha/helper.entitlements"

if [[ -z "$SIGN_IDENTITY" ]]; then
ADHOC=true
SIGN_IDENTITY="-"
SIGN_FLAGS=()
else
ADHOC=false
# A secure timestamp is required for notarization and cannot be added after
# the fact, so it must be part of every signature we produce.
SIGN_FLAGS=(--timestamp)
if ! security find-identity -v -p codesigning | grep -qF "$SIGN_IDENTITY"; then
echo "Error: signing identity not found in keychain: $SIGN_IDENTITY"
security find-identity -v -p codesigning | sed 's/^/ /'
exit 1
fi
fi

echo "Building patcha ${VERSION}..."

# Step 1: build native macOS menu bar app
echo ""
if $SKIP_APP; then
echo "[1/5] Skipping Patcha.app build (--skip-app)."
echo "[1/7] Skipping Patcha.app build (--skip-app)."
if [[ ! -d "dist/Patcha.app" ]]; then
echo "Error: dist/Patcha.app not found. Run without --skip-app first."
exit 1
fi
else
echo "[1/5] Building Patcha.app (Swift menu bar app)..."
echo "[1/7] Building Patcha.app (Swift menu bar app)..."
bash swift-xcode/patcha/build_app.sh
echo " Patcha.app built."
fi

# Step 2: compile Swift helper binaries (accessibility helpers)
echo ""
echo "[2/5] Compiling Swift helper binaries..."
echo "[2/7] Compiling Swift helper binaries..."
mkdir -p data

if ! command -v swiftc &>/dev/null; then
Expand Down Expand Up @@ -69,7 +96,7 @@ echo " Swift helper binaries compiled."

# Step 3: fetch the MobileCLIP image-encoder Core ML model (visual pre-filter)
echo ""
echo "[3/5] Fetching MobileCLIP model..."
echo "[3/7] Fetching MobileCLIP model..."
MLPKG="data/mobileclip_s2_image.mlpackage"
if [[ -f "$MLPKG/Data/com.apple.CoreML/weights/weight.bin" ]]; then
echo " Model already present, skipping download."
Expand All @@ -82,29 +109,9 @@ else
echo " Model downloaded to $MLPKG"
fi

# Step 3b: fetch the FastVLM ONNX captioner model (gist captioning).
# CPU execution provider needs fp32-activation graphs: q4f16 vision/embed run on
# CPU, but the decoder must be the q4 (fp32) graph, not q4f16.
echo " Fetching FastVLM captioner model..."
FVDIR="data/models/fastvlm"
if [[ -f "$FVDIR/onnx/decoder_model_merged_q4.onnx" ]]; then
echo " FastVLM model already present, skipping download."
else
FVBASE="https://huggingface.co/onnx-community/FastVLM-0.5B-ONNX/resolve/main"
mkdir -p "$FVDIR/onnx"
for f in config.json tokenizer.json tokenizer_config.json special_tokens_map.json \
generation_config.json preprocessor_config.json processor_config.json; do
curl -fsSL "$FVBASE/$f" -o "$FVDIR/$f"
done
curl -fsSL "$FVBASE/onnx/vision_encoder_q4f16.onnx" -o "$FVDIR/onnx/vision_encoder_q4f16.onnx"
curl -fsSL "$FVBASE/onnx/embed_tokens_q4f16.onnx" -o "$FVDIR/onnx/embed_tokens_q4f16.onnx"
curl -fsSL "$FVBASE/onnx/decoder_model_merged_q4.onnx" -o "$FVDIR/onnx/decoder_model_merged_q4.onnx"
echo " FastVLM model downloaded to $FVDIR"
fi

# Step 4: Rust build (replaces PyInstaller)
echo ""
echo "[4/5] Building Rust binary..."
echo "[4/7] Building Rust binary..."
rm -rf dist/bin
mkdir -p dist/bin

Expand All @@ -114,33 +121,101 @@ chmod +x dist/bin/patcha

echo " Rust binary built: dist/bin/patcha"

# Step 5: Assemble .dmg staging area
# Step 5: Assemble the .app payload
echo ""
echo "[5/5] Staging .dmg contents..."
echo "[5/7] Staging app payload..."
DMG_STAGE="dist/dmg_stage"
rm -rf "$DMG_STAGE"
mkdir -p "$DMG_STAGE"

cp -r dist/Patcha.app "$DMG_STAGE/"
APP_RES="$DMG_STAGE/Patcha.app/Contents/Resources"
cp -R dist/Patcha.app "$DMG_STAGE/"
STAGED_APP="$DMG_STAGE/Patcha.app"
APP_RES="$STAGED_APP/Contents/Resources"

cp dist/bin/patcha "$APP_RES/"
chmod +x "$APP_RES/patcha"

# Swift helper binaries + MobileCLIP model (resolved next to the patcha binary at runtime)
cp data/ax_content data/ocr data/mobileclip data/observer "$APP_RES/"
chmod +x "$APP_RES/ax_content" "$APP_RES/ocr" "$APP_RES/mobileclip" "$APP_RES/observer"
cp -r data/mobileclip_s2_image.mlpackage "$APP_RES/"
chmod +x "$APP_RES/patcha" "$APP_RES/ax_content" "$APP_RES/ocr" \
"$APP_RES/mobileclip" "$APP_RES/observer"
cp -R data/mobileclip_s2_image.mlpackage "$APP_RES/"

# The FastVLM captioner model (~810 MB) is deliberately NOT bundled. The daemon
# fetches it into ~/.patcha/models/fastvlm on first run; see model_fetch.rs.
# Bundling it would quadruple the .dmg and add two multi-gigabyte notarization
# uploads per release.

# FastVLM captioner model (resolved at resources_dir/models/fastvlm at runtime).
# NOTE: ~0.8 GB — consider download-on-first-run instead of bundling to keep the DMG small.
mkdir -p "$APP_RES/models"
cp -r data/models/fastvlm "$APP_RES/models/"
# PatchaSourceRoot is a dev-only fallback pointing at this machine's checkout.
# Leaving it in would ship a local filesystem path in a public release.
/usr/libexec/PlistBuddy -c "Delete :PatchaSourceRoot" \
"$STAGED_APP/Contents/Info.plist" 2>/dev/null || true

echo " Contents staged at $DMG_STAGE"
# Extended attributes left by cp/curl make codesign fail with
# "resource fork, Finder information, or similar detritus not allowed".
xattr -cr "$STAGED_APP"

# Create .dmg
echo " Payload staged at $STAGED_APP"

# Step 6: Sign inside-out. Nested code must be signed before the enclosing
# bundle, otherwise sealing the app captures signatures that no longer match.
echo ""
echo "Creating .dmg..."
if $ADHOC; then
echo "[6/7] Signing ad-hoc (local testing only)..."
echo " WARNING: Gatekeeper will reject this bundle on any other machine."
echo " Set PATCHA_SIGN_IDENTITY to a Developer ID Application identity to"
echo " produce a distributable build."
else
echo "[6/7] Signing with: $SIGN_IDENTITY"
fi

sign_code() {
codesign --force --options runtime "${SIGN_FLAGS[@]}" \
--sign "$SIGN_IDENTITY" --entitlements "$HELPER_ENTITLEMENTS" "$1"
}

for binary in patcha ax_content ocr mobileclip observer; do
sign_code "$APP_RES/$binary"
done

# Nested resource bundles carry no entitlements of their own.
for nested in "$APP_RES"/*.bundle; do
[[ -e "$nested" ]] || continue
codesign --force --options runtime "${SIGN_FLAGS[@]}" \
--sign "$SIGN_IDENTITY" "$nested"
done

codesign --force --options runtime "${SIGN_FLAGS[@]}" \
--sign "$SIGN_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$STAGED_APP"

echo " Verifying signature..."
codesign --verify --strict --deep --verbose=2 "$STAGED_APP"

if ! $ADHOC; then
# Only a real Developer ID signature can satisfy Gatekeeper. Before
# notarization this reports "rejected ... not notarized", which is expected.
spctl -a -vvv -t exec "$STAGED_APP" 2>&1 | sed 's/^/ /' || true
fi

# Notarize and staple the app. Stapling before building the .dmg means the app
# still validates offline once the user drags it out of the disk image.
if [[ -n "$NOTARY_PROFILE" ]] && ! $ADHOC; then
echo ""
echo " Notarizing app (this uploads the bundle to Apple and can take a while)..."
APP_ZIP="dist/Patcha-notarize.zip"
rm -f "$APP_ZIP"
ditto -c -k --keepParent "$STAGED_APP" "$APP_ZIP"
xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$STAGED_APP"
rm -f "$APP_ZIP"
echo " App notarized and stapled."
elif ! $ADHOC; then
echo ""
echo " Skipping notarization (PATCHA_NOTARY_PROFILE not set)."
fi

# Step 7: Create the .dmg
echo ""
echo "[7/7] Creating .dmg..."
DMG_PATH="dist/patcha-${VERSION}.dmg"
rm -f "$DMG_PATH"

Expand All @@ -155,5 +230,25 @@ create-dmg \
"$DMG_PATH" \
"$DMG_STAGE/"

if ! $ADHOC; then
echo " Signing .dmg..."
codesign --force "${SIGN_FLAGS[@]}" --sign "$SIGN_IDENTITY" "$DMG_PATH"
fi

if [[ -n "$NOTARY_PROFILE" ]] && ! $ADHOC; then
echo " Notarizing .dmg..."
xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$DMG_PATH"
echo " Verifying stapled .dmg..."
spctl -a -vvv -t install "$DMG_PATH" 2>&1 | sed 's/^/ /'
fi

echo ""
echo "Done: $DMG_PATH"
if $ADHOC; then
echo ""
echo "This is an ad-hoc build and is NOT distributable. To ship a release:"
echo " export PATCHA_SIGN_IDENTITY=\"Developer ID Application: NAME (TEAMID)\""
echo " export PATCHA_NOTARY_PROFILE=\"patcha-notary\""
echo "See docs/RELEASING.md for the one-time setup."
fi
Loading
Loading