Skip to content

Release Build

Release Build #135

Workflow file for this run

name: Release Build
on:
workflow_dispatch:
inputs:
marketing_version:
description: 'Marketing version (e.g., 1.0.6)'
required: true
type: string
build_version:
description: 'Build version (e.g., 10006)'
required: true
type: string
release_notes:
description: 'Release notes'
required: false
type: string
default: 'Bug fixes and improvements'
env:
SCHEME: 'aizen'
CONFIGURATION: 'Release'
APP_BUNDLE_NAME: 'Aizen.app'
APP_EXECUTABLE_NAME: 'Aizen'
APP_DISPLAY_NAME: 'Aizen'
DERIVED_DATA_PATH: 'build/DerivedData'
VVDEVKIT_SWIFTPM_SCRATCH_PATH: 'build/DerivedData/VVDevKitSwiftPM'
jobs:
build-and-release:
runs-on: macos-26
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify vendored GhosttyKit
run: |
for required_path in \
Vendor/libghostty/VERSION \
Vendor/libghostty/GhosttyKit.xcframework \
Vendor/libghostty/GhosttyKit.xcframework/macos-arm64/libghostty-fat.a
do
if [ ! -e "$required_path" ]; then
echo "Error: Missing vendored GhosttyKit payload file: $required_path"
exit 1
fi
done
echo "Using vendored libghostty version $(cat Vendor/libghostty/VERSION)"
ls -lh Vendor/libghostty/GhosttyKit.xcframework/macos-arm64/libghostty-fat.a
- name: Show Xcode version
id: xcode-version
run: |
xcodebuild -version
echo "cache_key=$(xcodebuild -version | shasum -a 256 | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Bump version
run: |
chmod +x scripts/bump-version.sh
scripts/bump-version.sh "${{ github.event.inputs.marketing_version }}" "${{ github.event.inputs.build_version }}"
- name: Install dependencies
run: |
brew install create-dmg awscli sparkle
- name: Resolve Swift packages
run: |
xcodebuild -resolvePackageDependencies \
-scheme "${{ env.SCHEME }}" \
-configuration "${{ env.CONFIGURATION }}" \
-derivedDataPath "${{ env.DERIVED_DATA_PATH }}" \
-skipPackagePluginValidation \
-skipMacroValidation
- name: Patch mlx-swift Metal warnings
run: |
MLX_DIR=$(find "${{ env.DERIVED_DATA_PATH }}/SourcePackages/checkouts" -maxdepth 1 -type d -name 'mlx-swift' | head -1)
if [ -z "$MLX_DIR" ]; then
echo "Error: Could not locate mlx-swift checkout in DerivedData"
exit 1
fi
TARGET="$MLX_DIR/Source/Cmlx/mlx-generated/metal/steel/attn/kernels/steel_attention.metal"
if [ ! -f "$TARGET" ]; then
echo "Error: Could not locate steel_attention.metal in mlx-swift checkout"
exit 1
fi
if ! grep -q 'diagnostic ignored "-Wc++17-extensions"' "$TARGET"; then
chmod u+w "$TARGET"
perl -0pi -e 's@^@#pragma clang diagnostic push\n#pragma clang diagnostic ignored "-Wc++17-extensions"\n@' "$TARGET"
printf '\n#pragma clang diagnostic pop\n' >> "$TARGET"
fi
- name: Cache VVDevKit grammar build
uses: actions/cache@v4
with:
path: ${{ env.VVDEVKIT_SWIFTPM_SCRATCH_PATH }}
key: vvdevkit-grammars-${{ runner.os }}-${{ steps.xcode-version.outputs.cache_key }}-${{ hashFiles('aizen.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', '.github/workflows/release.yml') }}
restore-keys: |
vvdevkit-grammars-${{ runner.os }}-${{ steps.xcode-version.outputs.cache_key }}-
vvdevkit-grammars-${{ runner.os }}-
- name: Build VVHighlighting grammar dylibs
run: |
CACHED_COUNT=$(find "${{ env.VVDEVKIT_SWIFTPM_SCRATCH_PATH }}" -type f -name 'libTreeSitter*.dylib' -not -path '*.dSYM/*' 2>/dev/null | wc -l | tr -d ' ')
if [ "$CACHED_COUNT" -gt 0 ]; then
echo "Skipping build — $CACHED_COUNT cached VVHighlighting grammar dylibs found"
exit 0
fi
VVDEVKIT_DIR=$(find "${{ env.DERIVED_DATA_PATH }}/SourcePackages/checkouts" -maxdepth 1 -type d \( -name 'VVDevKit' -o -name 'vvdevkit' \) | head -1)
if [ -z "$VVDEVKIT_DIR" ]; then
echo "Error: Could not locate VVDevKit checkout in DerivedData"
exit 1
fi
swift build -c release \
--package-path "$VVDEVKIT_DIR" \
--scratch-path "${{ env.VVDEVKIT_SWIFTPM_SCRATCH_PATH }}"
GRAMMAR_COUNT=$(find "${{ env.VVDEVKIT_SWIFTPM_SCRATCH_PATH }}" -type f -name 'libTreeSitter*.dylib' -not -path '*.dSYM/*' | wc -l | tr -d ' ')
if [ "$GRAMMAR_COUNT" -eq 0 ]; then
echo "Error: VVDevKit build did not produce any grammar dylibs"
exit 1
fi
echo "Built $GRAMMAR_COUNT VVHighlighting grammar dylibs"
- name: Build Apple Silicon archive
run: |
xcodebuild clean archive \
-scheme "${{ env.SCHEME }}" \
-configuration "${{ env.CONFIGURATION }}" \
-derivedDataPath "${{ env.DERIVED_DATA_PATH }}" \
-archivePath build/aizen.xcarchive \
-resultBundlePath build/aizen-archive.xcresult \
-arch arm64 \
-jobs 1 \
-skipPackagePluginValidation \
-skipMacroValidation \
COMPILER_INDEX_STORE_ENABLE=NO \
OTHER_METAL_FLAGS='-Wno-c++17-extensions' \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO
- name: Dump archive diagnostics
if: ${{ failure() }}
run: |
if [ -d build/aizen-archive.xcresult ]; then
xcrun xcresulttool get --legacy --path build/aizen-archive.xcresult --format json > build/aizen-archive.json || true
tail -n 200 build/aizen-archive.json || true
fi
- name: Export app bundle
run: |
mkdir -p build/dmg
cp -R "build/aizen.xcarchive/Products/Applications/${{ env.APP_BUNDLE_NAME }}" build/dmg/
- name: Ensure VVHighlighting dylibs are bundled
run: |
APP_RESOURCES="build/dmg/${{ env.APP_BUNDLE_NAME }}/Contents/Resources"
GRAMMAR_COUNT=$(find "$APP_RESOURCES" -maxdepth 1 -type f -name 'libTreeSitter*.dylib' | wc -l | tr -d ' ')
if [ "$GRAMMAR_COUNT" -gt 0 ]; then
echo "Found $GRAMMAR_COUNT bundled VVHighlighting dylibs"
exit 0
fi
echo "No VVHighlighting dylibs found in exported app bundle, copying from DerivedData fallback"
FALLBACK_DYLIBS_FILE="$(mktemp)"
find "${{ env.DERIVED_DATA_PATH }}" -type f -name 'libTreeSitter*.dylib' -not -path '*.dSYM/*' | sort -u > "$FALLBACK_DYLIBS_FILE"
if [ ! -s "$FALLBACK_DYLIBS_FILE" ]; then
rm -f "$FALLBACK_DYLIBS_FILE"
echo "Error: Could not find VVHighlighting dylibs anywhere in DerivedData"
exit 1
fi
while IFS= read -r dylib_path; do
cp -f "$dylib_path" "$APP_RESOURCES"/
done < "$FALLBACK_DYLIBS_FILE"
rm -f "$FALLBACK_DYLIBS_FILE"
GRAMMAR_COUNT=$(find "$APP_RESOURCES" -maxdepth 1 -type f -name 'libTreeSitter*.dylib' | wc -l | tr -d ' ')
if [ "$GRAMMAR_COUNT" -eq 0 ]; then
echo "Error: VVHighlighting dylibs are still missing after fallback copy"
exit 1
fi
echo "Copied $GRAMMAR_COUNT VVHighlighting dylibs from DerivedData fallback"
- name: Read minimum macOS version
run: |
BUNDLE_NAME=$(/usr/libexec/PlistBuddy -c "Print :CFBundleName" "build/dmg/${{ env.APP_BUNDLE_NAME }}/Contents/Info.plist")
SPARKLE_BUNDLE_NAME=$(/usr/libexec/PlistBuddy -c "Print :SUBundleName" "build/dmg/${{ env.APP_BUNDLE_NAME }}/Contents/Info.plist")
MIN_SYSTEM_VERSION=$(/usr/libexec/PlistBuddy -c "Print :LSMinimumSystemVersion" "build/dmg/${{ env.APP_BUNDLE_NAME }}/Contents/Info.plist")
if [ "$BUNDLE_NAME" != "${{ env.APP_DISPLAY_NAME }}" ]; then
echo "Error: CFBundleName is '$BUNDLE_NAME' but expected '${{ env.APP_DISPLAY_NAME }}'"
exit 1
fi
if [ "$SPARKLE_BUNDLE_NAME" != "${{ env.APP_DISPLAY_NAME }}" ]; then
echo "Error: SUBundleName is '$SPARKLE_BUNDLE_NAME' but expected '${{ env.APP_DISPLAY_NAME }}'"
exit 1
fi
if [ -z "$MIN_SYSTEM_VERSION" ]; then
echo "Error: Failed to read LSMinimumSystemVersion from built app"
exit 1
fi
echo "MIN_SYSTEM_VERSION=$MIN_SYSTEM_VERSION" >> "$GITHUB_ENV"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
if: ${{ env.APPLE_CERTIFICATE != '' }}
run: |
echo "Code signing certificate found, importing..."
security create-keychain -p actions build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p actions build.keychain
security set-keychain-settings -t 3600 -u build.keychain
echo "${{ secrets.APPLE_CERTIFICATE }}" | base64 --decode > certificate.p12
security import certificate.p12 \
-k build.keychain \
-P "${{ secrets.APPLE_CERT_PASSWORD }}" \
-T /usr/bin/codesign \
-T /usr/bin/productsign
security set-key-partition-list -S apple-tool:,apple: -s -k actions build.keychain
rm certificate.p12
- name: Sign app bundle
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
if: ${{ env.APPLE_CERTIFICATE != '' }}
run: |
CERT_IDENTITY=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -1 | grep -o '"[^"]*"' | tr -d '"')
echo "Using certificate: $CERT_IDENTITY"
APP_PATH="build/dmg/${{ env.APP_BUNDLE_NAME }}"
RESOURCE_ROOT="$APP_PATH/Contents/Resources"
CLI_PATH="build/dmg/${{ env.APP_BUNDLE_NAME }}/Contents/Resources/cli/aizen-cli"
if [ -f "$CLI_PATH" ]; then
codesign --force --sign "$CERT_IDENTITY" \
--options runtime \
--timestamp \
"$CLI_PATH"
else
echo "Error: CLI binary not found at $CLI_PATH"
exit 1
fi
while IFS= read -r resource_path; do
if file -b "$resource_path" | grep -q '^Mach-O'; then
echo "Signing nested resource binary: $resource_path"
codesign --force --sign "$CERT_IDENTITY" \
--timestamp \
"$resource_path"
fi
done < <(find "$RESOURCE_ROOT" -type f ! -path "$CLI_PATH")
codesign --force --deep --sign "$CERT_IDENTITY" \
--options runtime \
--timestamp \
--entitlements aizen/aizen.entitlements \
"$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
- name: Create DMG
run: |
create-dmg \
--volname "${{ env.APP_DISPLAY_NAME }}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "${{ env.APP_BUNDLE_NAME }}" 200 190 \
--hide-extension "${{ env.APP_BUNDLE_NAME }}" \
--app-drop-link 600 185 \
"build/${{ env.APP_DISPLAY_NAME }}-${{ github.event.inputs.marketing_version }}.dmg" \
"build/dmg/" || true
if [ ! -f "build/${{ env.APP_DISPLAY_NAME }}-${{ github.event.inputs.marketing_version }}.dmg" ]; then
echo "Error: DMG creation failed"
exit 1
fi
- name: Sign DMG
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
if: ${{ env.APPLE_CERTIFICATE != '' }}
run: |
CERT_IDENTITY=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -1 | grep -o '"[^"]*"' | tr -d '"')
codesign --force --sign "$CERT_IDENTITY" \
--timestamp \
"build/${{ env.APP_DISPLAY_NAME }}-${{ github.event.inputs.marketing_version }}.dmg"
- name: Notarize DMG
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
if: ${{ env.APPLE_CERTIFICATE != '' && env.APPLE_ID != '' && env.APPLE_APP_PASSWORD != '' }}
run: |
NOTARY_PLIST="$(mktemp)"
xcrun notarytool submit \
"build/${{ env.APP_DISPLAY_NAME }}-${{ github.event.inputs.marketing_version }}.dmg" \
--apple-id "${{ secrets.APPLE_ID }}" \
--team-id "${{ secrets.APPLE_TEAM_ID }}" \
--password "${{ secrets.APPLE_APP_PASSWORD }}" \
--wait \
--output-format plist > "$NOTARY_PLIST"
cat "$NOTARY_PLIST"
NOTARY_ID=$(/usr/libexec/PlistBuddy -c "Print :id" "$NOTARY_PLIST" 2>/dev/null || true)
NOTARY_STATUS=$(/usr/libexec/PlistBuddy -c "Print :status" "$NOTARY_PLIST" 2>/dev/null || true)
rm -f "$NOTARY_PLIST"
if [ -z "$NOTARY_ID" ]; then
echo "Error: Notary submission did not return an id"
exit 1
fi
echo "NOTARY_REQUEST_ID=$NOTARY_ID" >> "$GITHUB_ENV"
echo "Notary status: $NOTARY_STATUS"
if [ "$NOTARY_STATUS" != "Accepted" ]; then
LOG_PATH="build/notary-log.json"
xcrun notarytool log "$NOTARY_ID" \
--apple-id "${{ secrets.APPLE_ID }}" \
--team-id "${{ secrets.APPLE_TEAM_ID }}" \
--password "${{ secrets.APPLE_APP_PASSWORD }}" > "$LOG_PATH" || true
if [ -s "$LOG_PATH" ]; then
cat "$LOG_PATH"
fi
echo "Error: Notary status is not Accepted"
exit 1
fi
- name: Staple notarization
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
if: ${{ env.APPLE_CERTIFICATE != '' && env.APPLE_ID != '' && env.APPLE_APP_PASSWORD != '' }}
run: |
DMG_PATH="build/${{ env.APP_DISPLAY_NAME }}-${{ github.event.inputs.marketing_version }}.dmg"
xcrun stapler staple -v "$DMG_PATH"
xcrun stapler validate -v "$DMG_PATH"
- name: Generate Sparkle signature
run: |
echo "${{ secrets.SPARKLE_PRIVATE_KEY }}" > sparkle_priv.pem
if [ ! -s sparkle_priv.pem ]; then
echo "Error: Private key is empty"
exit 1
fi
SIGN_UPDATE=$(find /opt/homebrew/Caskroom/sparkle -name sign_update -type f 2>/dev/null | grep -v old_dsa | grep -v dSYM | head -1)
if [ -z "$SIGN_UPDATE" ]; then
echo "Error: sign_update binary not found"
exit 1
fi
SIGNATURE=$("$SIGN_UPDATE" --ed-key-file sparkle_priv.pem -p "build/${{ env.APP_DISPLAY_NAME }}-${{ github.event.inputs.marketing_version }}.dmg")
if [ -z "$SIGNATURE" ]; then
echo "Error: sign_update returned empty signature"
exit 1
fi
echo "SPARKLE_SIGNATURE=$SIGNATURE" >> $GITHUB_ENV
- name: Generate appcast
env:
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
SPARKLE_PRIVATE_KEY_FILE: sparkle_priv.pem
run: |
chmod +x scripts/generate-appcast.sh
scripts/generate-appcast.sh \
"build/${{ env.APP_DISPLAY_NAME }}-${{ github.event.inputs.marketing_version }}.dmg" \
"${{ github.event.inputs.build_version }}" \
"${{ github.event.inputs.marketing_version }}" \
"${{ github.event.inputs.release_notes }}" \
"${{ env.MIN_SYSTEM_VERSION }}"
- name: Upload to Cloudflare R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
run: |
aws s3 cp "build/Aizen-${{ github.event.inputs.marketing_version }}.dmg" \
"s3://${{ secrets.R2_BUCKET_NAME }}/Aizen-${{ github.event.inputs.marketing_version }}.dmg" \
--endpoint-url "${{ secrets.R2_ENDPOINT }}" \
--region auto
aws s3 cp appcast.xml "s3://${{ secrets.R2_BUCKET_NAME }}/appcast.xml" \
--endpoint-url "${{ secrets.R2_ENDPOINT }}" \
--region auto \
--content-type "application/xml"
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ github.event.inputs.marketing_version }}
name: Release ${{ github.event.inputs.marketing_version }}
body: ${{ github.event.inputs.release_notes }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cleanup
if: always()
run: |
rm -f sparkle_priv.pem
if security list-keychains | grep -q build.keychain; then
security delete-keychain build.keychain
fi
- name: Summary
run: |
echo "## Release Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version:** ${{ github.event.inputs.marketing_version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Build:** ${{ github.event.inputs.build_version }}" >> $GITHUB_STEP_SUMMARY
echo "- **DMG:** ${{ secrets.R2_PUBLIC_URL }}/Aizen-${{ github.event.inputs.marketing_version }}.dmg" >> $GITHUB_STEP_SUMMARY