* 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.
372 lines
16 KiB
YAML
372 lines
16 KiB
YAML
name: Nightly macOS build
|
|
|
|
on:
|
|
schedule:
|
|
# Every hour at :30. The 'decide' job skips if main has no new commits.
|
|
- cron: "30 * * * *"
|
|
workflow_dispatch:
|
|
inputs:
|
|
force:
|
|
description: Force a nightly build even if main has no new commits
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
env:
|
|
CREATE_DMG_VERSION: 8.0.0
|
|
|
|
jobs:
|
|
decide:
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
should_build: ${{ steps.decide.outputs.should_build }}
|
|
head_sha: ${{ steps.decide.outputs.head_sha }}
|
|
short_sha: ${{ steps.decide.outputs.short_sha }}
|
|
steps:
|
|
- name: Decide whether a nightly build is needed
|
|
id: decide
|
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
env:
|
|
FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }}
|
|
with:
|
|
script: |
|
|
const forceBuild = process.env.FORCE_BUILD === 'true';
|
|
const { owner, repo } = context.repo;
|
|
|
|
const branch = await github.rest.repos.getBranch({
|
|
owner,
|
|
repo,
|
|
branch: 'main',
|
|
});
|
|
const headSha = branch.data.commit.sha;
|
|
|
|
let nightlySha = null;
|
|
try {
|
|
const ref = await github.rest.git.getRef({
|
|
owner,
|
|
repo,
|
|
ref: 'tags/nightly',
|
|
});
|
|
if (ref.data.object.type === 'commit') {
|
|
nightlySha = ref.data.object.sha;
|
|
} else if (ref.data.object.type === 'tag') {
|
|
const tagObject = await github.rest.git.getTag({
|
|
owner,
|
|
repo,
|
|
tag_sha: ref.data.object.sha,
|
|
});
|
|
nightlySha = tagObject.data.object.sha;
|
|
}
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
}
|
|
|
|
const shouldBuild = forceBuild || nightlySha !== headSha;
|
|
core.setOutput('should_build', shouldBuild ? 'true' : 'false');
|
|
core.setOutput('head_sha', headSha);
|
|
core.setOutput('short_sha', headSha.slice(0, 7));
|
|
core.summary
|
|
.addHeading('Nightly build decision')
|
|
.addTable([
|
|
[{data: 'main HEAD', header: true}, headSha],
|
|
[{data: 'nightly tag', header: true}, nightlySha ?? '(missing)'],
|
|
[{data: 'force build', header: true}, String(forceBuild)],
|
|
[{data: 'should build', header: true}, String(shouldBuild)],
|
|
])
|
|
.write();
|
|
|
|
build-sign-notarize-nightly:
|
|
needs: decide
|
|
if: needs.decide.outputs.should_build == 'true'
|
|
runs-on: depot-macos-latest
|
|
steps:
|
|
- name: Checkout main
|
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
ref: ${{ needs.decide.outputs.head_sha }}
|
|
submodules: recursive
|
|
|
|
- name: Select Xcode
|
|
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
|
|
run: |
|
|
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
|
|
|
|
- name: Download pre-built GhosttyKit.xcframework
|
|
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: Configure SwiftPM cache
|
|
run: |
|
|
set -euo pipefail
|
|
CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}"
|
|
rm -rf "$CACHE_DIR"
|
|
mkdir -p "$CACHE_DIR"
|
|
echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV"
|
|
|
|
- name: Derive Sparkle public key from private key
|
|
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)
|
|
run: |
|
|
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
|
|
|
|
- name: Inject nightly identity and metadata
|
|
run: |
|
|
set -euo pipefail
|
|
APP_DIR="build/Build/Products/Release"
|
|
APP_PLIST="${APP_DIR}/cmux.app/Contents/Info.plist"
|
|
SHORT_SHA="${{ needs.decide.outputs.short_sha }}"
|
|
|
|
# --- Separate app identity: "cmux NIGHTLY" with its own bundle ID ---
|
|
/usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$APP_PLIST"
|
|
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$APP_PLIST"
|
|
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.cmuxterm.app.nightly" "$APP_PLIST"
|
|
|
|
# Rename the .app bundle to match the display name
|
|
mv "${APP_DIR}/cmux.app" "${APP_DIR}/cmux NIGHTLY.app"
|
|
|
|
# Update plist path after rename
|
|
APP_PLIST="${APP_DIR}/cmux NIGHTLY.app/Contents/Info.plist"
|
|
|
|
# --- Sparkle: point at the nightly appcast ---
|
|
/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
|
|
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST"
|
|
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" "$APP_PLIST"
|
|
|
|
# Marketing version: append -nightly.YYYYMMDD so users can identify the channel and date
|
|
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST")
|
|
NIGHTLY_DATE=$(date -u +%Y%m%d)
|
|
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$APP_PLIST"
|
|
|
|
# Build number: unique/monotonic per workflow run attempt so same-day
|
|
# nightlies and reruns still compare as newer in Sparkle.
|
|
if [ -n "${GITHUB_RUN_ID:-}" ]; then
|
|
RUN_ATTEMPT="$(printf '%02d' "${GITHUB_RUN_ATTEMPT:-1}")"
|
|
NIGHTLY_BUILD="${GITHUB_RUN_ID}${RUN_ATTEMPT}"
|
|
else
|
|
NIGHTLY_BUILD="${NIGHTLY_DATE}000000"
|
|
fi
|
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$APP_PLIST"
|
|
|
|
# Use an immutable DMG filename in appcast URLs so old appcasts keep
|
|
# pointing at matching archives while nightly assets roll forward.
|
|
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
|
|
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
|
|
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
|
|
|
|
# Embed commit SHA for bug reports
|
|
/usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true
|
|
/usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST"
|
|
|
|
echo "Nightly app name: cmux NIGHTLY"
|
|
echo "Nightly bundle ID: com.cmuxterm.app.nightly"
|
|
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
|
|
echo "Nightly build number: ${NIGHTLY_BUILD}"
|
|
echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
|
|
echo "Commit SHA: ${SHORT_SHA}"
|
|
|
|
- name: Import signing cert
|
|
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
|
|
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 NIGHTLY.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 and dmg
|
|
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 NIGHTLY.app"
|
|
ZIP_SUBMIT="cmux-nightly-notary.zip"
|
|
DMG_RELEASE="cmux-nightly-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 \
|
|
--identity="$APPLE_SIGNING_IDENTITY" \
|
|
"$APP_PATH" \
|
|
./
|
|
mv ./"cmux NIGHTLY"*.dmg "$DMG_RELEASE" 2>/dev/null || 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"
|
|
|
|
# Keep a stable filename for humans and an immutable filename used
|
|
# by appcast URLs to prevent signature/asset mismatch races.
|
|
cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE"
|
|
|
|
- name: Upload dSYMs to Sentry
|
|
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 (nightly)
|
|
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 "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml
|
|
|
|
- name: Move nightly tag to built commit
|
|
run: |
|
|
set -euo pipefail
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
git tag -f nightly "${{ needs.decide.outputs.head_sha }}"
|
|
git push origin refs/tags/nightly --force
|
|
|
|
- name: Publish nightly release assets
|
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
|
with:
|
|
tag_name: nightly
|
|
name: Nightly
|
|
prerelease: true
|
|
make_latest: false
|
|
body: |
|
|
Automated nightly build for `${{ needs.decide.outputs.short_sha }}`.
|
|
|
|
**cmux NIGHTLY** is a separate app (bundle ID `com.cmuxterm.app.nightly`) that can be installed alongside the stable release. It receives nightly updates automatically via its own Sparkle feed.
|
|
|
|
[Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
|
files: |
|
|
cmux-nightly-macos-${{ github.run_id }}*.dmg
|
|
cmux-nightly-macos.dmg
|
|
appcast.xml
|
|
overwrite_files: true
|
|
|
|
- name: Cleanup keychain
|
|
if: always()
|
|
run: |
|
|
security delete-keychain build.keychain >/dev/null 2>&1 || true
|
|
rm -f /tmp/cert.p12
|
|
|
|
skipped:
|
|
needs: decide
|
|
if: needs.decide.outputs.should_build != 'true'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: No nightly build needed
|
|
run: |
|
|
echo "No changes on main since last nightly tag; skipping build."
|