name: Nightly macOS build on: push: branches: [main] workflow_dispatch: inputs: force: description: Force a nightly build even if main has no new commits required: false default: false type: boolean concurrency: group: nightly-build-${{ github.ref_name }} cancel-in-progress: true 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 }} should_publish: ${{ steps.decide.outputs.should_publish }} 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 requestedRef = context.ref.startsWith('refs/heads/') ? context.ref.replace('refs/heads/', '') : 'main'; const isMainRef = requestedRef === 'main'; let headSha = context.sha; if (isMainRef) { const branch = await github.rest.repos.getBranch({ owner, repo, branch: 'main', }); headSha = branch.data.commit.sha; } let nightlySha = null; if (isMainRef) { 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 = !isMainRef || forceBuild || nightlySha !== headSha; core.setOutput('should_build', shouldBuild ? 'true' : 'false'); core.setOutput('head_sha', headSha); core.setOutput('short_sha', headSha.slice(0, 7)); core.setOutput('should_publish', isMainRef ? 'true' : 'false'); core.summary .addHeading('Nightly build decision') .addTable([ [{data: 'requested ref', header: true}, requestedRef], [{data: 'build 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)], [{data: 'should publish', header: true}, String(isMainRef)], ]) .write(); build-sign-notarize-nightly: needs: decide if: needs.decide.outputs.should_build == 'true' runs-on: macos-15 steps: - name: Checkout build ref 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 run: | ./scripts/download-prebuilt-ghosttykit.sh - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .spm-cache key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- - 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 Apple Silicon app (Release) run: | xcodebuild -scheme cmux -configuration Release -derivedDataPath build-arm \ -destination 'platform=macOS,arch=arm64' \ -clonedSourcePackagesDirPath .spm-cache \ ARCHS="arm64" \ ONLY_ACTIVE_ARCH=YES \ CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build - name: Build universal app (Release) run: | xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \ -destination 'generic/platform=macOS' \ -clonedSourcePackagesDirPath .spm-cache \ ARCHS="arm64 x86_64" \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build - name: Verify nightly binary architectures run: | set -euo pipefail ARM_APP_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/MacOS/cmux" ARM_CLI_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" ARM_APP_ARCHS="$(lipo -archs "$ARM_APP_BINARY")" ARM_CLI_ARCHS="$(lipo -archs "$ARM_CLI_BINARY")" APP_ARCHS="$(lipo -archs "$APP_BINARY")" CLI_ARCHS="$(lipo -archs "$CLI_BINARY")" echo "Arm app binary architectures: $ARM_APP_ARCHS" echo "Arm CLI binary architectures: $ARM_CLI_ARCHS" echo "App binary architectures: $APP_ARCHS" echo "CLI binary architectures: $CLI_ARCHS" [[ "$ARM_APP_ARCHS" == "arm64" ]] [[ "$ARM_CLI_ARCHS" == "arm64" ]] [[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]] [[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]] - name: Inject nightly identities and metadata run: | set -euo pipefail SHORT_SHA="${{ needs.decide.outputs.short_sha }}" ARM_APP_DIR="build-arm/Build/Products/Release" UNIVERSAL_APP_DIR="build-universal/Build/Products/Release" BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${ARM_APP_DIR}/cmux.app/Contents/Info.plist") NIGHTLY_DATE=$(date -u +%Y%m%d) # 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 echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg" echo "NIGHTLY_DMG_IMMUTABLE=${ARM_DMG_IMMUTABLE}" >> "$GITHUB_ENV" echo "NIGHTLY_UNIVERSAL_DMG_IMMUTABLE=${UNIVERSAL_DMG_IMMUTABLE}" >> "$GITHUB_ENV" prepare_variant() { local app_dir="$1" local bundle_id="$2" local feed_url="$3" local app_plist="$app_dir/cmux.app/Contents/Info.plist" /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 ${bundle_id}" "$app_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 /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist" /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${feed_url}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$app_plist" /usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$app_plist" mv "$app_dir/cmux.app" "$app_dir/cmux NIGHTLY.app" } prepare_variant \ "$ARM_APP_DIR" \ "com.cmuxterm.app.nightly" \ "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" prepare_variant \ "$UNIVERSAL_APP_DIR" \ "com.cmuxterm.app.nightly.universal" \ "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast-universal.xml" echo "Nightly app name: cmux NIGHTLY" echo "Nightly arm64 bundle ID: com.cmuxterm.app.nightly" echo "Nightly universal bundle ID: com.cmuxterm.app.nightly.universal" echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" echo "Nightly build number: ${NIGHTLY_BUILD}" echo "Nightly arm64 immutable DMG: ${ARM_DMG_IMMUTABLE}" echo "Nightly universal immutable DMG: ${UNIVERSAL_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 apps 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 ENTITLEMENTS="cmux.entitlements" for APP_PATH in \ "build-arm/Build/Products/Release/cmux NIGHTLY.app" \ "build-universal/Build/Products/Release/cmux NIGHTLY.app" do 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" done - name: Notarize apps and dmgs 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 notarize_and_package() { local app_path="$1" local dmg_release="$2" local dmg_immutable="$3" local zip_submit="${dmg_release%.dmg}-notary.zip" local dmg_tmp_dir local created_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 for $app_path 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" dmg_tmp_dir="$(mktemp -d)" create-dmg \ --identity="$APPLE_SIGNING_IDENTITY" \ "$app_path" \ "$dmg_tmp_dir" created_dmg="$(find "$dmg_tmp_dir" -maxdepth 1 -name '*.dmg' | head -n 1)" if [ -z "$created_dmg" ]; then echo "Failed to locate created DMG for $app_path" >&2 exit 1 fi mv "$created_dmg" "$dmg_release" rm -rf "$dmg_tmp_dir" 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 for $dmg_release 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" cp "$dmg_release" "$dmg_immutable" } notarize_and_package \ "build-arm/Build/Products/Release/cmux NIGHTLY.app" \ "cmux-nightly-macos.dmg" \ "$NIGHTLY_DMG_IMMUTABLE" notarize_and_package \ "build-universal/Build/Products/Release/cmux NIGHTLY.app" \ "cmux-nightly-universal-macos.dmg" \ "$NIGHTLY_UNIVERSAL_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-arm/Build/Products/Release/ \ build-universal/Build/Products/Release/ - name: Generate Sparkle appcasts (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 ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml - name: Upload branch nightly artifacts if: needs.decide.outputs.should_publish != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: cmux-nightly-${{ needs.decide.outputs.short_sha }} path: | cmux-nightly-macos*.dmg cmux-nightly-universal-macos*.dmg appcast.xml appcast-universal.xml if-no-files-found: error - name: Move nightly tag to built commit if: needs.decide.outputs.should_publish == 'true' 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 if: needs.decide.outputs.should_publish == 'true' 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** has two update tracks: - Apple Silicon: bundle ID `com.cmuxterm.app.nightly`, feed `appcast.xml` - Universal: bundle ID `com.cmuxterm.app.nightly.universal`, feed `appcast-universal.xml` [Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) [Download cmux-nightly-universal-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-universal-macos.dmg) files: | cmux-nightly-macos-${{ github.run_id }}*.dmg cmux-nightly-macos.dmg cmux-nightly-universal-macos-${{ github.run_id }}*.dmg cmux-nightly-universal-macos.dmg appcast.xml appcast-universal.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