Skip to content

Self-hosted OTA live updates for the native app#560

Merged
jordandrako merged 4 commits into
devfrom
feat/self-hosted-ota
Jun 1, 2026
Merged

Self-hosted OTA live updates for the native app#560
jordandrako merged 4 commits into
devfrom
feat/self-hosted-ota

Conversation

@jordandrako
Copy link
Copy Markdown
Member

Problem

Cutting a release publishes a Docker image and an APK. The web bundle is frozen inside the APK, so a user's mobile app only matches the backend if they reinstall the exact APK version for every release. Hosted live-update services (Capgo Cloud, Capawesome) solve this but cost a monthly fee.

What this does

The native app now downloads the web bundle that matches the backend it's connected to, fully self-hosted ($0/month). Each Docker image already contains that version's frontend — we additionally ship a Capacitor-flavored copy, expose it over the API, and teach the app to fetch + swap it.

Flow: on launch / foreground the app checks the server's bundle version; if it differs it silently downloads the bundle and prompts Reload Now / Later. A bundle that fails to boot auto-rolls-back to the previous one.

Changes

Backend

  • New GET /api/v1/native/bundle/manifest ({version, url, checksum, minNativeVersion}) and GET /api/v1/native/bundle/download (the zip), reading artifacts shipped at /app/otabackend/app/api/v1/endpoints/native.py (+ tests, registered in api.py).
  • MIN_NATIVE_VERSION file (read like VERSION via version.py): the minimum native app version a web bundle requires. An OTA can only swap web assets, never native code, so the app refuses a bundle needing a newer shell and prompts a store/APK update instead.

ImageDockerfile runs a second build:capacitor pass, zips it (index.html at root) with a sha256, and copies it + MIN_NATIVE_VERSION into the image.

Native@capgo/capacitor-updater (open-source, manual/self-hosted mode — not their cloud) synced into both Android and iOS projects. useNativeUpdate orchestrates the check/download/prompt; notifyAppReady() in main.tsx arms the rollback. The native-compat gate compares CapacitorUpdater.current().native to minNativeVersionno custom native code.

Release/CI

  • scripts/promote.sh auto-bumps MIN_NATIVE_VERSION to the release version when it detects a native change (capacitor.config.ts, committed frontend/android·frontend/ios, or an added/removed/bumped @capacitor*/@capgo dep).
  • docker-publish.yml gains a decide job: web-only releases skip the Android build and ship Docker-only (apps update OTA); releases that change the native shell still build a fresh APK.

Also — fixed the root .dockerignore (it didn't exclude node_modules/dist, so COPY frontend . overlaid the host's macOS modules and broke local image builds + bloated the context).

Schema / artifact notes

  • New committed root file MIN_NATIVE_VERSION (single source of truth, mirrors VERSION).
  • Regenerated API types (frontend/src/api/generated/native/ + initiativeAPI.schemas.ts) for the new endpoints — CI's check-generated-types will pass.
  • New native dependency @capgo/capacitor-updater@^8; cap sync updated capacitor.build.gradle, capacitor.settings.gradle, and the iOS SPM Package.swift.
  • i18n keys added to en/es/fr guilds.json.

Testing

  • Backend: 39 tests pass (native_test.py covers 404-when-absent, checksum-matches-zip, zip serving; + core).
  • Frontend: 424 tests pass (incl. 9 for the pure URL-join / decision / downgrade / native-gate logic); typecheck + biome clean.
  • Docker image built locally: _OTA_DIR resolves to /app/ota, bundle has index.html at root (496 entries), bundle.sha256 matches the computed digest exactly, and the manifest returns {version: 0.48.1, minNativeVersion: 0.48.0, url, checksum}.
cd backend && pytest app/api/v1/endpoints/native_test.py app/core
cd frontend && pnpm typecheck && pnpm vitest run src/hooks/useNativeUpdate.test.ts
docker build -t initiative-ota --build-arg VERSION=$(cat VERSION) .

Not covered here (follow-ups)

  • The on-device flow (download → reload → rollback) and the native-too-old gate still need a manual pass on a physical Android device + iOS, against a two-version server.
  • Optional: also attach the OTA bundle.zip to the GitHub Release; bundle signing beyond sha256; an xcconfig that reads MIN_NATIVE_VERSION at iOS build time. There is no iOS CI build pipeline yet, so the iOS gate is wired but only exercised by manual builds.

Serve the matching Capacitor web bundle from the backend so the native
app stays in sync with whatever server it connects to, replacing the
need to reinstall the APK every release (and avoiding paid live-update
services like Capgo/Capawesome cloud).

Backend:
- New /api/v1/native/bundle/{manifest,download} endpoints serving the
  bundle zipped into the image at /app/ota, with a sha256 checksum.
- MIN_NATIVE_VERSION file (read like VERSION) gates web bundles that
  need a newer native shell than the installed APK/IPA.

Native:
- @capgo/capacitor-updater (manual/self-hosted mode) downloads the
  bundle and prompts Reload Now / Later; notifyAppReady() arms the
  failed-boot auto-rollback. The native-compat gate compares
  current().native to minNativeVersion (no custom native plugin).

Release/CI:
- promote.sh auto-bumps MIN_NATIVE_VERSION when the native shell
  changes (capacitor config, android/ios, or @capacitor/@Capgo deps).
- docker-publish.yml skips the Android build for web-only releases;
  they ship Docker-only and update over the air.

Also fixes the root .dockerignore to exclude node_modules/dist so
local image builds don't overlay host modules.
@jordandrako jordandrako requested a review from LeeJMorel as a code owner June 1, 2026 05:25
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 1, 2026

Greptile Summary

This PR implements self-hosted OTA live updates for the Capacitor native app. Each Docker image ships a Capacitor-flavored web bundle under /app/ota; the app polls /api/v1/native/bundle/manifest on launch and foreground, downloads a new bundle via @capgo/capacitor-updater when versions differ, and prompts the user to reload. A MIN_NATIVE_VERSION gate blocks OTA for bundles that require newer native code, routing users to the store instead.

  • Backend: two new unauthenticated endpoints (manifest, download) in native.py; NativeMessages constant and get_min_native_version() follow established project patterns; 39 tests pass.
  • Frontend: useNativeUpdate orchestrates check/download/prompt; notifyAppReady() in main.tsx arms the rollback safety net; pure helpers (decideNativeUpdate, buildBundleDownloadUrl) are well-covered by 9 unit tests.
  • CI/CD: new decide job skips the Android build for web-only releases; release job now hard-fails when a native build fails; promote.sh auto-bumps MIN_NATIVE_VERSION on native shell changes.

Confidence Score: 4/5

Safe to merge — the OTA core paths are well-guarded and the two previously flagged regressions are fixed; one edge case around errored cached bundles is the only remaining concern and it is non-blocking.

The implementation is solid end-to-end: backend endpoints, Dockerfile artifact pipeline, CI gate logic, promote-script detection, and JS orchestration hook all look correct. The native-required repetition bug and the silent set() failure are both resolved in this commit. The one remaining rough edge is that CapacitorUpdater.list() may surface bundles that failed to download, and reusing them blocks subsequent fresh-download attempts — but it only surfaces in a narrow failure scenario and is non-data-loss.

frontend/src/hooks/useNativeUpdate.tsx — the existing bundle reuse path around line 1261

Important Files Changed

Filename Overview
frontend/src/hooks/useNativeUpdate.tsx Core OTA orchestration hook. native-required and applyUpdate failure paths are correctly handled. Minor: errored bundles in CapacitorUpdater.list() are reused without a status check, which can trap the user in a perpetual failed-set cycle.
backend/app/api/v1/endpoints/native.py Two unauthenticated OTA endpoints using the correct path resolution (parents[4]), NativeMessages constants, and immutable Cache-Control headers. No issues.
frontend/src/routes/_serverRequired.tsx Mounts useNativeUpdate at the right layout level; currentVersion passed to VersionDialog is unused in update mode (no visual defect), newVersion drives the display.
.github/workflows/docker-publish.yml New decide job correctly gates Android builds; release condition now correctly blocks on build-android failure while allowing skip (web-only release) to pass through.
scripts/promote.sh Adds detect_native_change and stamp_min_native_version helpers. Logic and regex are correct; correctly wired into both the full-release and cherry-pick paths.
Dockerfile Adds a second Capacitor build pass, zips with index.html at root, computes sha256, stashes and restores the browser build. The pipeline is correct.
backend/app/core/version.py Adds get_min_native_version() mirroring the existing get_version() pattern with Docker-path-first fallback.
frontend/src/main.tsx Calls notifyAppReady() before any server interaction; try/catch ensures the app boots even if the updater plugin is unavailable.

Sequence Diagram

sequenceDiagram
    participant App as Native App (JS)
    participant Updater as CapacitorUpdater
    participant Backend as Backend API

    Note over App: Launch / foreground resume
    App->>App: notifyAppReady() [arms rollback]
    App->>Backend: GET /api/v1/native/bundle/manifest
    alt Bundle not in image (dev/old server)
        Backend-->>App: 404
        App->>App: No-op (keep current bundle)
    else Bundle present
        Backend-->>App: "{version, url, checksum, minNativeVersion}"
        App->>App: decideNativeUpdate()
        alt Already up-to-date
            App->>App: return (no-op)
        else "minNativeVersion > installed native"
            App->>App: show NativeUpdateRequiredDialog
        else Download needed
            App->>Updater: list() — check for existing bundle
            alt Bundle already cached
                Updater-->>App: existing bundle
            else Not cached
                App->>Backend: GET /api/v1/native/bundle/download
                Backend-->>App: bundle.zip (immutable, 1yr cache)
                App->>Updater: "download({url, version, checksum})"
                Updater-->>App: bundle object
            end
            App->>App: show VersionDialog (Reload Now / Later)
            alt User taps Reload Now
                App->>Updater: "set({id}) → WebView reloads"
                App->>App: notifyAppReady() [new bundle armed]
            else User taps Later
                App->>App: dismiss (re-prompts on next cold start)
            else set() throws
                App->>App: keep dialog open for retry
            end
        end
    end
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
frontend/src/hooks/useNativeUpdate.tsx:131-133
Errored bundles are reused without checking their status. If `@capgo/capacitor-updater` retains a failed or partially-downloaded bundle in the list (with a non-success status), `set({ id: bundle.id })` will throw, the dialog stays open, the user can dismiss it, but on the next cold start the same broken `existing` entry is found again and `download()` is never retried — leaving the user unable to receive this OTA version until the bundle is evicted from the plugin's cache. Filtering to only reuse bundles that completed successfully avoids the stuck state.

```suggestion
      const existing = (await CapacitorUpdater.list()).bundles.find(
        (b) => b.version === manifest.version && b.status === "success"
      );
```

Reviews (4): Last reviewed commit: "Keep OTA reload prompt open if set() fai..." | Re-trigger Greptile

- native.py: use NativeMessages.OTA_BUNDLE_NOT_AVAILABLE constant instead
  of inline HTTPException detail strings; assert it in tests; map the code
  in errors.json (en/es/fr).
- useNativeUpdate: set handledVersionRef in the native-required branch so
  the 'App update required' dialog doesn't re-fire on every foreground.
- docker-publish.yml: release job now also requires
  needs.build-android.result != 'failure', so a broken native build is a
  hard failure rather than a publish with misleading OTA messaging.
@jordandrako
Copy link
Copy Markdown
Member Author

Thanks — all three findings addressed in 5fb5683:

  1. HTTPException constants — added NativeMessages.OTA_BUNDLE_NOT_AVAILABLE in messages.py, used it for both raises in native.py, mapped the code in errors.json (en/es/fr), and updated native_test.py to assert detail == NativeMessages.OTA_BUNDLE_NOT_AVAILABLE.
  2. Dialog repeats on foreground — the native-required branch in useNativeUpdate now sets handledVersionRef.current = manifest.version before returning, so the "App update required" dialog fires once per session (re-checked on cold start) instead of on every resume.
  3. Release publishes on failed native build — the release job condition now also requires needs.build-android.result != 'failure', so a broken native-change build is a hard failure rather than a Docker-only publish with misleading "updates over the air" messaging. A skipped (web-only) build still allows the release.

@greptile

@jordandrako
Copy link
Copy Markdown
Member Author

@greptile

Comment thread backend/app/api/v1/endpoints/native.py
Comment thread frontend/src/hooks/useNativeUpdate.tsx
If CapacitorUpdater.set() throws (e.g. the OS evicted the downloaded
bundle), leave the reload dialog open so the user can retry instead of
silently hiding it with no reload.
@jordandrako
Copy link
Copy Markdown
Member Author

@greptile

Filter CapacitorUpdater.list() to status 'success' so a retained
error/partial bundle isn't reused (which would make set() throw and,
since we'd keep finding it, never re-download — leaving the user stuck
on that version).
@jordandrako jordandrako merged commit 71716a7 into dev Jun 1, 2026
3 checks passed
@jordandrako jordandrako deleted the feat/self-hosted-ota branch June 1, 2026 06:49
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