ci: mirror in-app updater to Cloudflare R2 with release-gated sync

- Move the R2 sync out of release.yml into a standalone sync-r2.yml
  triggered on release promotion (release: released) or manual dispatch,
  so the mirror follows the same gate as GitHub's /releases/latest
- Hard-fail on the official repo when R2 secrets are missing (a silently
  stale mirror strands updater users); forks may still skip
- Only the tag that is currently releases/latest may rewrite the root
  manifests or prune old versions; backfills of older tags restore
  versioned files only
- Resolve releases/latest with retries and fail instead of guessing on
  API errors; re-verify right before publishing the root manifests
- Add scripts/rewrite-updater-manifest.mjs to point latest.json download
  URLs at the mirror (minisign signatures cover file contents and stay
  valid); upload the macOS .tar.gz updater payload alongside installers
- Put https://dl.ccswitch.io/latest.json first in the updater endpoints
  with GitHub as connectivity fallback
This commit is contained in:
Jason
2026-07-27 18:41:49 +08:00
parent 708b38791c
commit 2b2f2cfad9
4 changed files with 268 additions and 90 deletions
-90
View File
@@ -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
+222
View File
@@ -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
+45
View File
@@ -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 <latest-json> <tag> <base-url> [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 <latest-json> <tag> <base-url> [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 <base>/<tag>/<file>; on GitHub they live at
// .../releases/download/<tag>/<file>. 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}/`);
+1
View File
@@ -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"
]
}