Skip to content

feat: deliver desktop updates through Cloudflare R2 - #165

Merged
Soulter merged 5 commits into
mainfrom
codex/desktop-r2-updater
Aug 7, 2026
Merged

feat: deliver desktop updates through Cloudflare R2#165
Soulter merged 5 commits into
mainfrom
codex/desktop-r2-updater

Conversation

@Soulter

@Soulter Soulter commented Aug 7, 2026

Copy link
Copy Markdown
Member

What changed

  • move stable and nightly Tauri updater manifests to releases.astrbot.app
  • publish signed updater artifacts to immutable Cloudflare R2 release paths, verify them on the public origin, and only then promote the channel manifest
  • restore signed Linux AppImage updater artifacts for amd64 and arm64 while keeping deb/rpm as manual-install packages
  • ship macOS DMG installers with an Applications shortcut while retaining signed .app.tar.gz updater artifacts
  • use the pinned prebuilt @tauri-apps/cli in CI instead of compiling tauri-cli on every runner
  • document the R2 release configuration and platform-specific install/update behavior

Why

AstrBot Desktop already had a Tauri self-update bridge and GitHub Release manifests, but distribution still depended on GitHub assets, Linux AppImage publishing had been disabled, and macOS did not offer a normal drag-to-Applications installer. The release flow also promoted mutable manifests without first verifying every immutable updater object on the public download origin.

This change makes Desktop updates independent from the AstrBot Core update API and gives stable/nightly channels a dedicated, cacheable R2-backed delivery path.

User impact

  • installed macOS apps, Windows installers, and Linux AppImages can use the existing in-app update confirmation flow
  • Linux deb/rpm and Windows portable zip installs continue to require manual replacement
  • macOS users get a DMG for first installation instead of having to handle an updater archive
  • stable and nightly clients read their own channel manifests from releases.astrbot.app

Validation

  • Python release/manifest/R2 tests: 90 passed
  • Rust updater tests: 17 passed
  • focused Node workflow/artifact tests: passed
  • full Node suite: 128 passed; the remaining 3 local failures require Python 3.11+ tomllib or tomli (CI uses Python 3.12)
  • local DMG smoke test: hdiutil verify, mount, Applications symlink inspection, and detach passed
  • GitHub Actions successfully built both Linux architectures with signed AppImages, both macOS architectures with verified DMGs, Windows arm64, and the unaffected Windows amd64 path in earlier runs
  • the latest all-platform rerun was cancelled before build jobs started during a confirmed GitHub Actions major outage; no repository step failed in that run

Release notes

The updater UI and native install/restart bridge already existed. This PR changes the delivery backend and completes the missing package/publishing paths rather than introducing a second update UI.

Summary by Sourcery

Route AstrBot Desktop updater manifests and artifacts through Cloudflare R2 while restoring cross-platform self-update packages and switching CI to the prebuilt Tauri CLI.

New Features:

  • Enable Linux AppImage builds and signatures as first-class updater artifacts for amd64 and arm64.
  • Package macOS drag-to-Applications DMG installers alongside existing signed updater archives.
  • Publish immutable desktop release artifacts and manifests to Cloudflare R2 and expose stable/nightly updater manifests via releases.astrbot.app.

Enhancements:

  • Switch CI and local scripts to use the pinned @tauri-apps/cli via pnpm instead of compiling tauri-cli in workflows.
  • Extend latest.json generation to support R2-hosted assets, Linux AppImage platforms, and stricter artifact validation.
  • Add a Cloudflare R2 publication helper with tests and document the new R2 configuration, manifests, and platform-specific update behavior.

Documentation:

  • Document Cloudflare R2 environment variables, release object layout, and desktop updater channel endpoints in the developer and environment docs.
  • Update user READMEs with macOS DMG install instructions and Linux AppImage vs deb/rpm update behavior.

Tests:

  • Add CI workflow tests covering AppImage publishing, DMG packaging, R2 upload ordering, and R2-based updater manifests.
  • Add Python unit tests for R2 publishing logic and R2-aware latest.json generation, including AppImage handling and URL normalization.
  • Extend existing release artifact normalization tests to cover Linux AppImage canonicalization and R2 asset-base URLs.

@Soulter
Soulter marked this pull request as ready for review August 7, 2026 09:13
@Soulter
Soulter merged commit a7dde27 into main Aug 7, 2026
9 of 10 checks passed

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="scripts/ci/publish_r2_release.py" line_range="28-46" />
<code_context>
+    mutable: bool = False
+
+
+def content_type_for(path: Path) -> str:
+    lower_name = path.name.lower()
+    if lower_name.endswith(".json"):
+        return "application/json"
+    if lower_name.endswith(".sig"):
+        return "text/plain; charset=utf-8"
+    if lower_name.endswith(".zip"):
+        return "application/zip"
+    if lower_name.endswith((".tar.gz", ".gz")):
+        return "application/gzip"
+    if lower_name.endswith(".exe"):
+        return "application/vnd.microsoft.portable-executable"
+    if lower_name.endswith(".deb"):
+        return "application/vnd.debian.binary-package"
+    if lower_name.endswith(".rpm"):
+        return "application/x-rpm"
+    if lower_name.endswith(".dmg"):
+        return "application/x-apple-diskimage"
+    return "application/octet-stream"
+
+
</code_context>
<issue_to_address>
**suggestion:** Add an explicit MIME type for AppImage artifacts instead of falling back to application/octet-stream.

Right now `.AppImage` files fall through to `application/octet-stream`. Please add an explicit mapping for them in `content_type_for` (e.g. `application/x-executable` or another appropriate type) so their content type is accurately advertised and better supported by tooling and intermediaries.

```suggestion
def content_type_for(path: Path) -> str:
    lower_name = path.name.lower()
    if lower_name.endswith(".json"):
        return "application/json"
    if lower_name.endswith(".sig"):
        return "text/plain; charset=utf-8"
    if lower_name.endswith(".zip"):
        return "application/zip"
    if lower_name.endswith((".tar.gz", ".gz")):
        return "application/gzip"
    if lower_name.endswith(".exe"):
        return "application/vnd.microsoft.portable-executable"
    if lower_name.endswith(".deb"):
        return "application/vnd.debian.binary-package"
    if lower_name.endswith(".rpm"):
        return "application/x-rpm"
    if lower_name.endswith(".dmg"):
        return "application/x-apple-diskimage"
    if lower_name.endswith(".appimage"):
        # AppImage artifacts are self-contained Linux executables; use an explicit executable MIME type
        return "application/x-executable"
    return "application/octet-stream"
```
</issue_to_address>

### Comment 2
<location path="scripts/ci/test_publish_r2_release.py" line_range="156-102" />
<code_context>
+                    "artifacts",
+                )
+
+    def test_build_upload_plan_rejects_missing_manifest_artifact(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+            (root / "other.exe").write_bytes(b"installer")
+            manifest = root / "latest-stable.json"
+            self.write_manifest(
+                manifest,
+                version="4.29.0",
+                channel="stable",
+                artifact_name="AstrBot.exe",
+            )
+
+            with self.assertRaisesRegex(ValueError, "missing from the upload set"):
+                MODULE.build_upload_plan(
+                    root,
+                    manifest,
+                    "4.29.0",
+                    "12345-1",
+                    "stable",
+                    "artifacts",
+                )
+
</code_context>
<issue_to_address>
**suggestion (testing):** Extend tests to cover invalid or non-HTTPS artifact URLs in the manifest

The remaining untested paths are the manifest URL validation branches (non-HTTPS, missing netloc, empty path/filename). Please add tests that construct manifests with values like `"http://..."`, `"/relative/path"`, and an `https` URL without a filename, and assert the corresponding `ValueError` messages. This will fully exercise the URL validation, including the enforcement that only HTTPS artifact URLs are accepted.

Suggested implementation:

```python
import json

from scripts.ci import publish_r2_release as MODULE

```

```python
    def test_build_upload_plan_rejects_missing_manifest_artifact(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "other.exe").write_bytes(b"installer")
            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            with self.assertRaisesRegex(ValueError, "missing from the upload set"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

    def test_build_upload_plan_rejects_non_https_artifact_url(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            artifact_path = root / "AstrBot.exe"
            artifact_path.write_bytes(b"installer")

            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            data = json.loads(manifest.read_text(encoding="utf-8"))
            # Non-HTTPS URL should be rejected
            data["artifacts"][0]["url"] = "http://example.com/AstrBot.exe"
            manifest.write_text(json.dumps(data), encoding="utf-8")

            with self.assertRaisesRegex(ValueError, "HTTPS"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

    def test_build_upload_plan_rejects_artifact_url_missing_netloc(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            artifact_path = root / "AstrBot.exe"
            artifact_path.write_bytes(b"installer")

            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            data = json.loads(manifest.read_text(encoding="utf-8"))
            # An HTTPS URL without a hostname/netloc should be rejected
            data["artifacts"][0]["url"] = "https:///AstrBot.exe"
            manifest.write_text(json.dumps(data), encoding="utf-8")

            with self.assertRaisesRegex(ValueError, "netloc"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

    def test_build_upload_plan_rejects_artifact_url_without_filename(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            artifact_path = root / "AstrBot.exe"
            artifact_path.write_bytes(b"installer")

            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            data = json.loads(manifest.read_text(encoding="utf-8"))
            # HTTPS URL without a filename in the path should be rejected
            data["artifacts"][0]["url"] = "https://example.com/"
            manifest.write_text(json.dumps(data), encoding="utf-8")

            with self.assertRaisesRegex(ValueError, "filename"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

```

These tests assume the `ValueError` messages for the URL validation contain the substrings `"HTTPS"`, `"netloc"`, and `"filename"` respectively. If the actual messages differ (e.g., `"must use https"`, `"missing host"`, `"missing filename in URL"`), adjust the `assertRaisesRegex` patterns to match the real messages emitted by `build_upload_plan`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +28 to +46
def content_type_for(path: Path) -> str:
lower_name = path.name.lower()
if lower_name.endswith(".json"):
return "application/json"
if lower_name.endswith(".sig"):
return "text/plain; charset=utf-8"
if lower_name.endswith(".zip"):
return "application/zip"
if lower_name.endswith((".tar.gz", ".gz")):
return "application/gzip"
if lower_name.endswith(".exe"):
return "application/vnd.microsoft.portable-executable"
if lower_name.endswith(".deb"):
return "application/vnd.debian.binary-package"
if lower_name.endswith(".rpm"):
return "application/x-rpm"
if lower_name.endswith(".dmg"):
return "application/x-apple-diskimage"
return "application/octet-stream"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Add an explicit MIME type for AppImage artifacts instead of falling back to application/octet-stream.

Right now .AppImage files fall through to application/octet-stream. Please add an explicit mapping for them in content_type_for (e.g. application/x-executable or another appropriate type) so their content type is accurately advertised and better supported by tooling and intermediaries.

Suggested change
def content_type_for(path: Path) -> str:
lower_name = path.name.lower()
if lower_name.endswith(".json"):
return "application/json"
if lower_name.endswith(".sig"):
return "text/plain; charset=utf-8"
if lower_name.endswith(".zip"):
return "application/zip"
if lower_name.endswith((".tar.gz", ".gz")):
return "application/gzip"
if lower_name.endswith(".exe"):
return "application/vnd.microsoft.portable-executable"
if lower_name.endswith(".deb"):
return "application/vnd.debian.binary-package"
if lower_name.endswith(".rpm"):
return "application/x-rpm"
if lower_name.endswith(".dmg"):
return "application/x-apple-diskimage"
return "application/octet-stream"
def content_type_for(path: Path) -> str:
lower_name = path.name.lower()
if lower_name.endswith(".json"):
return "application/json"
if lower_name.endswith(".sig"):
return "text/plain; charset=utf-8"
if lower_name.endswith(".zip"):
return "application/zip"
if lower_name.endswith((".tar.gz", ".gz")):
return "application/gzip"
if lower_name.endswith(".exe"):
return "application/vnd.microsoft.portable-executable"
if lower_name.endswith(".deb"):
return "application/vnd.debian.binary-package"
if lower_name.endswith(".rpm"):
return "application/x-rpm"
if lower_name.endswith(".dmg"):
return "application/x-apple-diskimage"
if lower_name.endswith(".appimage"):
# AppImage artifacts are self-contained Linux executables; use an explicit executable MIME type
return "application/x-executable"
return "application/octet-stream"

"4.29.0",
"12345-1",
"stable",
"artifacts",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Extend tests to cover invalid or non-HTTPS artifact URLs in the manifest

The remaining untested paths are the manifest URL validation branches (non-HTTPS, missing netloc, empty path/filename). Please add tests that construct manifests with values like "http://...", "/relative/path", and an https URL without a filename, and assert the corresponding ValueError messages. This will fully exercise the URL validation, including the enforcement that only HTTPS artifact URLs are accepted.

Suggested implementation:

import json

from scripts.ci import publish_r2_release as MODULE
    def test_build_upload_plan_rejects_missing_manifest_artifact(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            (root / "other.exe").write_bytes(b"installer")
            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            with self.assertRaisesRegex(ValueError, "missing from the upload set"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

    def test_build_upload_plan_rejects_non_https_artifact_url(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            artifact_path = root / "AstrBot.exe"
            artifact_path.write_bytes(b"installer")

            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            data = json.loads(manifest.read_text(encoding="utf-8"))
            # Non-HTTPS URL should be rejected
            data["artifacts"][0]["url"] = "http://example.com/AstrBot.exe"
            manifest.write_text(json.dumps(data), encoding="utf-8")

            with self.assertRaisesRegex(ValueError, "HTTPS"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

    def test_build_upload_plan_rejects_artifact_url_missing_netloc(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            artifact_path = root / "AstrBot.exe"
            artifact_path.write_bytes(b"installer")

            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            data = json.loads(manifest.read_text(encoding="utf-8"))
            # An HTTPS URL without a hostname/netloc should be rejected
            data["artifacts"][0]["url"] = "https:///AstrBot.exe"
            manifest.write_text(json.dumps(data), encoding="utf-8")

            with self.assertRaisesRegex(ValueError, "netloc"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

    def test_build_upload_plan_rejects_artifact_url_without_filename(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            artifact_path = root / "AstrBot.exe"
            artifact_path.write_bytes(b"installer")

            manifest = root / "latest-stable.json"
            self.write_manifest(
                manifest,
                version="4.29.0",
                channel="stable",
                artifact_name="AstrBot.exe",
            )

            data = json.loads(manifest.read_text(encoding="utf-8"))
            # HTTPS URL without a filename in the path should be rejected
            data["artifacts"][0]["url"] = "https://example.com/"
            manifest.write_text(json.dumps(data), encoding="utf-8")

            with self.assertRaisesRegex(ValueError, "filename"):
                MODULE.build_upload_plan(
                    root,
                    manifest,
                    "4.29.0",
                    "12345-1",
                    "stable",
                    "artifacts",
                )

These tests assume the ValueError messages for the URL validation contain the substrings "HTTPS", "netloc", and "filename" respectively. If the actual messages differ (e.g., "must use https", "missing host", "missing filename in URL"), adjust the assertRaisesRegex patterns to match the real messages emitted by build_upload_plan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant