Format: {upstream-version}-tb{n}
0.62.2-tb1= first TextBox release based on upstream 0.62.20.62.2-tb12= twelfth release on the same upstream base0.63.2-tb13= first release after merging upstream 0.63.2 (tb number continues across upstream bumps)
The -tb suffix distinguishes fork releases from upstream. The number after tb increments for each release on the same upstream base.
ghCLI authenticated with access toalumican/cmux-tb- GitHub Secrets configured:
APPLE_CERTIFICATE_BASE64,APPLE_CERTIFICATE_PASSWORD,APPLE_SIGNING_IDENTITY,APPLE_ID,APPLE_APP_SPECIFIC_PASSWORD,APPLE_TEAM_ID
See upstream-sync.md for the full merge procedure.
The scripts/bump-version.sh only supports x.y.z format, so set the version manually:
# In GhosttyTabs.xcodeproj/project.pbxproj, update both occurrences:
MARKETING_VERSION = 0.63.2-tb14;
# Bump CURRENT_PROJECT_VERSION (must be monotonically increasing).
# Current: 80 (v0.63.2-tb13)
CURRENT_PROJECT_VERSION = 81;xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -destination 'platform=macOS' buildVerify the app launches and TextBox works correctly.
Update CHANGELOG.md with user-facing changes for this release.
Commit directly to main — do not create a PR. Keeping the version bump
and changelog in the same commit stream as the feature/fix commits makes it
easy to trace release contents from git log.
git add CHANGELOG.md GhosttyTabs.xcodeproj/project.pbxproj
git commit -m "Bump version to 0.63.2-tb14"
git tag v0.63.2-tb14
git push origin main
git push origin v0.63.2-tb14Pushing a v*-tb* tag triggers the release-tb.yml workflow, which automatically:
- Downloads pre-built GhosttyKit.xcframework
- Installs Zig (required for Ghostty CLI helper)
- Builds the Release app (
cmux.app— PRODUCT_NAME is "cmux" in Release config) - Codesigns with Apple Developer certificate
- Notarizes with Apple
- Creates a styled DMG (
cmux-tb-macos.dmg) - Generates Sparkle
appcast.xmlfor auto-update - Uploads the DMG and appcast as GitHub Release assets
Important notes about the workflow (release-tb.yml):
- The built app is
cmux.app(notcmux-tb.app) becausePRODUCT_NAME = cmuxin the Release build configuration. The workflow referencesbuild/Build/Products/Release/cmux.appfor codesign and notarize steps. - Zig must be installed on the runner — it is not pre-installed on GitHub Actions macOS runners. The workflow installs it from the official tarball.
- The
create-dmgtool generates a DMG named after the app (cmux *.dmg), which is then renamed tocmux-tb-macos.dmg.
Important: Create the release as a draft so the README download link (releases/latest/download/cmux-tb-macos.dmg) keeps pointing to the previous release until the DMG is ready.
gh release create v0.63.2-tb14 --repo alumican/cmux-tb --draft --title "v0.63.2-tb14" --notes "$(cat <<'EOF'
## cmux + TextBox v0.63.2-tb14
Based on [cmux v0.63.2](https://github.com/manaflow-ai/cmux/releases/tag/v0.63.2).
### Changes
- ...
### Install
Download `cmux-tb-macos.dmg`, open it, and drag cmux-tb to Applications.
EOF
)"gh run list --repo alumican/cmux-tb --limit 3
gh run view <run-id> --repo alumican/cmux-tbCI (softprops/action-gh-release) may create a separate published release alongside your draft. Check for duplicates:
gh release list --repo alumican/cmux-tb --limit 5If a duplicate draft remains, delete it:
# Find the draft release ID
gh api repos/alumican/cmux-tb/releases --jq '.[] | select(.draft==true and .tag_name=="v0.63.2-tb14") | .id'
# Delete it
gh api -X DELETE repos/alumican/cmux-tb/releases/<id>Once the CI workflow completes, the DMG is attached, and duplicates are cleaned up:
gh release edit v0.63.2-tb14 --repo alumican/cmux-tb --draft=falseThe signed DMG will then be available at:
https://github.com/alumican/cmux-tb/releases/latest/download/cmux-tb-macos.dmg
If a release needs to be redone (e.g. workflow failed and you fixed the issue):
gh release delete v0.63.2-tb13 --repo alumican/cmux-tb --yes
git push origin --delete v0.63.2-tb13
git tag -d v0.63.2-tb13
git tag v0.63.2-tb13
git push origin v0.63.2-tb13For quick local testing without codesigning:
BG="$(npm root -g)/create-dmg/assets/dmg-background@2x.png"
create-dmg \
--volname "cmux-tb" \
--background "$BG" \
--window-size 660 400 \
--icon-size 128 \
--icon "cmux.app" 180 180 \
--app-drop-link 480 180 \
/tmp/cmux-tb-macos.dmg \
"/path/to/DerivedData/.../Build/Products/Release/cmux.app"Requires brew install create-dmg and npm create-dmg for the background image.
- The DMG asset must be named
cmux-tb-macos.dmgto match the README download link. - Build number (
CURRENT_PROJECT_VERSION) must always increase — never reuse or go backwards. After an upstream merge, the build number inproject.pbxprojwill be reset to the upstream value, which is almost certainly lower than the fork's. Always check the previous fork release's appcast to find the last published build number and set the new one higher:For example, v0.62.2-tb12 had build 91 but upstream v0.63.2 had 79. Setting tb13 to 80 broke the Sparkle upgrade path because 80 < 91. The fix was to set it to 92.curl -fsSL https://github.com/alumican/cmux-tb/releases/download/v<previous-tag>/appcast.xml | grep 'sparkle:version'
- The
release-tb.ymlworkflow only triggers on tags matchingv*-tb*. The upstreamrelease.ymltriggers on allv*tags but will fail (requires Depot runner). - Always create releases as draft first, then publish after CI attaches the DMG. This prevents the README download link from 404-ing during the build.
- Release notes should link to the upstream cmux version:
[cmux vX.Y.Z](https://github.com/manaflow-ai/cmux/releases/tag/vX.Y.Z). - The
tbnumber is global and continues incrementing across upstream version bumps (e.g. tb12 on 0.62.2 → tb13 on 0.63.2).
Zig is not pre-installed on GitHub Actions macOS runners. The Install Zig step is required.
The Release build's PRODUCT_NAME is cmux (not cmux-tb). The build artifact is at build/Build/Products/Release/cmux.app. All paths in the workflow must reference cmux.app.
create-dmg generates a DMG named after the app (cmux *.dmg). Use mv ./cmux*.dmg "$DMG_RELEASE" to rename it to cmux-tb-macos.dmg. The glob cmux-tb*.dmg will not match — use cmux*.dmg instead.
The sparkle_generate_appcast.sh fallback signing path (sign_update) injects sparkle:edSignature and length into the <enclosure> element. If generate_appcast already wrote a length attribute, the injection creates a duplicate, producing invalid XML that Sparkle rejects with SUAppcastParseError (1000).
This happens when generate_appcast adds length but skips the EdDSA signature (e.g. because the public key in the built app doesn't match the private key). The script then falls through to the sign_update fallback and naively injects another length.
Fix (applied in v0.63.2-tb13): The fallback now uses re.sub(r'length="[^"]*"', ...) to update the existing length attribute instead of adding a new one. If this error recurs after modifying the appcast script, check for duplicate XML attributes in the generated appcast.xml:
curl -fsSL https://github.com/alumican/cmux-tb/releases/latest/download/appcast.xml | grep -o 'length=' | wc -l
# Should be exactly 1Symptoms: clients see SUSparkleErrorDomain(4005) | underlying=SUSparkleErrorDomain(10) Update validation was a failure from Sparkle and the update never installs. The appcast XML parses fine and the DMG downloads — only the EdDSA signature check fails.
Root cause: the SPARKLE_PUBLIC_KEY baked into the released app's Info.plist (via project.pbxproj) does not match the public key derived from the SPARKLE_PRIVATE_KEY GitHub secret used to sign the appcast. The sign_update fallback in sparkle_generate_appcast.sh happily signs with whatever private key the workflow hands it, so a mismatched pair produces a structurally valid appcast that no installed client can verify.
Historical context (v0.63.2-tb15.1): The first fork Sparkle setup put the fork public key l3TMx3bk... into project.pbxproj. The tb13 upstream merge silently rewrote SPARKLE_PUBLIC_KEY back to upstream's value avjcgKibf..., but the GitHub secret continued holding the l3TMx3bk... private key. From tb13 through tb15, every released appcast carried an EdDSA signature unverifiable against any installed client. tb15.1 rotated to a fresh pair and added a checklist entry to upstream-sync.md.
Verify locally before tagging a release:
# 1. Confirm secret/pbxproj are paired
swift scripts/derive_sparkle_public_key.swift "$(grep '^SPARKLE_PRIVATE_KEY=' .env | cut -d= -f2-)"
# Must equal the SPARKLE_PUBLIC_KEY value in GhosttyTabs.xcodeproj/project.pbxproj.
# 2. After release, verify a published DMG end-to-end
curl -fsSL --output /tmp/check.dmg https://github.com/alumican/cmux-tb/releases/download/<tag>/cmux-tb-macos.dmg
SIG=$(curl -fsSL https://github.com/alumican/cmux-tb/releases/download/<tag>/appcast.xml | grep -oE 'sparkle:edSignature="[^"]+"' | sed 's/sparkle:edSignature="//; s/"$//')
swift -e '
import CryptoKit, Foundation
let pub = "<SPARKLE_PUBLIC_KEY from project.pbxproj>"
let sig = "'"$SIG"'"
let pk = try Curve25519.Signing.PublicKey(rawRepresentation: Data(base64Encoded: pub)!)
print(pk.isValidSignature(Data(base64Encoded: sig)!, for: try Data(contentsOf: URL(fileURLWithPath: "/tmp/check.dmg"))) ? "VALID" : "INVALID")
'Recovering from a mismatch in a shipped release: generate a new key pair with scripts/sparkle_generate_keys.sh, update both SPARKLE_PUBLIC_KEY in project.pbxproj and the GitHub secret, bump the version, and ship a hotfix tag. Existing installs whose SUPublicEDKey no longer matches the new pair will need a one-time manual DMG download; future Sparkle updates work normally once they're on the rotated key.
name: Release cmux-tb
on:
push:
tags:
- "v*-tb*"
workflow_dispatch:
permissions:
contents: write
jobs:
build-sign-notarize:
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Select Xcode
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: Download pre-built GhosttyKit.xcframework
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Cache Swift packages
uses: actions/cache@v4
with:
path: .spm-cache
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Install Zig
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
- name: Build app (Release)
run: |
export PATH="/usr/local/bin:$PATH"
xcodebuild -scheme cmux -configuration Release -derivedDataPath build \
-clonedSourcePackagesDirPath .spm-cache \
CODE_SIGNING_ALLOWED=NO build
- name: Verify Sparkle keys in Info.plist
run: |
APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist"
PLIST_KEY=$(/usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" "$APP_PLIST" 2>/dev/null || echo "")
PLIST_FEED=$(/usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST" 2>/dev/null || echo "")
echo "SUPublicEDKey: $PLIST_KEY"
echo "SUFeedURL: $PLIST_FEED"
if [ -z "$PLIST_KEY" ]; then
echo "ERROR: SUPublicEDKey missing from Info.plist" >&2
exit 1
fi
- name: Import signing cert
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 app
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
APP_PATH="build/Build/Products/Release/cmux.app"
ENTITLEMENTS="cmux.entitlements"
for CLI_BIN in "$APP_PATH"/Contents/Resources/bin/*; do
[ -f "$CLI_BIN" ] || continue
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_BIN"
done
/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"
- name: Notarize app
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" >&2
exit 1
fi
APP_PATH="build/Build/Products/Release/cmux.app"
ZIP_SUBMIT="cmux-tb-notary.zip"
DMG_RELEASE="cmux-tb-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_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")"
if [ "$APP_STATUS" != "Accepted" ]; then
echo "App notarization failed: $APP_STATUS" >&2
exit 1
fi
xcrun stapler staple "$APP_PATH"
spctl -a -vv --type execute "$APP_PATH"
rm -f "$ZIP_SUBMIT"
npm install --global create-dmg@8.0.0
create-dmg --identity="$APPLE_SIGNING_IDENTITY" "$APP_PATH" ./
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_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")"
if [ "$DMG_STATUS" != "Accepted" ]; then
echo "DMG notarization failed: $DMG_STATUS" >&2
exit 1
fi
xcrun stapler staple "$DMG_RELEASE"
- name: Generate appcast.xml
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
./scripts/sparkle_generate_appcast.sh cmux-tb-macos.dmg "$TAG" appcast.xml
- name: Upload release assets
uses: softprops/action-gh-release@v2
with:
files: |
cmux-tb-macos.dmg
appcast.xml
- name: Cleanup keychain
if: always()
run: |
security delete-keychain build.keychain >/dev/null 2>&1 || true
rm -f /tmp/cert.p12