Files
CC-Switch/.github/workflows/sync-r2.yml
T
Jason 2b2f2cfad9 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
2026-07-27 18:41:49 +08:00

223 lines
9.9 KiB
YAML

# 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