Merge pull request #1067 from manaflow-ai/task-universal-macos-build

Publish separate universal nightly track
This commit is contained in:
Lawrence Chen 2026-03-08 16:02:18 -07:00 committed by GitHub
commit 0f29735635
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 361 additions and 122 deletions

View file

@ -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'

View file

@ -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

View file

@ -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:

View file

@ -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;

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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"

View file

@ -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"