diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1fa7baed..dde229900 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -737,93 +737,3 @@ jobs: run: | set -euxo pipefail gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY" - - # Mirror the release assets to Cloudflare R2 so ccswitch.io/download can - # serve them directly (dl.ccswitch.io). Skips itself when the R2 secrets - # are not configured (e.g. on forks). Required secrets: - # R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY - sync-to-r2: - name: Sync release to R2 - runs-on: ubuntu-22.04 - needs: assemble-latest-json - permissions: - contents: read - env: - R2_BUCKET: cc-switch-releases - R2_PUBLIC_BASE_URL: https://dl.ccswitch.io - KEEP_VERSIONS: "5" - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - # aws-cli >= 2.23 defaults to CRC checksums that R2 rejects. - AWS_REQUEST_CHECKSUM_CALCULATION: when_required - AWS_RESPONSE_CHECKSUM_VALIDATION: when_required - R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com - steps: - - name: Check R2 secrets - id: r2 - run: | - if [ -n "$AWS_ACCESS_KEY_ID" ]; then - echo "configured=true" >> "$GITHUB_OUTPUT" - else - echo "configured=false" >> "$GITHUB_OUTPUT" - echo "R2 secrets not configured; skipping download mirror sync." - fi - - - name: Checkout scripts - if: steps.r2.outputs.configured == 'true' - uses: actions/checkout@v6 - with: - sparse-checkout: scripts - - - name: Download release assets - if: steps.r2.outputs.configured == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - mkdir -p r2-assets - gh release download "$GITHUB_REF_NAME" --dir r2-assets --repo "$GITHUB_REPOSITORY" - ls -la r2-assets - - - name: Generate download manifest - if: steps.r2.outputs.configured == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - pub_date=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json publishedAt --jq .publishedAt) - node scripts/generate-download-manifest.mjs r2-assets "$GITHUB_REF_NAME" "$R2_PUBLIC_BASE_URL" manifest.json "$pub_date" - - - name: Upload assets and manifest to R2 - if: steps.r2.outputs.configured == 'true' - run: | - set -euo pipefail - # Versioned asset paths never change — cache hard at the edge. - aws s3 cp r2-assets "s3://$R2_BUCKET/$GITHUB_REF_NAME/" \ - --recursive \ - --exclude "*.sig" --exclude "*.tar.gz" --exclude "latest.json" \ - --cache-control "public, max-age=31536000, immutable" \ - --endpoint-url "$R2_ENDPOINT" - # The manifest is replaced on every release — keep edge cache short. - aws s3 cp manifest.json "s3://$R2_BUCKET/manifest.json" \ - --content-type "application/json" \ - --cache-control "public, max-age=300" \ - --endpoint-url "$R2_ENDPOINT" - - - name: Prune old versions - if: steps.r2.outputs.configured == 'true' - run: | - set -euo pipefail - versions=$(aws s3 ls "s3://$R2_BUCKET/" --endpoint-url "$R2_ENDPOINT" \ - | awk '$1 == "PRE" {print $2}' | tr -d '/' | grep -E '^v[0-9]' | sort -V || true) - count=$(printf '%s\n' "$versions" | grep -c . || true) - if [ "$count" -le "$KEEP_VERSIONS" ]; then - echo "Nothing to prune ($count versions, keeping up to $KEEP_VERSIONS)." - exit 0 - fi - printf '%s\n' "$versions" | head -n "-$KEEP_VERSIONS" | while read -r old; do - [ -n "$old" ] || continue - echo "Pruning s3://$R2_BUCKET/$old/" - aws s3 rm "s3://$R2_BUCKET/$old/" --recursive --endpoint-url "$R2_ENDPOINT" - done diff --git a/.github/workflows/sync-r2.yml b/.github/workflows/sync-r2.yml new file mode 100644 index 000000000..4ce61372d --- /dev/null +++ b/.github/workflows/sync-r2.yml @@ -0,0 +1,222 @@ +# Mirror release assets to Cloudflare R2 so ccswitch.io/download can serve +# them directly (dl.ccswitch.io), and mirror the Tauri updater manifest and +# artifacts so in-app updates avoid GitHub too (the updater verifies minisign +# signatures client-side, so the mirror is untrusted). +# +# Triggered on release promotion (`released` fires when a prerelease is +# flipped to a full release, or a release is published as stable directly) — +# NOT on tag push. This keeps the R2 root manifests behind the same gate as +# GitHub's /releases/latest/: while a release sits as prerelease, neither the +# download page nor the updater sees it. Additionally, a run only touches the +# root manifests (and prunes old versions) when its tag IS releases/latest — +# manual backfills of older tags restore versioned files only. +# +# Caveat (by design of tauri-plugin-updater): the updater stops at the first +# endpoint whose response parses, so a stale-but-valid R2 manifest would mask +# newer GitHub releases. That is why this sync hard-fails on the official +# repo when the R2 secrets are missing, instead of silently skipping. +# +# Required secrets: R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY +name: Sync release to R2 + +on: + release: + types: [released] + # Manual recovery/backfill: re-sync any existing release tag. + workflow_dispatch: + inputs: + tag: + description: 'Release tag to sync (e.g. v3.18.0)' + required: true + type: string + +permissions: + contents: read + +# latest.json / manifest.json are last-writer-wins — serialize runs and keep +# every trigger queued instead of the default replace-the-pending behavior. +concurrency: + group: sync-r2 + cancel-in-progress: false + queue: max + +jobs: + sync-to-r2: + name: Sync release to R2 + runs-on: ubuntu-22.04 + env: + R2_BUCKET: cc-switch-releases + R2_PUBLIC_BASE_URL: https://dl.ccswitch.io + KEEP_VERSIONS: "5" + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + # aws-cli >= 2.23 defaults to CRC checksums that R2 rejects. + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + TAG: ${{ github.event.release.tag_name || inputs.tag }} + steps: + - name: Check R2 secrets + id: r2 + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + run: | + if [ -z "$TAG" ]; then + echo "::error::No release tag to sync." + exit 1 + fi + if [ -n "$R2_ACCOUNT_ID" ] && [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + elif [ "$GITHUB_REPOSITORY" = "farion1231/cc-switch" ]; then + # A silently stale mirror strands updater users on old versions + # (the R2 endpoint wins as long as its manifest parses), so the + # official repo must never skip this sync. + echo "::error::R2 secrets (R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY) are missing on the official repo; refusing to skip the mirror sync." + exit 1 + else + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "R2 secrets not configured; skipping download mirror sync." + fi + + # Only the tag that IS GitHub's releases/latest may touch the bucket-root + # manifests or prune old versions. This keeps manual backfills of old + # tags harmless (they only restore versioned files — otherwise the root + # manifests would point at a version the prune step deletes right after) + # and makes out-of-order runs self-correcting: whichever run executes, + # only the true latest publishes the manifests. + - name: Check whether tag is the latest stable release + id: latest + if: steps.r2.outputs.configured == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # An API failure must fail the run — mapping it to "not latest" + # would skip the root manifests on a green run, silently leaving + # them stale (the exact failure mode this workflow exists to make + # loud). Retry transient errors, then give up loudly. + latest="" + for attempt in 1 2 3 4; do + latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name || true) + if [ -n "$latest" ]; then + break + fi + if [ "$attempt" -lt 4 ]; then + sleep $((attempt * 10)) + fi + done + if [ -z "$latest" ]; then + echo "::error::Could not resolve the latest stable release from GitHub after 4 attempts; failing instead of guessing." + exit 1 + fi + if [ "$TAG" = "$latest" ]; then + echo "is_latest=true" >> "$GITHUB_OUTPUT" + echo "$TAG is the latest stable release; full sync." + else + echo "is_latest=false" >> "$GITHUB_OUTPUT" + echo "::warning::$TAG is not the latest stable release ($latest); syncing versioned files only — root manifests and pruning are skipped." + fi + + - name: Checkout scripts + if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true' + uses: actions/checkout@v6 + with: + sparse-checkout: scripts + + - name: Download release assets + if: steps.r2.outputs.configured == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p r2-assets + gh release download "$TAG" --dir r2-assets --repo "$GITHUB_REPOSITORY" + ls -la r2-assets + + - name: Generate download manifest + if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + pub_date=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json publishedAt --jq .publishedAt) + node scripts/generate-download-manifest.mjs r2-assets "$TAG" "$R2_PUBLIC_BASE_URL" manifest.json "$pub_date" + + - name: Rewrite updater manifest for R2 + if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true' + run: | + set -euo pipefail + node scripts/rewrite-updater-manifest.mjs r2-assets/latest.json "$TAG" "$R2_PUBLIC_BASE_URL" latest-r2.json + + - name: Upload versioned assets to R2 + if: steps.r2.outputs.configured == 'true' + run: | + set -euo pipefail + # Versioned asset paths never change — cache hard at the edge. + # .tar.gz is the macOS updater payload; .sig files are not needed + # (signatures are embedded in latest.json) and latest.json goes to + # the bucket root with a short TTL instead. + aws s3 cp r2-assets "s3://$R2_BUCKET/$TAG/" \ + --recursive \ + --exclude "*.sig" --exclude "latest.json" \ + --cache-control "public, max-age=31536000, immutable" \ + --endpoint-url "$R2_ENDPOINT" + + - name: Publish root manifests to R2 + if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Re-verify right before touching the root manifests: a newer + # release may have been promoted while assets were downloading. + # Skipping then is safe — the newer release's own queued run + # publishes the newer manifests. An unresolvable API is still a + # hard failure, never a guess. + latest="" + for attempt in 1 2 3 4; do + latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name || true) + if [ -n "$latest" ]; then + break + fi + if [ "$attempt" -lt 4 ]; then + sleep $((attempt * 10)) + fi + done + if [ -z "$latest" ]; then + echo "::error::Could not re-verify the latest stable release before publishing root manifests; failing instead of guessing." + exit 1 + fi + if [ "$TAG" != "$latest" ]; then + echo "::warning::$TAG stopped being the latest stable release mid-run ($latest is); leaving root manifests untouched." + exit 0 + fi + # Both manifests are replaced on every release — keep edge cache + # short. Uploaded after the assets they reference so an aborted run + # never leaves them pointing at missing files. + aws s3 cp manifest.json "s3://$R2_BUCKET/manifest.json" \ + --content-type "application/json" \ + --cache-control "public, max-age=300" \ + --endpoint-url "$R2_ENDPOINT" + aws s3 cp latest-r2.json "s3://$R2_BUCKET/latest.json" \ + --content-type "application/json" \ + --cache-control "public, max-age=300" \ + --endpoint-url "$R2_ENDPOINT" + + - name: Prune old versions + if: steps.r2.outputs.configured == 'true' && steps.latest.outputs.is_latest == 'true' + run: | + set -euo pipefail + versions=$(aws s3 ls "s3://$R2_BUCKET/" --endpoint-url "$R2_ENDPOINT" \ + | awk '$1 == "PRE" {print $2}' | tr -d '/' | grep -E '^v[0-9]' | sort -V || true) + count=$(printf '%s\n' "$versions" | grep -c . || true) + if [ "$count" -le "$KEEP_VERSIONS" ]; then + echo "Nothing to prune ($count versions, keeping up to $KEEP_VERSIONS)." + exit 0 + fi + printf '%s\n' "$versions" | head -n "-$KEEP_VERSIONS" | while read -r old; do + [ -n "$old" ] || continue + echo "Pruning s3://$R2_BUCKET/$old/" + aws s3 rm "s3://$R2_BUCKET/$old/" --recursive --endpoint-url "$R2_ENDPOINT" + done diff --git a/scripts/rewrite-updater-manifest.mjs b/scripts/rewrite-updater-manifest.mjs new file mode 100644 index 000000000..e53e9fb23 --- /dev/null +++ b/scripts/rewrite-updater-manifest.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// Rewrites the Tauri updater manifest (latest.json) so its download URLs point +// at the R2 mirror instead of GitHub Releases. Minisign signatures cover file +// contents, not URLs, so they stay valid unchanged — the updater still verifies +// every downloaded artifact against the pubkey baked into the app. +// +// Usage: node scripts/rewrite-updater-manifest.mjs [output] + +import { readFileSync, writeFileSync } from 'node:fs'; + +const [input, tag, baseUrl, output = 'latest-r2.json'] = process.argv.slice(2); + +if (!input || !tag || !baseUrl) { + console.error('Usage: node scripts/rewrite-updater-manifest.mjs [output]'); + process.exit(1); +} + +const manifest = JSON.parse(readFileSync(input, 'utf8')); +const platforms = Object.entries(manifest.platforms ?? {}); +if (platforms.length === 0) { + console.error(`No platforms found in ${input}`); + process.exit(1); +} + +const normalizedBase = baseUrl.replace(/\/+$/, ''); +// Mirrored artifacts live at //; on GitHub they live at +// .../releases/download//. Rebuild from the filename rather than +// prefix-replacing so a layout change on either side fails loudly here. +const githubPrefix = `https://github.com/`; + +for (const [key, entry] of platforms) { + if (typeof entry?.url !== 'string' || typeof entry?.signature !== 'string') { + console.error(`Platform ${key} is missing url or signature`); + process.exit(1); + } + if (!entry.url.startsWith(githubPrefix) || !entry.url.includes(`/releases/download/${tag}/`)) { + console.error(`Platform ${key} has unexpected url for ${tag}: ${entry.url}`); + process.exit(1); + } + const name = entry.url.split('/').pop(); + entry.url = `${normalizedBase}/${tag}/${name}`; +} + +writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`); +console.log(`Wrote ${output} with ${platforms.length} platforms pointing at ${normalizedBase}/${tag}/`); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 322008ac6..c93d55b25 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -62,6 +62,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK", "endpoints": [ + "https://dl.ccswitch.io/latest.json", "https://github.com/farion1231/cc-switch/releases/latest/download/latest.json" ] }