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: self-hosted concurrency: group: self-hosted-build cancel-in-progress: false 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: | brew update brew install zig npm install --global "create-dmg@${CREATE_DMG_VERSION}" - name: Build GhosttyKit.xcframework run: | cd ghostty zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast cd .. rm -rf GhosttyKit.xcframework cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework - name: Clear SPM cache run: | rm -rf ~/Library/Caches/org.swift.swiftpm rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - 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."