diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index 1f2fb84f..ec787452 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -69,7 +69,7 @@ jobs: exit 1 fi fi - cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast + cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast - name: Package xcframework if: steps.check-release.outputs.exists == 'false' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 18caadc3..f645b45c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,7 +12,7 @@ on: type: boolean concurrency: - group: nightly-build + group: nightly-build-${{ github.ref_name }} cancel-in-progress: true permissions: @@ -28,6 +28,7 @@ jobs: 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 @@ -38,46 +39,58 @@ jobs: 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'; - 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({ + let headSha = context.sha; + if (isMainRef) { + const branch = await github.rest.repos.getBranch({ owner, repo, - ref: 'tags/nightly', + branch: 'main', }); - 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; + headSha = branch.data.commit.sha; } - const shouldBuild = forceBuild || nightlySha !== headSha; + 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: 'main HEAD', header: true}, headSha], + [{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(); @@ -86,7 +99,7 @@ jobs: if: needs.decide.outputs.should_build == 'true' runs-on: macos-15 steps: - - name: Checkout main + - name: Checkout build ref uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.decide.outputs.head_sha }} @@ -138,40 +151,53 @@ jobs: echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY" echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - - name: Build app (Release) + - name: Build Apple Silicon app (Release) run: | - xcodebuild -scheme cmux -configuration Release -derivedDataPath build \ + 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: Inject nightly identity and metadata + - 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 - APP_DIR="build/Build/Products/Release" - APP_PLIST="${APP_DIR}/cmux.app/Contents/Info.plist" SHORT_SHA="${{ needs.decide.outputs.short_sha }}" + ARM_APP_DIR="build-arm/Build/Products/Release" + UNIVERSAL_APP_DIR="build-universal/Build/Products/Release" - # --- 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") + BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${ARM_APP_DIR}/cmux.app/Contents/Info.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. @@ -181,23 +207,49 @@ jobs: 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" + 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 bundle ID: com.cmuxterm.app.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 immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}" + 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 @@ -223,7 +275,7 @@ jobs: 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 + - name: Codesign apps env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -231,16 +283,20 @@ jobs: 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" + 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 app and dmg + - name: Notarize apps and dmgs env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} @@ -251,41 +307,62 @@ jobs: 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" + 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 - # 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" + 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: @@ -298,9 +375,11 @@ jobs: exit 0 fi brew install getsentry/tools/sentry-cli || true - sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + sentry-cli debug-files upload --include-sources \ + build-arm/Build/Products/Release/ \ + build-universal/Build/Products/Release/ - - name: Generate Sparkle appcast (nightly) + - name: Generate Sparkle appcasts (nightly) env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -309,8 +388,22 @@ jobs: 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]" @@ -319,6 +412,7 @@ jobs: 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 @@ -328,13 +422,19 @@ jobs: 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. + **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 diff --git a/CLAUDE.md b/CLAUDE.md index e1c1c942..d2cba8c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -des When rebuilding GhosttyKit.xcframework, always use Release optimizations: ```bash -cd ghostty && zig build -Demit-xcframework=true -Doptimize=ReleaseFast +cd ghostty && zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast ``` When rebuilding cmuxd for release/bundling, always use ReleaseFast: diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index fade9fc0..444419f2 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -784,6 +784,7 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -859,7 +860,7 @@ "-framework", Carbon, ); - ONLY_ACTIVE_ARCH = YES; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app; PRODUCT_NAME = cmux; SPARKLE_PUBLIC_KEY = "avjcgKibf1FTvhIjLBxhd+0HSpsXU4D0IGlVk8cgqRc="; @@ -901,6 +902,7 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; + ONLY_ACTIVE_ARCH = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; @@ -932,7 +934,7 @@ GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.61.0; - ONLY_ACTIVE_ARCH = YES; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -968,7 +970,7 @@ GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.61.0; - ONLY_ACTIVE_ARCH = YES; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index ac870d30..08d1f84c 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -61,7 +61,7 @@ echo "Pre-flight checks passed" # --- Build GhosttyKit (if needed) --- if [ ! -d "GhosttyKit.xcframework" ]; then echo "Building GhosttyKit..." - cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast && cd .. + cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast && cd .. rm -rf GhosttyKit.xcframework cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework else diff --git a/scripts/setup.sh b/scripts/setup.sh index bcfeb818..7384ef62 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -58,7 +58,7 @@ else echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." ( cd ghostty - zig build -Demit-xcframework=true -Doptimize=ReleaseFast + zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast ) # Stamp the build output with the SHA it was built from echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP" diff --git a/scripts/sparkle_generate_appcast.sh b/scripts/sparkle_generate_appcast.sh index bfcfb64a..644562bd 100755 --- a/scripts/sparkle_generate_appcast.sh +++ b/scripts/sparkle_generate_appcast.sh @@ -70,14 +70,23 @@ while (( ${#padded_key} % 4 != 0 )); do done printf "%s" "$padded_key" > "$key_file" +generated_appcast_path="$archives_dir/$(basename "$OUT_PATH")" + "$generate_appcast" \ --ed-key-file "$key_file" \ --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ --full-release-notes-url "$RELEASE_NOTES_URL" \ "$archives_dir" -if [[ ! -f "$archives_dir/appcast.xml" ]]; then - echo "appcast.xml not generated." >&2 +if [[ ! -f "$generated_appcast_path" ]]; then + fallback_generated_appcast="$(find "$archives_dir" -maxdepth 1 -name '*.xml' | head -n 1)" + if [[ -n "$fallback_generated_appcast" ]]; then + generated_appcast_path="$fallback_generated_appcast" + fi +fi + +if [[ ! -f "$generated_appcast_path" ]]; then + echo "Expected appcast was not generated." >&2 exit 1 fi @@ -85,7 +94,7 @@ fi # to sign the DMG and inject the signature. generate_appcast silently skips # signing when the public key derived from the private key doesn't match the # SUPublicEDKey in the app's Info.plist. -if ! grep -q 'sparkle:edSignature' "$archives_dir/appcast.xml"; then +if ! grep -q 'sparkle:edSignature' "$generated_appcast_path"; then echo "Warning: generate_appcast did not add edSignature. Using sign_update fallback..." SIGNATURE=$("$sign_update" -p --ed-key-file "$key_file" "$DMG_PATH") DMG_LENGTH=$(stat -f%z "$DMG_PATH") @@ -95,7 +104,7 @@ if ! grep -q 'sparkle:edSignature' "$archives_dir/appcast.xml"; then # Inject sparkle:edSignature and correct length into the enclosure element python3 -c " import sys -xml = open('$archives_dir/appcast.xml').read() +xml = open('$generated_appcast_path').read() sig = '$SIGNATURE' length = '$DMG_LENGTH' # Add edSignature to enclosure @@ -103,12 +112,12 @@ xml = xml.replace( 'type=\"application/octet-stream\"', 'sparkle:edSignature=\"' + sig + '\" length=\"' + length + '\" type=\"application/octet-stream\"' ) -open('$archives_dir/appcast.xml', 'w').write(xml) +open('$generated_appcast_path', 'w').write(xml) print(' Injected edSignature into appcast.xml') " fi -cp "$archives_dir/appcast.xml" "$OUT_PATH" +cp "$generated_appcast_path" "$OUT_PATH" echo "Generated appcast at $OUT_PATH" # Verify the appcast has a signature diff --git a/tests/test_ci_universal_release_settings.sh b/tests/test_ci_universal_release_settings.sh new file mode 100644 index 00000000..634a015d --- /dev/null +++ b/tests/test_ci_universal_release_settings.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression test for universal GhosttyKit and Release build settings. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +for file in \ + "$ROOT_DIR/.github/workflows/build-ghosttykit.yml" \ + "$ROOT_DIR/scripts/setup.sh" \ + "$ROOT_DIR/scripts/build-sign-upload.sh" +do + if ! grep -Fq -- '-Dxcframework-target=universal' "$file"; then + echo "FAIL: $file must build GhosttyKit with -Dxcframework-target=universal" + exit 1 + fi +done + +if ! awk ' + /\/\* Release \*\// { in_release=1; next } + in_release && /ONLY_ACTIVE_ARCH = YES;/ { saw_yes=1 } + in_release && /ONLY_ACTIVE_ARCH = NO;/ { saw_no=1 } + in_release && /name = Release;/ { in_release=0 } + END { exit !(saw_no && !saw_yes) } +' "$ROOT_DIR/GhosttyTabs.xcodeproj/project.pbxproj"; then + echo "FAIL: Release configurations in project.pbxproj must use ONLY_ACTIVE_ARCH = NO" + exit 1 +fi + +echo "PASS: GhosttyKit builds universal and Release configs disable ONLY_ACTIVE_ARCH" diff --git a/tests/test_nightly_universal_build.sh b/tests/test_nightly_universal_build.sh new file mode 100644 index 00000000..e86dbf36 --- /dev/null +++ b/tests/test_nightly_universal_build.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Regression test for dual nightly macOS tracks. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW_FILE="$ROOT_DIR/.github/workflows/nightly.yml" + +if ! awk ' + /^ - name: Build Apple Silicon app \(Release\)/ { in_arm=1; next } + /^ - name: Build universal app \(Release\)/ { in_universal=1; next } + in_arm && /^ - name:/ { in_arm=0 } + in_universal && /^ - name:/ { in_universal=0 } + in_arm && /-destination '\''platform=macOS,arch=arm64'\''/ { saw_arm_destination=1 } + in_arm && /ARCHS="arm64"/ { saw_arm_archs=1 } + in_arm && /ONLY_ACTIVE_ARCH=YES/ { saw_arm_only_active_arch=1 } + in_universal && /-destination '\''generic\/platform=macOS'\''/ { saw_universal_destination=1 } + in_universal && /ARCHS="arm64 x86_64"/ { saw_universal_archs=1 } + in_universal && /ONLY_ACTIVE_ARCH=NO/ { saw_universal_only_active_arch=1 } + END { + exit !(saw_arm_destination && saw_arm_archs && saw_arm_only_active_arch && saw_universal_destination && saw_universal_archs && saw_universal_only_active_arch) + } +' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must force Apple Silicon nightly to arm64-only and universal nightly to both slices" + exit 1 +fi + +if ! awk ' + /^ - name: Verify nightly binary architectures/ { in_verify=1; next } + in_verify && /^ - name:/ { in_verify=0 } + in_verify && /lipo -archs "\$ARM_APP_BINARY"/ { saw_arm_app=1 } + in_verify && /lipo -archs "\$ARM_CLI_BINARY"/ { saw_arm_cli=1 } + in_verify && /lipo -archs "\$APP_BINARY"/ { saw_app=1 } + in_verify && /lipo -archs "\$CLI_BINARY"/ { saw_cli=1 } + in_verify && /\[\[ "\$ARM_APP_ARCHS" == "arm64" \]\]/ { saw_arm_app_assert=1 } + in_verify && /\[\[ "\$ARM_CLI_ARCHS" == "arm64" \]\]/ { saw_arm_cli_assert=1 } + END { exit !(saw_arm_app && saw_arm_cli && saw_app && saw_cli && saw_arm_app_assert && saw_arm_cli_assert) } +' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must verify arm-only and universal slices with lipo" + exit 1 +fi + +if ! grep -Fq 'com.cmuxterm.app.nightly.universal' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must set a distinct .universal bundle ID" + exit 1 +fi + +if ! grep -Fq 'https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast-universal.xml' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must publish a separate universal appcast feed" + exit 1 +fi + +if ! grep -Fq './scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml' "$WORKFLOW_FILE"; then + echo "FAIL: nightly workflow must generate a separate universal appcast" + exit 1 +fi + +if ! grep -Fq "core.setOutput('should_publish', isMainRef ? 'true' : 'false');" "$WORKFLOW_FILE"; then + echo "FAIL: nightly decide step must expose should_publish based on whether the ref is main" + exit 1 +fi + +if ! awk ' + /^ - name: Upload branch nightly artifacts/ { in_upload=1; next } + in_upload && /^ - name:/ { in_upload=0 } + in_upload && /if: needs\.decide\.outputs\.should_publish != '\''true'\''/ { saw_if=1 } + in_upload && /uses: actions\/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4/ { saw_upload=1 } + in_upload && /cmux-nightly-macos\*\.dmg/ { saw_arm_artifacts=1 } + in_upload && /cmux-nightly-universal-macos\*\.dmg/ { saw_universal_artifacts=1 } + in_upload && /appcast-universal\.xml/ { saw_universal_appcast=1 } + END { exit !(saw_if && saw_upload && saw_arm_artifacts && saw_universal_artifacts && saw_universal_appcast) } +' "$WORKFLOW_FILE"; then + echo "FAIL: non-main nightly runs must upload both nightly variants and both appcasts" + exit 1 +fi + +if ! awk ' + /^ - name: Move nightly tag to built commit/ { in_move=1; next } + in_move && /^ - name:/ { in_move=0 } + in_move && /if: needs\.decide\.outputs\.should_publish == '\''true'\''/ { saw_move_if=1 } + END { exit !saw_move_if } +' "$WORKFLOW_FILE"; then + echo "FAIL: moving the nightly tag must be gated to main nightly publishes" + exit 1 +fi + +if ! awk ' + /^ - name: Publish nightly release assets/ { in_publish=1; next } + in_publish && /^ - name:/ { in_publish=0 } + in_publish && /if: needs\.decide\.outputs\.should_publish == '\''true'\''/ { saw_publish_if=1 } + in_publish && /cmux-nightly-universal-macos-\$\{\{ github\.run_id \}\}\*\.dmg/ { saw_universal_immutable=1 } + in_publish && /cmux-nightly-universal-macos\.dmg/ { saw_universal_stable=1 } + in_publish && /appcast-universal\.xml/ { saw_universal_appcast=1 } + END { exit !(saw_publish_if && saw_universal_immutable && saw_universal_stable && saw_universal_appcast) } +' "$WORKFLOW_FILE"; then + echo "FAIL: main nightly publish must include the universal assets and appcast" + exit 1 +fi + +echo "PASS: nightly workflow keeps separate Apple Silicon and universal nightly tracks"