From 27aab3a0356c5d7c2407f947feca353bcfe6bfc9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 30 Mar 2026 04:50:18 -0700 Subject: [PATCH] Simplify R2 to appcast-only (keep DMGs on GitHub) (#2363) * Simplify R2 upload to appcast-only (keep DMGs on GitHub) DMGs are immutable per-build on GitHub Releases (unique filenames, no overwrite), so there's no race condition for them. Only the appcast.xml needs atomic replacement, which R2 PutObject provides. Upload the original appcast.xml as-is (GitHub Release DMG URLs) to R2. No sed URL rewriting, no DMG uploads, less storage/bandwidth. * Move R2 appcast upload after GitHub Release publish The R2 appcast references GitHub Release DMG URLs, so it must be uploaded after the DMGs exist on GitHub. Previously the R2 upload ran before the publish step, creating a brief window where the appcast pointed to a not-yet-existing DMG. * Add semver guard to stable R2 appcast upload Prevents a backport tag (e.g. v0.62.1 pushed after v0.63.1) from overwriting the stable appcast with an older version. Uses sort -V to compare all non-prerelease tags and only uploads if the current tag is the highest. --------- Co-authored-by: Lawrence Chen --- .github/workflows/nightly.yml | 59 ++++++++++++-------------------- .github/workflows/release.yml | 63 ++++++++++++++++++----------------- 2 files changed, 54 insertions(+), 68 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1b47b2e3..64dc72a6 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -503,43 +503,6 @@ jobs: # installs to migrate onto the unified nightly appcast. 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 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 @@ -598,6 +561,28 @@ jobs: appcast-universal.xml overwrite_files: true + - name: Upload nightly appcast 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; } + + # Upload after GitHub Release publish so the appcast never references + # a DMG that doesn't exist yet. R2 PutObject is atomic, so the appcast + # is either the old version or the new one, never missing. + aws s3 cp appcast.xml \ + "s3://cmux-binaries/nightly/appcast.xml" \ + --endpoint-url "$R2_ENDPOINT" \ + --cache-control "no-cache, no-store, must-revalidate" + + echo "R2 appcast upload complete: https://files.cmux.com/nightly/appcast.xml" + - name: Cleanup keychain if: always() run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e50e61d..2f4be207 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,37 +336,6 @@ jobs: fi ./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 if: steps.guard_release_assets.outputs.skip_all != 'true' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 @@ -406,6 +375,38 @@ jobs: generate_release_notes: true overwrite_files: false + - name: Upload release appcast 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; } + + # Guard: only upload if this tag is the highest semver release. + # Prevents a backport tag (e.g. v0.62.1 after v0.63.1) from + # overwriting the stable appcast with an older version. + LATEST=$(gh release list --exclude-drafts --exclude-pre-releases \ + --json tagName -q '.[].tagName' | sort -V | tail -1) + if [ -n "$LATEST" ] && [ "$LATEST" != "$GITHUB_REF_NAME" ]; then + echo "Skipping R2 stable upload: $GITHUB_REF_NAME is not the latest release ($LATEST)" + exit 0 + fi + + # Upload after GitHub Release publish so the appcast never references + # a DMG that doesn't exist yet. R2 PutObject is atomic, so the appcast + # is either the old version or the new one, never missing. + aws s3 cp appcast.xml \ + "s3://cmux-binaries/stable/appcast.xml" \ + --endpoint-url "$R2_ENDPOINT" \ + --cache-control "no-cache, no-store, must-revalidate" + + echo "R2 appcast upload complete: https://files.cmux.com/stable/appcast.xml" + - name: Cleanup keychain if: always() run: |