cmux/.github/workflows/nightly.yml
2026-03-14 22:40:36 -07:00

487 lines
23 KiB
YAML

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 }}
# Only the newest nightly matters. Cancel older runs so a fresh main push
# does not sit behind an outdated build that would be discarded anyway.
cancel-in-progress: true
permissions:
contents: write
env:
CREATE_DMG_VERSION: 8.0.0
jobs:
decide:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.decide.outputs.should_build }}
head_sha: ${{ steps.decide.outputs.head_sha }}
short_sha: ${{ steps.decide.outputs.short_sha }}
should_publish: ${{ steps.decide.outputs.should_publish }}
steps:
- name: Decide whether a nightly build is needed
id: decide
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }}
with:
script: |
const forceBuild = process.env.FORCE_BUILD === 'true';
const { owner, repo } = context.repo;
const requestedRef = context.ref.startsWith('refs/heads/')
? context.ref.replace('refs/heads/', '')
: 'main';
const isMainRef = requestedRef === 'main';
let headSha = context.sha;
if (isMainRef) {
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: 'main',
});
headSha = branch.data.commit.sha;
}
let nightlySha = null;
if (isMainRef) {
try {
const ref = await github.rest.git.getRef({
owner,
repo,
ref: 'tags/nightly',
});
if (ref.data.object.type === 'commit') {
nightlySha = ref.data.object.sha;
} else if (ref.data.object.type === 'tag') {
const tagObject = await github.rest.git.getTag({
owner,
repo,
tag_sha: ref.data.object.sha,
});
nightlySha = tagObject.data.object.sha;
}
} catch (error) {
if (error.status !== 404) throw error;
}
}
const shouldBuild = !isMainRef || forceBuild || nightlySha !== headSha;
core.setOutput('should_build', shouldBuild ? 'true' : 'false');
core.setOutput('head_sha', headSha);
core.setOutput('short_sha', headSha.slice(0, 7));
core.setOutput('should_publish', isMainRef ? 'true' : 'false');
core.summary
.addHeading('Nightly build decision')
.addTable([
[{data: 'requested ref', header: true}, requestedRef],
[{data: 'build HEAD', header: true}, headSha],
[{data: 'nightly tag', header: true}, nightlySha ?? '(missing)'],
[{data: 'force build', header: true}, String(forceBuild)],
[{data: 'should build', header: true}, String(shouldBuild)],
[{data: 'should publish', header: true}, String(isMainRef)],
])
.write();
build-sign-notarize-nightly:
needs: decide
if: needs.decide.outputs.should_build == 'true'
runs-on: depot-macos-latest
steps:
- name: Checkout build ref
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
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
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version
xcrun --sdk macosx --show-sdk-path
- name: Install build deps
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- 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@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .spm-cache
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Derive Sparkle public key from private key
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'
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
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
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
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"
/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 \
"$APP_DIR" \
"com.cmuxterm.app.nightly" \
"https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
echo "Nightly app name: cmux NIGHTLY"
echo "Nightly bundle ID: com.cmuxterm.app.nightly"
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
echo "Nightly build number: ${NIGHTLY_BUILD}"
echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
echo "Commit SHA: ${SHORT_SHA}"
- name: Import signing cert
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: 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
ENTITLEMENTS="cmux.entitlements"
for APP_PATH in \
"build-universal/Build/Products/Release/cmux NIGHTLY.app"
do
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
done
- name: Notarize apps and dmgs
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"
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: 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
appcast.xml
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'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -f nightly "${{ needs.decide.outputs.head_sha }}"
git push origin refs/tags/nightly --force
- name: Publish nightly release assets
if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
tag_name: nightly
name: Nightly
prerelease: true
make_latest: false
body: |
Automated nightly build for `${{ needs.decide.outputs.short_sha }}`.
**cmux NIGHTLY** 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
appcast-universal.xml
overwrite_files: true
- name: Cleanup keychain
if: always()
run: |
security delete-keychain build.keychain >/dev/null 2>&1 || true
rm -f /tmp/cert.p12