Nightly macOS build #2035
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} | |
| # Queue concurrent runs instead of canceling them so no build is lost. | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| attestations: write | |
| id-token: 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| 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' | |
| # Run on the macOS 15 host that carries the signing/notarization secrets. | |
| # The Swift app still selects an Xcode with the macOS 26 SDK so it adopts | |
| # Liquid Glass on Tahoe. The Ghostty CLI helper is built separately and | |
| # injected before signing, preferring a pre-26 SDK when the runner image has | |
| # one but falling back to the selected app Xcode when the image only ships | |
| # Xcode 26. The helper build remains required and lipo-verified below. | |
| runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout build ref | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ needs.decide.outputs.head_sha }} | |
| submodules: recursive | |
| - name: Check whether build commit is still current main HEAD before build | |
| if: needs.decide.outputs.should_publish == 'true' | |
| id: current_head_prebuild | |
| run: | | |
| set -euo pipefail | |
| CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" | |
| BUILD_SHA="${{ needs.decide.outputs.head_sha }}" | |
| if [ "$CURRENT_MAIN_SHA" = "$BUILD_SHA" ]; then | |
| STILL_CURRENT=true | |
| else | |
| STILL_CURRENT=false | |
| fi | |
| echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### Pre-build publish guard" | |
| echo | |
| echo "- build sha: \`$BUILD_SHA\`" | |
| echo "- current main sha: \`$CURRENT_MAIN_SHA\`" | |
| echo "- continue build/sign/publish: \`$STILL_CURRENT\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Select Xcode | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| run: | | |
| set -euo pipefail | |
| ./scripts/select-nightly-xcodes.sh | |
| - name: Install build deps | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| run: | | |
| ./scripts/install-rust-ci.sh | |
| ./scripts/install-zig-ci.sh | |
| npm install --global "create-dmg@${CREATE_DMG_VERSION}" | |
| - name: Build universal Ghostty CLI helper | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| env: | |
| DEVELOPER_DIR: ${{ env.HELPER_DEVELOPER_DIR }} | |
| run: | | |
| set -euo pipefail | |
| ./scripts/build-ghostty-cli-helper.sh --universal --output /tmp/cmux-ghostty-helper-universal | |
| ARCHS_OUT="$(lipo -archs /tmp/cmux-ghostty-helper-universal)" | |
| echo "Universal Ghostty CLI helper architectures: $ARCHS_OUT" | |
| case " $ARCHS_OUT " in *" arm64 "*) ;; *) echo "helper missing arm64 slice" >&2; exit 1 ;; esac | |
| case " $ARCHS_OUT " in *" x86_64 "*) ;; *) echo "helper missing x86_64 slice" >&2; exit 1 ;; esac | |
| - name: Download pre-built GhosttyKit.xcframework | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| run: | | |
| ./scripts/download-prebuilt-ghosttykit.sh | |
| - name: Cache Swift packages | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| with: | |
| path: .spm-cache | |
| key: spm-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} | |
| restore-keys: spm- | |
| - name: Setup Go | |
| uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 | |
| with: | |
| go-version-file: daemon/remote/go.mod | |
| cache-dependency-path: daemon/remote/go.sum | |
| - name: Derive Sparkle public key from private key | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == '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 universal nightly app (Release) | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| env: | |
| # Skip the in-Xcode zig helper build; it would fail to cross-link | |
| # x86_64 against the macOS 26 SDK. The real universal helper is built | |
| # by the "Build universal Ghostty CLI helper" step and injected below. | |
| CMUX_SKIP_ZIG_BUILD: "1" | |
| 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: Inject universal Ghostty CLI helper | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| run: | | |
| set -euo pipefail | |
| APP_DIR="build-universal/Build/Products/Release/cmux.app" | |
| if [ ! -d "$APP_DIR" ]; then | |
| echo "Built app not found at $APP_DIR" >&2 | |
| exit 1 | |
| fi | |
| DEST="$APP_DIR/Contents/Resources/bin/ghostty" | |
| mkdir -p "$(dirname "$DEST")" | |
| install -m 755 /tmp/cmux-ghostty-helper-universal "$DEST" | |
| echo "Injected Ghostty CLI helper architectures: $(lipo -archs "$DEST")" | |
| - name: Run bundled Ghostty theme picker helper regression | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| run: | | |
| set -euo pipefail | |
| CMUX_APP_PATH="build-universal/Build/Products/Release/cmux.app" \ | |
| ./tests/test_bundled_ghostty_theme_picker_helper.sh | |
| - name: Verify nightly binary architectures | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| run: | | |
| set -euo pipefail | |
| 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" | |
| HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty" | |
| APP_ARCHS="$(lipo -archs "$APP_BINARY")" | |
| CLI_ARCHS="$(lipo -archs "$CLI_BINARY")" | |
| HELPER_ARCHS="$(lipo -archs "$HELPER_BINARY")" | |
| echo "App binary architectures: $APP_ARCHS" | |
| echo "CLI binary architectures: $CLI_ARCHS" | |
| echo "Ghostty helper architectures: $HELPER_ARCHS" | |
| [[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]] | |
| [[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]] | |
| [[ "$HELPER_ARCHS" == *arm64* && "$HELPER_ARCHS" == *x86_64* ]] | |
| - name: Run CLI version memory guard regression | |
| if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' | |
| run: | | |
| set -euo pipefail | |
| CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" | |
| [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } | |
| CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py | |
| - name: Check whether build commit is still current main HEAD after build | |
| if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' | |
| id: current_head_postbuild | |
| run: | | |
| set -euo pipefail | |
| CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" | |
| BUILD_SHA="${{ needs.decide.outputs.head_sha }}" | |
| if [ "$CURRENT_MAIN_SHA" = "$BUILD_SHA" ]; then | |
| STILL_CURRENT=true | |
| else | |
| STILL_CURRENT=false | |
| fi | |
| echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "### Post-build publish guard" | |
| echo | |
| echo "- build sha: \`$BUILD_SHA\`" | |
| echo "- current main sha: \`$CURRENT_MAIN_SHA\`" | |
| echo "- continue signing/publish: \`$STILL_CURRENT\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Inject nightly identities and metadata | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') | |
| run: | | |
| set -euo pipefail | |
| SHORT_SHA="${{ needs.decide.outputs.short_sha }}" | |
| APP_DIR="build-universal/Build/Products/Release" | |
| BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${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 | |
| NIGHTLY_MARKETING_VERSION="${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" | |
| NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" | |
| { | |
| echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" | |
| echo "NIGHTLY_MARKETING_VERSION=${NIGHTLY_MARKETING_VERSION}" | |
| echo "NIGHTLY_REMOTE_DAEMON_VERSION=${NIGHTLY_MARKETING_VERSION}" | |
| echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_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" | |
| local url_type_name | |
| url_type_name="$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes:1:CFBundleURLName" "$app_plist")" | |
| if [[ "$url_type_name" != *.auth ]]; then | |
| echo "Expected CFBundleURLTypes[1] to be the auth URL type, found: $url_type_name" >&2 | |
| exit 1 | |
| fi | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleURLTypes:1:CFBundleURLSchemes:0 cmux-nightly" "$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 ${NIGHTLY_MARKETING_VERSION}" "$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 \ | |
| "$APP_DIR" \ | |
| "com.cmuxterm.app.nightly" \ | |
| "https://files.cmux.com/nightly/appcast.xml" | |
| echo "Nightly app name: cmux NIGHTLY" | |
| echo "Nightly bundle ID: com.cmuxterm.app.nightly" | |
| echo "Nightly marketing version: ${NIGHTLY_MARKETING_VERSION}" | |
| echo "Nightly build number: ${NIGHTLY_BUILD}" | |
| echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}" | |
| echo "Commit SHA: ${SHORT_SHA}" | |
| - name: Build remote daemon nightly assets and inject manifest | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') | |
| run: | | |
| set -euo pipefail | |
| # Build with --asset-suffix so manifest download URLs point to | |
| # immutable, build-specific asset names (e.g. cmuxd-remote-darwin-arm64-2362248028801). | |
| # This prevents checksum mismatches when a newer nightly overwrites | |
| # the shared "latest" assets on the release. | |
| ./scripts/build_remote_daemon_release_assets.sh \ | |
| --version "$NIGHTLY_REMOTE_DAEMON_VERSION" \ | |
| --release-tag "nightly" \ | |
| --repo "manaflow-ai/cmux" \ | |
| --output-dir "remote-daemon-assets" \ | |
| --asset-suffix "$NIGHTLY_BUILD" | |
| MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' "remote-daemon-assets/cmuxd-remote-manifest-${NIGHTLY_BUILD}.json")" | |
| APP_PLIST="build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" | |
| if [ ! -f "$APP_PLIST" ]; then | |
| echo "Missing nightly app Info.plist at $APP_PLIST" >&2 | |
| exit 1 | |
| fi | |
| plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true | |
| plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" | |
| # Also create unsuffixed "latest" copies for the release page and | |
| # any tooling that fetches the generic asset names. The manifest's | |
| # downloadURLs still point to the versioned filenames (intentional: | |
| # the live manifest is used by the client-side checksum fallback | |
| # which only reads sha256, not downloadURL). The unsuffixed copies | |
| # are convenience aliases and don't carry build-provenance | |
| # attestation (attested versioned files are canonical). | |
| for platform in darwin-arm64 darwin-amd64 linux-arm64 linux-amd64; do | |
| cp "remote-daemon-assets/cmuxd-remote-${platform}-${NIGHTLY_BUILD}" \ | |
| "remote-daemon-assets/cmuxd-remote-${platform}" | |
| done | |
| # Regenerate unsuffixed checksums with generic filenames so | |
| # `shasum -c cmuxd-remote-checksums.txt` works against the aliases. | |
| ( | |
| cd remote-daemon-assets | |
| shasum -a 256 \ | |
| cmuxd-remote-darwin-arm64 \ | |
| cmuxd-remote-darwin-amd64 \ | |
| cmuxd-remote-linux-arm64 \ | |
| cmuxd-remote-linux-amd64 \ | |
| > cmuxd-remote-checksums.txt | |
| ) | |
| cp "remote-daemon-assets/cmuxd-remote-manifest-${NIGHTLY_BUILD}.json" \ | |
| "remote-daemon-assets/cmuxd-remote-manifest.json" | |
| - name: Import signing cert | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == '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: Embed nightly provisioning profile | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') | |
| env: | |
| APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64 }} | |
| run: | | |
| if [ -z "$APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64" ]; then | |
| echo "Missing APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64 secret" >&2 | |
| exit 1 | |
| fi | |
| APP_PATH="build-universal/Build/Products/Release/cmux NIGHTLY.app" | |
| PROFILE_PATH="$APP_PATH/Contents/embedded.provisionprofile" | |
| TMP_PROFILE="$(mktemp /tmp/cmux-nightly-profile.XXXXXX)" | |
| TMP_PLIST="$(mktemp /tmp/cmux-nightly-profile.XXXXXX.plist)" | |
| trap 'rm -f "$TMP_PROFILE" "$TMP_PLIST"' EXIT | |
| echo "$APPLE_NIGHTLY_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$TMP_PROFILE" | |
| security cms -D -i "$TMP_PROFILE" > "$TMP_PLIST" | |
| APP_ID="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.application-identifier" "$TMP_PLIST")" | |
| if [ "$APP_ID" != "7WLXT3NR37.com.cmuxterm.app.nightly" ]; then | |
| echo "Nightly provisioning profile targets unexpected app ID: $APP_ID" >&2 | |
| exit 1 | |
| fi | |
| WEBAUTHN_ENTITLEMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.web-browser.public-key-credential" "$TMP_PLIST")" | |
| if [ "$WEBAUTHN_ENTITLEMENT" != "true" ]; then | |
| echo "Nightly provisioning profile is missing WebAuthn browser entitlement" >&2 | |
| exit 1 | |
| fi | |
| PROVISIONS_ALL_DEVICES="$(/usr/libexec/PlistBuddy -c "Print :ProvisionsAllDevices" "$TMP_PLIST")" | |
| if [ "$PROVISIONS_ALL_DEVICES" != "true" ]; then | |
| echo "Nightly provisioning profile is not a Developer ID all-devices profile" >&2 | |
| exit 1 | |
| fi | |
| cp "$TMP_PROFILE" "$PROFILE_PATH" | |
| - name: Codesign apps | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == '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 | |
| for APP_PATH in \ | |
| "build-universal/Build/Products/Release/cmux NIGHTLY.app" | |
| do | |
| ./scripts/sign-cmux-bundle.sh \ | |
| "$APP_PATH" \ | |
| cmux.nightly.entitlements \ | |
| "$APPLE_SIGNING_IDENTITY" | |
| done | |
| - name: Notarize apps and dmgs | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == '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 | |
| 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" | |
| ./scripts/smoke-launch-macos-app.sh "$app_path" | |
| ./scripts/verify-app-bundle-channel-metadata.sh "$app_path" nightly | |
| 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-universal/Build/Products/Release/cmux NIGHTLY.app" \ | |
| "cmux-nightly-macos.dmg" \ | |
| "$NIGHTLY_DMG_IMMUTABLE" | |
| - name: Upload dSYMs to Sentry | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == '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-universal/Build/Products/Release/ | |
| - name: Generate Sparkle appcasts (nightly) | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == '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 "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml | |
| # Keep the legacy universal feed alive long enough for older nightly | |
| # installs to migrate onto the unified nightly appcast. | |
| cp appcast.xml appcast-universal.xml | |
| - name: Attest remote daemon nightly assets | |
| id: attest-remote-daemon-nightly-assets | |
| if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') | |
| continue-on-error: true | |
| uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 | |
| with: | |
| subject-path: | | |
| remote-daemon-assets/cmuxd-remote-darwin-arm64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-darwin-amd64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-linux-arm64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-linux-amd64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-checksums-${{ env.NIGHTLY_BUILD }}.txt | |
| remote-daemon-assets/cmuxd-remote-manifest-${{ env.NIGHTLY_BUILD }}.json | |
| - name: Retry remote daemon nightly asset attestation | |
| if: (needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')) && steps.attest-remote-daemon-nightly-assets.outcome == 'failure' | |
| uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 | |
| with: | |
| subject-path: | | |
| remote-daemon-assets/cmuxd-remote-darwin-arm64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-darwin-amd64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-linux-arm64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-linux-amd64-${{ env.NIGHTLY_BUILD }} | |
| remote-daemon-assets/cmuxd-remote-checksums-${{ env.NIGHTLY_BUILD }}.txt | |
| remote-daemon-assets/cmuxd-remote-manifest-${{ env.NIGHTLY_BUILD }}.json | |
| - name: Upload branch nightly artifacts | |
| if: needs.decide.outputs.should_publish != 'true' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: cmux-nightly-${{ needs.decide.outputs.short_sha }} | |
| path: | | |
| cmux-nightly-macos*.dmg | |
| appcast.xml | |
| remote-daemon-assets/cmuxd-remote-* | |
| appcast-universal.xml | |
| if-no-files-found: error | |
| - name: Move nightly tag to built commit | |
| if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| 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 }}" | |
| # Push via a tokenized URL so we neither rely on the persisted | |
| # checkout credentials (which intermittently fail with "Device not | |
| # configured" on self-hosted runners) nor overlay a second | |
| # Authorization header on top of them (which makes GitHub reject the | |
| # push with `remote: Duplicate header: "Authorization"`). | |
| git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ | |
| refs/tags/nightly --force | |
| - name: Prune old nightly release assets | |
| if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| python3 ./scripts/prune_nightly_release_assets.py \ | |
| --repo manaflow-ai/cmux \ | |
| --release-tag nightly \ | |
| --keep-builds 100 \ | |
| --execute | |
| - name: Publish nightly release assets | |
| if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true' | |
| uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 | |
| with: | |
| tag_name: nightly | |
| name: Nightly | |
| prerelease: true | |
| make_latest: false | |
| body: | | |
| Automated nightly build for `${{ needs.decide.outputs.short_sha }}`. | |
| **cmux NIGHTLY** is published as a universal app: | |
| - bundle ID `com.cmuxterm.app.nightly` | |
| - feed `appcast.xml` | |
| - compatibility feed `appcast-universal.xml` for older universal nightlies | |
| [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 | |
| remote-daemon-assets/cmuxd-remote-* | |
| appcast-universal.xml | |
| overwrite_files: true | |
| - name: Upload nightly appcast to R2 | |
| if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true' | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} | |
| AWS_DEFAULT_REGION: auto | |
| R2_ENDPOINT: "https://${{ secrets.CF_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com" | |
| run: | | |
| set -euo pipefail | |
| command -v aws >/dev/null 2>&1 || { echo "Installing AWS CLI..."; brew install awscli; } | |
| # Upload after GitHub Release publish so the appcast never references | |
| # a DMG that doesn't exist yet. R2 PutObject is atomic, so the appcast | |
| # is either the old version or the new one, never missing. | |
| aws s3 cp appcast.xml \ | |
| "s3://cmux-binaries/nightly/appcast.xml" \ | |
| --endpoint-url "$R2_ENDPOINT" \ | |
| --cache-control "no-cache, no-store, must-revalidate" | |
| echo "R2 appcast upload complete: https://files.cmux.com/nightly/appcast.xml" | |
| - name: Cleanup keychain | |
| if: always() | |
| run: | | |
| security delete-keychain build.keychain >/dev/null 2>&1 || true | |
| rm -f /tmp/cert.p12 |