Fix homebrew SHA mismatch race condition (#111)

Root cause: update-homebrew.yml triggered on release:published, which fires
before softprops/action-gh-release finishes uploading assets. The workflow
downloaded a 404 page instead of the DMG and committed its SHA.

Fix:
- Change trigger from release:published to workflow_run (fires after the
  release workflow completes, guaranteeing assets are uploaded)
- Add download validation with retries and file size checks
- Add SHA verification step before committing to the cask
- Add homebrew cask update to build-sign-upload.sh for local releases
- Add regression test (tests/test_homebrew_sha.sh)
- Update /release and /release-local skills with homebrew verification steps

Fixes #110
This commit is contained in:
Lawrence Chen 2026-02-19 17:44:00 -08:00 committed by GitHub
parent 41639d226c
commit fc1de08561
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 158 additions and 8 deletions

View file

@ -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 ./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"`. 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 ## Changelog Guidelines
**Include only end-user visible changes:** **Include only end-user visible changes:**

View file

@ -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 - Verify the release appears at: https://github.com/manaflow-ai/cmux/releases
- Check that the DMG is attached to the release - 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 success: `say "cmux release complete"`
- On failure: `say "cmux release failed"` - On failure: `say "cmux release failed"`

View file

@ -1,12 +1,15 @@
name: Update Homebrew Cask name: Update Homebrew Cask
on: on:
release: # Trigger after the release workflow completes (not on release:published,
types: [published] # which fires before assets finish uploading — causing SHA mismatch).
workflow_run:
workflows: ["Release macOS app"]
types: [completed]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Version tag (e.g., v1.9.0)' description: 'Version (e.g., 0.58.0 or v0.58.0)'
required: true required: true
permissions: permissions:
@ -15,6 +18,10 @@ permissions:
jobs: jobs:
update-cask: update-cask:
runs-on: ubuntu-latest 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: steps:
- name: Get version - name: Get version
id: version id: version
@ -22,18 +29,40 @@ jobs:
if [ -n "${{ github.event.inputs.version }}" ]; then if [ -n "${{ github.event.inputs.version }}" ]; then
VERSION="${{ github.event.inputs.version }}" VERSION="${{ github.event.inputs.version }}"
else 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 fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
if [ -z "$VERSION" ]; then
echo "Could not determine version" >&2
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Updating homebrew cask to version $VERSION"
- name: Download DMG and get SHA256 - name: Download DMG and get SHA256
id: sha id: sha
run: | run: |
VERSION="${{ steps.version.outputs.version }}" 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) SHA256=$(shasum -a 256 cmux.dmg | cut -d' ' -f1)
echo "sha256=$SHA256" >> $GITHUB_OUTPUT echo "sha256=$SHA256" >> $GITHUB_OUTPUT
echo "DMG SHA256: $SHA256"
- name: Checkout homebrew-cmux - name: Checkout homebrew-cmux
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -76,6 +105,16 @@ jobs:
# Remove leading whitespace from heredoc # Remove leading whitespace from heredoc
sed -i 's/^ //' homebrew-cmux/Casks/cmux.rb 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 - name: Commit and push
env: env:
VERSION: ${{ steps.version.outputs.version }} VERSION: ${{ steps.version.outputs.version }}

@ -1 +1 @@
Subproject commit 72f1e256bed433e7f3c4d0040fd87083345e92a2 Subproject commit 27fdfc514e21a581d363c297970c35e7b6f93b26

View file

@ -91,6 +91,54 @@ fi
# --- Verify --- # --- Verify ---
gh release view "$TAG" 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 --- # --- Cleanup ---
rm -rf build/ cmux-macos.dmg appcast.xml rm -rf build/ cmux-macos.dmg appcast.xml
echo "" echo ""

52
tests/test_homebrew_sha.sh Executable file
View file

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