From 1c512819b0292934e5bc85309ae7fa5ad6b47692 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 29 Mar 2026 20:31:15 -0700 Subject: [PATCH 1/4] Add R2 dual-write for nightly appcast and DMGs Upload nightly DMGs and appcast to Cloudflare R2 (files.cmux.com) alongside the existing GitHub Release assets. R2 uses atomic PutObject so the appcast never 404s during replacement, fixing the transient SUDownloadError 2001 that occurs when GitHub Release assets are being overwritten. DMGs are uploaded before the appcast so the feed never references a file that doesn't exist yet. The GitHub Release upload is unchanged, so existing nightly users are unaffected. A follow-up PR will switch the Sparkle feed URL in the app bundle from GitHub Releases to R2 after manual verification. --- .github/workflows/nightly.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 72e270584465..7d4c3fafca4f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -503,6 +503,41 @@ jobs: # installs to migrate onto the unified nightly appcast. cp appcast.xml appcast-universal.xml + - name: Generate R2-targeted appcast + 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 }} + DOWNLOAD_URL_PREFIX: "https://files.cmux.com/nightly/" + RELEASE_NOTES_URL: "https://github.com/manaflow-ai/cmux/releases/tag/nightly" + run: | + ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast-r2.xml + + - name: Upload nightly assets 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' + 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 + BUCKET=cmux-binaries + + # Upload DMGs first so the appcast never references a missing file. + aws s3 cp "$NIGHTLY_DMG_IMMUTABLE" \ + "s3://${BUCKET}/nightly/${NIGHTLY_DMG_IMMUTABLE}" \ + --endpoint-url "$R2_ENDPOINT" + aws s3 cp cmux-nightly-macos.dmg \ + "s3://${BUCKET}/nightly/cmux-nightly-macos.dmg" \ + --endpoint-url "$R2_ENDPOINT" + + # Upload appcast last (atomic PutObject, no 404 window). + aws s3 cp appcast-r2.xml \ + "s3://${BUCKET}/nightly/appcast.xml" \ + --endpoint-url "$R2_ENDPOINT" + + echo "R2 upload complete: https://files.cmux.com/nightly/appcast.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 From 17a1f246b20d750027ebe64622b65c4f3c8139a5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 29 Mar 2026 23:02:19 -0700 Subject: [PATCH 2/4] Add continue-on-error to R2 steps R2 upload failures should not block the existing GitHub Release publish. This keeps the nightly pipeline safe while R2 is new. --- .github/workflows/nightly.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7d4c3fafca4f..11f1e1f7722d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -505,6 +505,7 @@ jobs: - name: Generate R2-targeted appcast 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: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} DOWNLOAD_URL_PREFIX: "https://files.cmux.com/nightly/" @@ -514,6 +515,7 @@ jobs: - name: Upload nightly assets 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 }} From b0e785c35dd68f9d130f786385be6e31918295a0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 03:51:07 -0700 Subject: [PATCH 3/4] Add R2 dual-write for stable release appcast and DMG Same pattern as nightly: upload DMG then appcast to R2 (files.cmux.com/stable/) alongside the GitHub Release. Both steps use continue-on-error so R2 failures can't block the release. --- .github/workflows/release.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06d053717af2..0a66e1ab23a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,6 +336,40 @@ jobs: fi ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml + - name: Generate R2-targeted appcast + if: steps.guard_release_assets.outputs.skip_all != 'true' + continue-on-error: true + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + DOWNLOAD_URL_PREFIX: "https://files.cmux.com/stable/" + RELEASE_NOTES_URL: "https://github.com/manaflow-ai/cmux/releases/tag/${{ github.ref_name }}" + run: | + ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast-r2.xml + + - name: Upload release assets to R2 + if: steps.guard_release_assets.outputs.skip_upload != 'true' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + 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 + BUCKET=cmux-binaries + + # Upload DMG first so the appcast never references a missing file. + aws s3 cp cmux-macos.dmg \ + "s3://${BUCKET}/stable/cmux-macos.dmg" \ + --endpoint-url "$R2_ENDPOINT" + + # Upload appcast last (atomic PutObject, no 404 window). + aws s3 cp appcast-r2.xml \ + "s3://${BUCKET}/stable/appcast.xml" \ + --endpoint-url "$R2_ENDPOINT" + + echo "R2 upload complete: https://files.cmux.com/stable/appcast.xml" + - name: Attest remote daemon release assets if: steps.guard_release_assets.outputs.skip_all != 'true' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 From 7ba3ea0aa50da6b49fcbdcc5ea4cf4b740386b0d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 30 Mar 2026 03:52:57 -0700 Subject: [PATCH 4/4] Address review feedback: cache headers, no double build, AWS CLI guard - Add Cache-Control headers: immutable versioned DMGs get max-age=1yr, mutable appcast.xml and latest DMG get no-cache to prevent stale CDN - Replace separate appcast generation step with sed URL replacement, avoiding a second Sparkle clone+build (signature is over DMG content, not the URL) - Add AWS CLI availability check with fallback brew install --- .github/workflows/nightly.yml | 28 ++++++++++++++-------------- .github/workflows/release.yml | 21 +++++++++------------ 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 11f1e1f7722d..1b47b2e30c41 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -503,16 +503,6 @@ jobs: # installs to migrate onto the unified nightly appcast. cp appcast.xml appcast-universal.xml - - name: Generate R2-targeted appcast - 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: - SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} - DOWNLOAD_URL_PREFIX: "https://files.cmux.com/nightly/" - RELEASE_NOTES_URL: "https://github.com/manaflow-ai/cmux/releases/tag/nightly" - run: | - ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast-r2.xml - - name: Upload nightly assets 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 @@ -523,20 +513,30 @@ jobs: 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; } BUCKET=cmux-binaries - # Upload DMGs first so the appcast never references a missing file. + # Derive R2 appcast from the GitHub one by replacing the download URL prefix. + # EdDSA signature is over the DMG content, not the URL, so this is safe. + sed 's|https://github.com/manaflow-ai/cmux/releases/download/nightly/|https://files.cmux.com/nightly/|g' \ + appcast.xml > appcast-r2.xml + + # Upload immutable versioned DMG (cacheable forever). aws s3 cp "$NIGHTLY_DMG_IMMUTABLE" \ "s3://${BUCKET}/nightly/${NIGHTLY_DMG_IMMUTABLE}" \ - --endpoint-url "$R2_ENDPOINT" + --endpoint-url "$R2_ENDPOINT" \ + --cache-control "max-age=31536000, immutable" + # Upload mutable latest DMG (no cache). aws s3 cp cmux-nightly-macos.dmg \ "s3://${BUCKET}/nightly/cmux-nightly-macos.dmg" \ - --endpoint-url "$R2_ENDPOINT" + --endpoint-url "$R2_ENDPOINT" \ + --cache-control "no-cache, no-store, must-revalidate" # Upload appcast last (atomic PutObject, no 404 window). aws s3 cp appcast-r2.xml \ "s3://${BUCKET}/nightly/appcast.xml" \ - --endpoint-url "$R2_ENDPOINT" + --endpoint-url "$R2_ENDPOINT" \ + --cache-control "no-cache, no-store, must-revalidate" echo "R2 upload complete: https://files.cmux.com/nightly/appcast.xml" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a66e1ab23a1..5e50e61dd852 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,16 +336,6 @@ jobs: fi ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml - - name: Generate R2-targeted appcast - if: steps.guard_release_assets.outputs.skip_all != 'true' - continue-on-error: true - env: - SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} - DOWNLOAD_URL_PREFIX: "https://files.cmux.com/stable/" - RELEASE_NOTES_URL: "https://github.com/manaflow-ai/cmux/releases/tag/${{ github.ref_name }}" - run: | - ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast-r2.xml - - name: Upload release assets to R2 if: steps.guard_release_assets.outputs.skip_upload != 'true' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') continue-on-error: true @@ -356,17 +346,24 @@ jobs: 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; } BUCKET=cmux-binaries + # Derive R2 appcast by replacing the download URL prefix. + sed "s|https://github.com/manaflow-ai/cmux/releases/download/${GITHUB_REF_NAME}/|https://files.cmux.com/stable/|g" \ + appcast.xml > appcast-r2.xml + # Upload DMG first so the appcast never references a missing file. aws s3 cp cmux-macos.dmg \ "s3://${BUCKET}/stable/cmux-macos.dmg" \ - --endpoint-url "$R2_ENDPOINT" + --endpoint-url "$R2_ENDPOINT" \ + --cache-control "no-cache, no-store, must-revalidate" # Upload appcast last (atomic PutObject, no 404 window). aws s3 cp appcast-r2.xml \ "s3://${BUCKET}/stable/appcast.xml" \ - --endpoint-url "$R2_ENDPOINT" + --endpoint-url "$R2_ENDPOINT" \ + --cache-control "no-cache, no-store, must-revalidate" echo "R2 upload complete: https://files.cmux.com/stable/appcast.xml"