feat(site): serve downloads via Cloudflare R2 mirror with GitHub fallback#1000
Conversation
…back Make the landing page download buttons serve installers from a stable, China-accessible, CDN-cached URL instead of the GitHub Releases page, which mainland-China users without a VPN reach poorly. Design (chosen over fixed-name + cache-purge to avoid release-day stale/corrupt downloads): version-stamped objects are immutable so the edge cache is always correct; a tiny no-cache latest.json pointer is the single switch that makes a release live. No Worker needed — the page reads the pointer client-side. - site/: download buttons gain data-dl keys; on load the page fetches a no-store latest.json from R2 and swaps button hrefs to per-platform direct links. If the manifest is unreachable, the GitHub Releases fallback stands, so buttons always work. Until R2 is configured the live behavior is exactly today's GitHub fallback. - mirror-release-to-r2.yml + scripts/mirror-release-to-r2.ts: on release published (or manual tag dispatch), gate on the existing verify-release.ts (fail closed if incomplete), upload immutable versioned artifacts first with HEAD size checks, then the updater latest*.yml, and finally latest.json — so a failure leaves the site on the previous good release, never half-mirrored. - Reuses releaseAssetNames() from verify-release.ts so mirrored names track the release pipeline. concurrency guard prevents event/manual-rerun races. Verification: site builds; buttons render with data-dl + GitHub fallback href; mirror script imports resolve and usage/missing-env paths exit correctly; desktop-electron typecheck green; bun-version workflow guard updated and green. Live href-swap is verifiable once the R2 bucket, dl.pawwork.ai custom domain, CORS, and R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY secrets are configured. Follow-up: in-app updater generic provider (#219) can reuse the mirrored latest*.yml; out of scope here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
More reviews will be available in 39 minutes and 37 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe PR adds a complete Cloudflare R2–backed release distribution pipeline: a Bun script that downloads GitHub Release assets and uploads them to R2 in load-bearing order (versioned installers, then pointers, then manifest), a GitHub Actions workflow that triggers on release publish and runs the script with credential injection, comprehensive tests validating manifest generation and upload ordering, and site-side integration that fetches the R2 manifest to provide platform-specific download URLs with GitHub Releases as fallback. ChangesR2-Backed Release Distribution and Site Download Integration
Sequence Diagram(s)sequenceDiagram
participant GitHub as GitHub Release
participant Script as mirror-release-to-r2
participant R2
Script->>GitHub: fetch assets by tag
Script->>Script: stage assets in temp dir
Script->>Script: validate all assets present
Script->>Script: compute upload plan (versioned, pointers, latest.json)
Script->>R2: upload versioned installers (immutable)
Script->>R2: upload updater pointer files (no-cache)
Script->>Script: generate latest.json manifest
Script->>R2: upload latest.json (no-cache, final switch)
Script->>R2: verify content size for each upload
sequenceDiagram
participant Browser
participant Homepage as index.astro
participant Manifest as dl.pawwork.ai/latest.json
participant GitHub as GitHub Releases
Browser->>Homepage: load page with download buttons
Homepage->>Manifest: fetch DOWNLOAD_MANIFEST_URL
alt manifest reachable
Manifest->>Homepage: return platform URLs
Homepage->>Browser: replace button hrefs with manifest URLs
else manifest unreachable
Homepage->>Browser: keep GitHub Releases hrefs as fallback
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a script and workflow to mirror GitHub Release installers to Cloudflare R2, enabling faster, CDN-cached downloads for the China landing page. It also updates the landing page to dynamically fetch the download manifest and swap the download buttons to direct R2 links, falling back to GitHub Releases if the fetch fails. Feedback on these changes includes a recommendation to clean up the temporary directory created during the mirroring process using a try...finally block with rm to prevent disk space accumulation, and a security suggestion to validate the fetched URLs' protocols (e.g., ensuring they start with https:// or http://) before setting them as href attributes to mitigate potential DOM XSS vulnerabilities.
- index.astro: validate manifest URLs are http(s) before setting button hrefs, guarding against a compromised manifest injecting javascript: hrefs. - mirror-release-to-r2.ts: always clean up the temp download dir via try/finally (extracted the body into mirror()); import rm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review P3: the mirror script's release-correctness semantics had no
regression test, so a future change could silently break the upload order or
manifest keys and only surface at release time.
- Extract two pure functions from mirror-release-to-r2.ts: buildManifest()
(the { version, macArm64, macX64, winX64 } pointer) and uploadPlan() (the
ordered, load-bearing upload sequence: immutable versioned artifacts ->
mutable updater pointers -> latest.json last). mirror() now drives uploads
from uploadPlan(), so the tested order is the executed order.
- Guard the CLI entry with `if (import.meta.main)` so the module can be
imported by tests without running main().
- Add mirror-release-to-r2.test.ts locking manifest shape/URLs (incl. trailing
slash), the versioned -> pointers -> manifest ordering, cache-control per
class, and that every released asset is uploaded exactly once plus manifest.
Verification: bun test scripts/mirror-release-to-r2.test.ts (6 pass); CLI usage
and missing-env paths still exit non-zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/desktop-electron/scripts/mirror-release-to-r2.ts (1)
135-161: 💤 Low valueConsider handling malformed
head-objectresponses.If the
aws s3api head-objectcommand returns unexpected JSON (e.g., during transient API errors),JSON.parsewill throw an unhandled exception with a confusing message. A brief validation or try-catch with a clearer error would improve debuggability.🛡️ Optional defensive parse
const head = JSON.parse( await run(["aws", "s3api", "head-object", "--bucket", bucket, "--key", name, "--endpoint-url", endpoint]), ) + if (typeof head.ContentLength !== "number") { + throw new Error(`Unexpected head-object response for ${name}: missing ContentLength`) + } const localSize = (await stat(local)).size🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/desktop-electron/scripts/mirror-release-to-r2.ts` around lines 135 - 161, The JSON.parse of the head-object output inside the upload function can throw on malformed or non-JSON responses; wrap the call to run(["aws", "s3api", "head-object", ...]) in a try/catch, capture the raw output string, attempt JSON.parse inside the try, and if parse fails throw a new Error that includes the command output and the object key (step.name) for context; also validate that the parsed head has a numeric ContentLength before comparing to localSize and throw a clear error if it's missing or invalid.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/mirror-release-to-r2.yml:
- Around line 42-52: The run commands interpolate steps.tag.outputs.tag without
quoting, risking shell injection; update the workflow step commands that call
verify-release.ts and mirror-release-to-r2.ts to quote the tag value (use single
quotes around "${{ steps.tag.outputs.tag }}" in the run lines) so the variable
is passed as a literal argument to the scripts (refer to the step id "tag" and
the scripts verify-release.ts and mirror-release-to-r2.ts).
---
Nitpick comments:
In `@packages/desktop-electron/scripts/mirror-release-to-r2.ts`:
- Around line 135-161: The JSON.parse of the head-object output inside the
upload function can throw on malformed or non-JSON responses; wrap the call to
run(["aws", "s3api", "head-object", ...]) in a try/catch, capture the raw output
string, attempt JSON.parse inside the try, and if parse fails throw a new Error
that includes the command output and the object key (step.name) for context;
also validate that the parsed head has a numeric ContentLength before comparing
to localSize and throw a clear error if it's missing or invalid.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 9ec7c20b-fb4d-4547-bf8d-6859318ecb74
📒 Files selected for processing (6)
.github/workflows/mirror-release-to-r2.ymlpackages/desktop-electron/scripts/mirror-release-to-r2.test.tspackages/desktop-electron/scripts/mirror-release-to-r2.tspackages/opencode/test/github/bun-version-workflow.test.tssite/src/config.tssite/src/pages/index.astro
…rror
Address review P1: the mirror workflow interpolated ${{ ...tag... }} directly
into run: scripts, including the step that holds the R2 write secrets. A
release/dispatch tag containing shell metacharacters (e.g. $(...)) would run
arbitrary commands in that step and could exfiltrate the credentials.
- Resolve the tag once into a job env var (TAG) — env values are passed as
data, not spliced into the script text — and reference "$TAG" in the verify
and mirror run: steps. Drop the echo-to-GITHUB_OUTPUT resolve step entirely.
- Add a regression guard to mirror-release-to-r2.test.ts: no run: line may
contain a ${{ }} expression, and the scripts must be invoked with "$TAG".
The concurrency group still uses the expression — that is a group-name string,
not a shell context, so it is not injectable.
Verification: bun test scripts/mirror-release-to-r2.test.ts (8 pass);
bun-version workflow guard still green; CLI missing-env path still exits 1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…219) (#1003) * feat(updater): route in-app updates through R2 with GitHub fallback (#219) Mainland China users frequently hit slow, intermittent, or blocked GitHub update checks. The prod release feed is already mirrored to Cloudflare R2 (dl.pawwork.ai, PR #1000 / mirror-release-to-r2.yml). This wires that mirror into the desktop updater. electron-updater does not fail over across providers and binds the download source at check time (downloadUpdate reuses the updateInfoAndProvider from the last checkForUpdates). So feed selection is done at runtime via setFeedURL, not by changing the baked app-update.yml: - new update-feed.ts: a provider-agnostic feed selector. check() tries feeds in order (R2 first, GitHub fallback) with a per-feed timeout; the winning feed becomes active and serves the download. download() retries on GitHub if an R2 download fails. A generation guard stops a timed-out check that resolves late from clobbering the active feed. - index.ts: build the ordered feeds (R2 for prod, GitHub fallback; beta is GitHub-only with no mirror) and route the controller's check/download through the selector. setupAutoUpdater sets an initial feed (with its own GitHub fallback) and logs the selection. - app-update.yml is intentionally left provider: github. The runtime always re-selects the feed before each check, so the baked value is only a startup safety net; keeping GitHub there avoids touching CI grep checks, the three app-update tests, and Windows/mac packaging, with no behavior difference. - mirror-release-to-r2.ts now fails the mirror if a latest*.yml pointer references an asset it would not upload, guarding the R2 feed alignment. - dev harness: PAWWORK_DEV_UPDATER=1 enables forceDevUpdateConfig under dev:desktop to exercise the real R2 feed without a signed build. Verification: bun test (423 pass; the lone failure is the pre-existing packaged-app smoke test that needs a built binary), typecheck, lint, and electron-vite build all green. Live check against the current release (v2026.5.29): R2 latest-mac.yml / latest.yml are byte-identical to the GitHub release and every referenced binary HEADs 200 on dl.pawwork.ai. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(updater): remove duplicated feed config declarations (#219) A bad edit replay during development inserted the constants.ts feed block and the index.ts UPDATE_FEED_TIMEOUT_MS const twice, which compiled locally in a stale state but failed CI typecheck (TS2451 redeclare) and broke the desktop build/tests. Deduplicate to a single declaration of each. Verified locally: typecheck, biome lint, electron-vite build, and bun test (535 pass, 0 fail) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(updater): assert the UPDATER_ACTIVE early-return guard (#219) The source-contract test still grepped the old `!UPDATER_ENABLED` guard, but setupAutoUpdater now early-returns on `!UPDATER_ACTIVE`. This edit was lost in an earlier replay and never made the first commit, so unit-desktop failed. Point the regex at the current guard. Verified: bun test in packages/desktop-electron is 436 pass, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mirror): drop duplicated pointer-alignment describe block (#219) Same edit-replay glitch that duplicated the constants/index declarations also inserted the "pointer reference alignment" describe twice in the mirror test (it passed because the cases just ran twice). Gemini flagged it; remove the second copy. Verified: bun test mirror-release-to-r2.test.ts is 7 pass, 0 fail; biome lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(updater): select feed via cancellable probe, not an uncancellable check race (#219) Review P1: the R2-timeout fallback raced electron-updater's checkForUpdates() with Promise.race but never cancelled the underlying check. A slow R2 check could resolve late on the shared autoUpdater instance and rebind the provider back to R2 *after* we had fallen back to GitHub — so "fell back to GitHub" was a lie and the download could still go to the slow/unreachable R2 the fallback exists to avoid. The generation guard only protected our return value, not electron-updater's internal updateInfoAndProvider. Fix: choose the feed with a cancellable reachability probe (HEAD on the channel file, aborted on timeout via AbortController), then run exactly one checkForUpdates() against the winning feed. Only one real check ever runs, bound to the chosen feed; downloadUpdate() reuses that provider. No abandoned check can rebind it. Also addresses review P2: the download-fallback now compares the GitHub re-check version against the version the controller validated and fails closed on mismatch, so it can never mark a different release ready than it checked. Verified: typecheck, lint, build, bun test (433 pass, 0 fail). New tests assert single-check-on-fallback (incl. the abort/timeout path) and the version-mismatch fail-closed. Real electron-updater fallback (point R2 at a bad host under PAWWORK_DEV_UPDATER=1 dev:desktop) remains a manual pre-merge check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(updater): fallback GitHub on check reject + expose UPDATER_ACTIVE to renderer (#219) - check() now catches R2 checkForUpdates() reject and retries on the next feed (GitHub). Covers "R2 probe 200 but metadata fetch fails" — including the 60 s socket timeout built into builder-util-runtime. - diagnostics() and getWindowConfig() now expose UPDATER_ACTIVE instead of UPDATER_ENABLED so the renderer reflects dev-harness updater state. - 3 new tests: R2 check reject → GitHub fallback, both feeds reject → throw, download fallback re-check reject → fail closed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(updater): align module header with sequential check fallback (#219) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Goal
Make the landing page download buttons serve installers from a stable, China-accessible, Cloudflare-CDN-cached URL instead of linking to the GitHub Releases page (mainland-China users without a VPN download from GitHub poorly).
Design
Chosen over "fixed filename + cache purge" to avoid release-day stale/corrupt downloads of large installers:
no-cachelatest.jsonpointer is the single switch that makes a release live.Changes
site/: download buttons gaindata-dlkeys; on load the page fetches ano-storelatest.jsonfrom R2 and swaps hrefs to per-platform direct links. If the manifest is unreachable, the GitHub Releases fallback stands, so buttons always work. Until R2 is configured, live behavior is exactly today's GitHub fallback..github/workflows/mirror-release-to-r2.yml+packages/desktop-electron/scripts/mirror-release-to-r2.ts: onrelease: published(or manual tag dispatch), gate on the existingverify-release.ts(fail closed if incomplete) → upload immutable versioned artifacts first with HEAD size checks → then updaterlatest*.yml→ finallylatest.json. A failure leaves the site on the previous good release, never half-mirrored.concurrencyguard prevents event/manual-rerun races; reusesreleaseAssetNames()so mirrored names track the release pipeline.Required before this goes live (Cloudflare + secrets, maintainer)
pawwork-downloads.dl.pawwork.aibound to the bucket (public).GETfromhttps://pawwork.ai(so the page can readlatest.json).R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY. Account id reuses existingCLOUDFLARE_ACCOUNT_ID.Verification
data-dl+ GitHub fallback href; manifest URL inlined.releaseAssetNames()output matches real release assets.@opencode-ai/desktop-electrontypecheck green;bun-version-workflowguard updated and green.Follow-up
In-app updater generic provider (#219) can reuse the mirrored
latest*.yml; out of scope here.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Chores