cmux/.github/workflows/nightly.yml
Lawrence Chen 27aab3a035
Simplify R2 to appcast-only (keep DMGs on GitHub) (#2363)
* Simplify R2 upload to appcast-only (keep DMGs on GitHub)

DMGs are immutable per-build on GitHub Releases (unique filenames,
no overwrite), so there's no race condition for them. Only the
appcast.xml needs atomic replacement, which R2 PutObject provides.

Upload the original appcast.xml as-is (GitHub Release DMG URLs)
to R2. No sed URL rewriting, no DMG uploads, less storage/bandwidth.

* Move R2 appcast upload after GitHub Release publish

The R2 appcast references GitHub Release DMG URLs, so it must be
uploaded after the DMGs exist on GitHub. Previously the R2 upload
ran before the publish step, creating a brief window where the
appcast pointed to a not-yet-existing DMG.

* Add semver guard to stable R2 appcast upload

Prevents a backport tag (e.g. v0.62.1 pushed after v0.63.1) from
overwriting the stable appcast with an older version. Uses sort -V
to compare all non-prerelease tags and only uploads if the current
tag is the highest.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-30 04:50:18 -07:00

590 lines
29 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 }}
# 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@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: warp-macos-26-arm64-6x
timeout-minutes: 20
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: |
ZIG_REQUIRED="0.15.2"
if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then
echo "zig ${ZIG_REQUIRED} already installed"
else
echo "Installing zig ${ZIG_REQUIRED} from tarball"
curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz
tar xf /tmp/zig.tar.xz -C /tmp
sudo mkdir -p /usr/local/bin /usr/local/lib
sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig
sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig
export PATH="/usr/local/bin:$PATH"
zig version
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: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version-file: daemon/remote/go.mod
- 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
NIGHTLY_MARKETING_VERSION="${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}"
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
echo "NIGHTLY_MARKETING_VERSION=${NIGHTLY_MARKETING_VERSION}" >> "$GITHUB_ENV"
echo "NIGHTLY_REMOTE_DAEMON_VERSION=${NIGHTLY_MARKETING_VERSION}" >> "$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 ${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://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: ${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: 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: 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')
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
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'
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
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'
continue-on-error: 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