Add R2 dual-write for nightly appcast and DMGs (#2335)

* 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.

* 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.

* 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.

* 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

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-30 03:54:01 -07:00 committed by GitHub
parent 90a9edb761
commit d6d9130c72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 0 deletions

View file

@ -503,6 +503,43 @@ jobs:
# installs to migrate onto the unified nightly appcast. # installs to migrate onto the unified nightly appcast.
cp appcast.xml appcast-universal.xml cp appcast.xml appcast-universal.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
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; }
BUCKET=cmux-binaries
# 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" \
--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" \
--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" \
--cache-control "no-cache, no-store, must-revalidate"
echo "R2 upload complete: https://files.cmux.com/nightly/appcast.xml"
- name: Attest remote daemon nightly assets - 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') 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 uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0

View file

@ -336,6 +336,37 @@ jobs:
fi fi
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.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
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" \
--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" \
--cache-control "no-cache, no-store, must-revalidate"
echo "R2 upload complete: https://files.cmux.com/stable/appcast.xml"
- name: Attest remote daemon release assets - name: Attest remote daemon release assets
if: steps.guard_release_assets.outputs.skip_all != 'true' if: steps.guard_release_assets.outputs.skip_all != 'true'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0