* Migrate all workflows from self-hosted Mac Mini to Depot runners Move CI, nightly, and release workflows to depot-macos-latest. Replace zig GhosttyKit builds with pre-built xcframework downloads. Add virtual display for CI UI tests. Remove concurrency groups (ephemeral VMs don't need them). * Add per-test timeout to CI UI tests to prevent hangs on Depot SidebarResizeUITests hangs on headless Depot runners due to mouse drag simulation issues. Adding -maximum-test-execution-time-allowance 120 (matching test-depot.yml) ensures individual tests timeout after 2 min instead of blocking the entire run. * Skip SidebarResizeUITests in CI on Depot runners Mouse drag simulation hangs on headless Depot runners even with a virtual display. The per-test timeout doesn't prevent the hang either. Skip this test class in CI; it still runs fine on local machines. * Handle XCTExpectFailure in CI UI tests (exit 65 with 0 unexpected) xcodebuild exits 65 even when all failures use XCTExpectFailure. Add the same expected-failure handling from the unit test step so browser focus tests (which are expected to fail on headless runners) don't break CI.
290 lines
13 KiB
YAML
290 lines
13 KiB
YAML
name: Release macOS app
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- "v*"
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
env:
|
|
CREATE_DMG_VERSION: 8.0.0
|
|
|
|
jobs:
|
|
build-sign-notarize:
|
|
runs-on: depot-macos-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
submodules: recursive
|
|
|
|
- name: Guard immutable release assets
|
|
id: guard_release_assets
|
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
with:
|
|
script: |
|
|
const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard');
|
|
const tag = context.ref.replace('refs/tags/', '');
|
|
core.setOutput('skip_all', 'false');
|
|
core.setOutput('skip_upload', 'false');
|
|
core.setOutput('release_state', 'clear');
|
|
try {
|
|
const release = await github.rest.repos.getReleaseByTag({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
tag,
|
|
});
|
|
const existingAssetNames = (release.data.assets || []).map((asset) => asset.name);
|
|
const {
|
|
conflicts,
|
|
missingImmutableAssets,
|
|
guardState,
|
|
hasPartialConflict,
|
|
shouldSkipBuildAndUpload,
|
|
} = evaluateReleaseAssetGuard({ existingAssetNames });
|
|
|
|
core.setOutput('release_state', guardState);
|
|
|
|
if (hasPartialConflict) {
|
|
core.setFailed(
|
|
`Release ${tag} has a partial immutable asset state. Existing immutable assets: ` +
|
|
`${conflicts.join(', ')}. Missing immutable assets: ${missingImmutableAssets.join(', ')}. ` +
|
|
'Resolve release assets manually before rerunning.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (shouldSkipBuildAndUpload) {
|
|
core.notice(
|
|
`Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` +
|
|
'Skipping build, notarization, and upload to preserve existing signed artifacts.'
|
|
);
|
|
core.setOutput('skip_all', 'true');
|
|
core.setOutput('skip_upload', 'true');
|
|
return;
|
|
}
|
|
|
|
core.notice(`Release ${tag} exists but has no immutable release assets yet; continuing.`);
|
|
} catch (error) {
|
|
if (error.status === 404) {
|
|
core.notice(`Release ${tag} does not exist yet; safe to build and publish assets.`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
- name: Select Xcode
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
|
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
|
|
else
|
|
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
|
|
if [ -n "$XCODE_APP" ]; then
|
|
XCODE_DIR="$XCODE_APP/Contents/Developer"
|
|
else
|
|
echo "No Xcode.app found under /Applications" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
|
|
export DEVELOPER_DIR="$XCODE_DIR"
|
|
xcodebuild -version
|
|
xcrun --sdk macosx --show-sdk-path
|
|
|
|
- name: Install build deps
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
run: |
|
|
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
|
|
|
|
- name: Download pre-built GhosttyKit.xcframework
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
|
|
TAG="xcframework-$GHOSTTY_SHA"
|
|
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
|
|
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
|
|
MAX_RETRIES=30
|
|
RETRY_DELAY=20
|
|
for i in $(seq 1 $MAX_RETRIES); do
|
|
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
|
|
echo "Download succeeded on attempt $i"
|
|
break
|
|
fi
|
|
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
|
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
|
|
exit 1
|
|
fi
|
|
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
|
|
sleep $RETRY_DELAY
|
|
done
|
|
tar xzf GhosttyKit.xcframework.tar.gz
|
|
rm GhosttyKit.xcframework.tar.gz
|
|
test -d GhosttyKit.xcframework
|
|
|
|
- name: Derive Sparkle public key from private key
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
env:
|
|
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
|
run: |
|
|
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
|
|
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
|
|
exit 1
|
|
fi
|
|
DERIVED_PUBLIC_KEY=$(swift scripts/derive_sparkle_public_key.swift "$SPARKLE_PRIVATE_KEY")
|
|
echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY"
|
|
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
|
|
|
|
- name: Build app (Release)
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
run: |
|
|
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
|
|
|
|
- name: Inject Sparkle keys into Info.plist
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
run: |
|
|
APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist"
|
|
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true
|
|
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$APP_PLIST" >/dev/null 2>&1 || true
|
|
echo "Adding SUPublicEDKey to Info.plist..."
|
|
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST"
|
|
echo "Adding SUFeedURL to Info.plist..."
|
|
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" "$APP_PLIST"
|
|
echo "Verifying:"
|
|
/usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" "$APP_PLIST"
|
|
/usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST"
|
|
|
|
- name: Import signing cert
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
env:
|
|
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
run: |
|
|
if [ -z "$APPLE_CERTIFICATE_BASE64" ]; then
|
|
echo "Missing APPLE_CERTIFICATE_BASE64 secret" >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then
|
|
echo "Missing APPLE_CERTIFICATE_PASSWORD secret" >&2
|
|
exit 1
|
|
fi
|
|
KEYCHAIN_PASSWORD="$(uuidgen)"
|
|
echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > /tmp/cert.p12
|
|
security delete-keychain build.keychain >/dev/null 2>&1 || true
|
|
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
|
security set-keychain-settings -lut 21600 build.keychain
|
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
|
security import /tmp/cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
|
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
|
security list-keychains -d user -s build.keychain
|
|
|
|
- name: Codesign app
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
env:
|
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
run: |
|
|
if [ -z "$APPLE_SIGNING_IDENTITY" ]; then
|
|
echo "Missing APPLE_SIGNING_IDENTITY secret" >&2
|
|
exit 1
|
|
fi
|
|
APP_PATH="build/Build/Products/Release/cmux.app"
|
|
ENTITLEMENTS="cmux.entitlements"
|
|
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
|
if [ -f "$CLI_PATH" ]; then
|
|
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
|
|
fi
|
|
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
|
|
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
|
|
|
- name: Notarize app
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
env:
|
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
run: |
|
|
if [ -z "$APPLE_ID" ] || [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then
|
|
echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2
|
|
exit 1
|
|
fi
|
|
APP_PATH="build/Build/Products/Release/cmux.app"
|
|
ZIP_SUBMIT="cmux-notary.zip"
|
|
DMG_RELEASE="cmux-macos.dmg"
|
|
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT"
|
|
APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
|
|
APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")"
|
|
APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")"
|
|
if [ "$APP_STATUS" != "Accepted" ]; then
|
|
echo "App notarization failed with status: $APP_STATUS" >&2
|
|
xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
|
|
exit 1
|
|
fi
|
|
xcrun stapler staple "$APP_PATH"
|
|
xcrun stapler validate "$APP_PATH"
|
|
spctl -a -vv --type execute "$APP_PATH"
|
|
rm -f "$ZIP_SUBMIT"
|
|
# create-dmg generates a styled drag-to-install DMG
|
|
create-dmg \
|
|
--identity="$APPLE_SIGNING_IDENTITY" \
|
|
"$APP_PATH" \
|
|
./
|
|
mv ./cmux*.dmg "$DMG_RELEASE"
|
|
DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
|
|
DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")"
|
|
DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")"
|
|
if [ "$DMG_STATUS" != "Accepted" ]; then
|
|
echo "DMG notarization failed with status: $DMG_STATUS" >&2
|
|
xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
|
|
exit 1
|
|
fi
|
|
xcrun stapler staple "$DMG_RELEASE"
|
|
xcrun stapler validate "$DMG_RELEASE"
|
|
|
|
- name: Upload dSYMs to Sentry
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
env:
|
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
SENTRY_ORG: manaflow
|
|
SENTRY_PROJECT: cmuxterm-macos
|
|
run: |
|
|
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
|
|
echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload"
|
|
exit 0
|
|
fi
|
|
brew install getsentry/tools/sentry-cli || true
|
|
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
|
|
|
|
- name: Generate Sparkle appcast
|
|
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
|
env:
|
|
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
|
run: |
|
|
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
|
|
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
|
|
exit 1
|
|
fi
|
|
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml
|
|
|
|
- name: Upload release asset
|
|
if: steps.guard_release_assets.outputs.skip_upload != 'true'
|
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
|
with:
|
|
files: |
|
|
cmux-macos.dmg
|
|
appcast.xml
|
|
generate_release_notes: true
|
|
overwrite_files: false
|
|
|
|
- name: Cleanup keychain
|
|
if: always()
|
|
run: |
|
|
security delete-keychain build.keychain >/dev/null 2>&1 || true
|
|
rm -f /tmp/cert.p12
|