diff --git a/.claude/commands/release-local.md b/.claude/commands/release-local.md index 94b21af6..8aeea134 100644 --- a/.claude/commands/release-local.md +++ b/.claude/commands/release-local.md @@ -41,10 +41,15 @@ Full end-to-end release built locally. Bumps version, updates changelog, tags, t ./scripts/build-sign-upload.sh vX.Y.Z ``` -This script handles: GhosttyKit build, xcodebuild, Sparkle key injection, codesigning, notarization (app + DMG), appcast generation, GitHub release upload, and cleanup. +This script handles: GhosttyKit build, xcodebuild, Sparkle key injection, codesigning, notarization (app + DMG), appcast generation, GitHub release upload, homebrew cask update, and cleanup. If the script fails, run `say "cmux release failed"`. +### 7. Verify homebrew cask + +- Run `bash tests/test_homebrew_sha.sh` to confirm the cask SHA matches the release DMG +- Update the homebrew-cmux submodule pointer: `git add homebrew-cmux && git commit -m "Update homebrew-cmux submodule to latest" && git push origin main` + ## Changelog Guidelines **Include only end-user visible changes:** diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 698f6183..9ef7c00f 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -55,7 +55,13 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve - Verify the release appears at: https://github.com/manaflow-ai/cmux/releases - Check that the DMG is attached to the release -12. **Notify** +12. **Verify homebrew cask update** + - The "Update Homebrew Cask" workflow triggers automatically after the release workflow completes + - Watch: `gh run list --workflow=update-homebrew.yml --limit=1` and `gh run watch` + - Verify: `cd homebrew-cmux && git pull && grep version Casks/cmux.rb` + - Run `bash tests/test_homebrew_sha.sh` to confirm the SHA matches + +13. **Notify** - On success: `say "cmux release complete"` - On failure: `say "cmux release failed"` diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index 9656d053..09115902 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -1,12 +1,15 @@ name: Update Homebrew Cask on: - release: - types: [published] + # Trigger after the release workflow completes (not on release:published, + # which fires before assets finish uploading — causing SHA mismatch). + workflow_run: + workflows: ["Release macOS app"] + types: [completed] workflow_dispatch: inputs: version: - description: 'Version tag (e.g., v1.9.0)' + description: 'Version (e.g., 0.58.0 or v0.58.0)' required: true permissions: @@ -15,6 +18,10 @@ permissions: jobs: update-cask: runs-on: ubuntu-latest + # Only run if the release workflow succeeded (or manual trigger) + if: >- + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' steps: - name: Get version id: version @@ -22,18 +29,40 @@ jobs: if [ -n "${{ github.event.inputs.version }}" ]; then VERSION="${{ github.event.inputs.version }}" else - VERSION="${{ github.event.release.tag_name }}" + # workflow_run: extract tag from the triggering workflow's head branch + VERSION="${{ github.event.workflow_run.head_branch }}" fi VERSION="${VERSION#v}" + if [ -z "$VERSION" ]; then + echo "Could not determine version" >&2 + exit 1 + fi echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Updating homebrew cask to version $VERSION" - name: Download DMG and get SHA256 id: sha run: | VERSION="${{ steps.version.outputs.version }}" - curl -sL "https://github.com/manaflow-ai/cmux/releases/download/v${VERSION}/cmux-macos.dmg" -o cmux.dmg + URL="https://github.com/manaflow-ai/cmux/releases/download/v${VERSION}/cmux-macos.dmg" + MAX_RETRIES=5 + for i in $(seq 1 $MAX_RETRIES); do + HTTP_CODE=$(curl -sL -w '%{http_code}' "$URL" -o cmux.dmg) + FILE_SIZE=$(stat --printf="%s" cmux.dmg 2>/dev/null || stat -f%z cmux.dmg) + if [ "$HTTP_CODE" = "200" ] && [ "$FILE_SIZE" -gt 1000000 ]; then + echo "Download OK: HTTP $HTTP_CODE, size $FILE_SIZE bytes" + break + fi + echo "Attempt $i/$MAX_RETRIES: HTTP $HTTP_CODE, size ${FILE_SIZE:-0} bytes" + if [ "$i" -eq "$MAX_RETRIES" ]; then + echo "Failed to download DMG after $MAX_RETRIES attempts" >&2 + exit 1 + fi + sleep 30 + done SHA256=$(shasum -a 256 cmux.dmg | cut -d' ' -f1) echo "sha256=$SHA256" >> $GITHUB_OUTPUT + echo "DMG SHA256: $SHA256" - name: Checkout homebrew-cmux uses: actions/checkout@v4 @@ -76,6 +105,16 @@ jobs: # Remove leading whitespace from heredoc sed -i 's/^ //' homebrew-cmux/Casks/cmux.rb + - name: Verify cask SHA matches DMG + run: | + CASK_SHA=$(grep 'sha256' homebrew-cmux/Casks/cmux.rb | sed 's/.*"\(.*\)".*/\1/') + ACTUAL_SHA=$(shasum -a 256 cmux.dmg | cut -d' ' -f1) + if [ "$CASK_SHA" != "$ACTUAL_SHA" ]; then + echo "SHA mismatch! Cask: $CASK_SHA, Actual: $ACTUAL_SHA" >&2 + exit 1 + fi + echo "SHA verification passed: $CASK_SHA" + - name: Commit and push env: VERSION: ${{ steps.version.outputs.version }} diff --git a/homebrew-cmux b/homebrew-cmux index 72f1e256..27fdfc51 160000 --- a/homebrew-cmux +++ b/homebrew-cmux @@ -1 +1 @@ -Subproject commit 72f1e256bed433e7f3c4d0040fd87083345e92a2 +Subproject commit 27fdfc514e21a581d363c297970c35e7b6f93b26 diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index d68ca3fb..7f6d9644 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -91,6 +91,54 @@ fi # --- Verify --- gh release view "$TAG" +# --- Update Homebrew cask (skip for nightlies) --- +if [[ "$TAG" != *"-nightly"* ]]; then + VERSION="${TAG#v}" + DMG_SHA256=$(shasum -a 256 cmux-macos.dmg | cut -d' ' -f1) + echo "Updating homebrew cask to $VERSION (SHA: $DMG_SHA256)..." + CASK_FILE="homebrew-cmux/Casks/cmux.rb" + if [ -f "$CASK_FILE" ]; then + cat > "$CASK_FILE" << CASKEOF +cask "cmux" do + version "${VERSION}" + sha256 "${DMG_SHA256}" + + url "https://github.com/manaflow-ai/cmux/releases/download/v#{version}/cmux-macos.dmg" + name "cmux" + desc "Lightweight native macOS terminal with vertical tabs for AI coding agents" + homepage "https://github.com/manaflow-ai/cmux" + + livecheck do + url :url + strategy :github_latest + end + + depends_on macos: ">= :ventura" + + app "cmux.app" + + zap trash: [ + "~/Library/Application Support/cmux", + "~/Library/Caches/cmux", + "~/Library/Preferences/ai.manaflow.cmuxterm.plist", + ] +end +CASKEOF + cd homebrew-cmux + git add Casks/cmux.rb + if git diff --staged --quiet; then + echo "Homebrew cask already up to date" + else + git commit -m "Update cmux to ${VERSION}" + git push + echo "Homebrew cask updated" + fi + cd .. + else + echo "WARNING: homebrew-cmux submodule not found, skipping cask update" + fi +fi + # --- Cleanup --- rm -rf build/ cmux-macos.dmg appcast.xml echo "" diff --git a/tests/test_homebrew_sha.sh b/tests/test_homebrew_sha.sh new file mode 100755 index 00000000..3b376071 --- /dev/null +++ b/tests/test_homebrew_sha.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Regression test: verify the homebrew cask SHA256 matches the actual release DMG. +# Catches issues like https://github.com/manaflow-ai/cmux/issues/110 where a race +# condition caused the cask to contain the SHA of a 404 page instead of the DMG. +set -euo pipefail + +CASK_FILE="$(dirname "$0")/../homebrew-cmux/Casks/cmux.rb" + +if [ ! -f "$CASK_FILE" ]; then + echo "SKIP: homebrew-cmux submodule not initialized" + exit 0 +fi + +VERSION=$(grep 'version "' "$CASK_FILE" | head -1 | sed 's/.*"\(.*\)".*/\1/') +CASK_SHA=$(grep 'sha256 "' "$CASK_FILE" | head -1 | sed 's/.*"\(.*\)".*/\1/') + +if [ -z "$VERSION" ] || [ -z "$CASK_SHA" ]; then + echo "FAIL: could not parse version/sha256 from $CASK_FILE" + exit 1 +fi + +echo "Cask version: $VERSION" +echo "Cask SHA256: $CASK_SHA" + +URL="https://github.com/manaflow-ai/cmux/releases/download/v${VERSION}/cmux-macos.dmg" +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' EXIT + +HTTP_CODE=$(curl -sL -w '%{http_code}' "$URL" -o "$TMPFILE") +FILE_SIZE=$(stat -f%z "$TMPFILE" 2>/dev/null || stat --printf="%s" "$TMPFILE") + +if [ "$HTTP_CODE" != "200" ]; then + echo "FAIL: download returned HTTP $HTTP_CODE (expected 200)" + exit 1 +fi + +if [ "$FILE_SIZE" -lt 1000000 ]; then + echo "FAIL: downloaded file is only $FILE_SIZE bytes (expected >1MB for a DMG)" + exit 1 +fi + +ACTUAL_SHA=$(shasum -a 256 "$TMPFILE" | cut -d' ' -f1) +echo "Actual SHA256: $ACTUAL_SHA" + +if [ "$CASK_SHA" != "$ACTUAL_SHA" ]; then + echo "FAIL: SHA256 mismatch!" + echo " Cask: $CASK_SHA" + echo " Actual: $ACTUAL_SHA" + exit 1 +fi + +echo "PASS: homebrew cask SHA256 matches release DMG"