Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ backend/.coveragerc
**/*.pyc
backend/.venv

# Node / frontend build artifacts — installed fresh inside the image. Copying the host's
# (macOS) node_modules over the container's would corrupt the install and bloat the context.
**/node_modules
frontend/dist

# Version control
.git
.gitignore
Expand Down
59 changes: 56 additions & 3 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,44 @@ env:
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/initiative

jobs:
decide:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
build_android: ${{ steps.decide.outputs.build_android }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Decide whether to build the native app
id: decide
run: |
# The Android/iOS app only needs rebuilding when the native shell changed — which
# promote.sh records by moving MIN_NATIVE_VERSION forward. Compare this ref's value
# against the previous release tag; web-only releases reuse the existing APK and
# update over the air. The committed file is the single source of truth, so both the
# workflow_dispatch and manual tag-push paths behave identically.
cur=$(cat MIN_NATIVE_VERSION 2>/dev/null || echo "")
prev=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$prev" ]; then
old=$(git show "$prev:MIN_NATIVE_VERSION" 2>/dev/null || echo "")
else
old=""
fi
if [ -z "$old" ] || [ "$old" != "$cur" ]; then
echo "build_android=true" >> "$GITHUB_OUTPUT"
echo "Native min-version changed ('$old' -> '$cur') or no prior value — building the app."
else
echo "build_android=false" >> "$GITHUB_OUTPUT"
echo "Native min-version unchanged ('$cur') — web-only release, skipping the app build."
fi

build-android:
needs: decide
if: ${{ needs.decide.outputs.build_android == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -248,7 +285,12 @@ jobs:
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

release:
needs: [build-android, merge-docker-public]
needs: [decide, build-android, merge-docker-public]
# Run even when build-android was *skipped* (web-only release). Only the Docker manifest
# must have succeeded; the APK is attached opportunistically below. But a build-android
# *failure* on a native-change release is a hard error — publishing then would advertise
# "updates over the air" while the bumped minNativeVersion blocks OTA and no new APK exists.
if: always() && needs.merge-docker-public.result == 'success' && needs.build-android.result != 'failure'
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -258,6 +300,7 @@ jobs:
uses: actions/checkout@v6

- name: Download Android APK
if: needs.build-android.result == 'success'
uses: actions/download-artifact@v8
with:
name: android-apk
Expand All @@ -281,6 +324,7 @@ jobs:
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
APK_BUILT: ${{ needs.build-android.result == 'success' }}
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Extracting changelog for version $VERSION"
Expand All @@ -304,6 +348,16 @@ jobs:
CHANGELOG="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
fi

# The native app is only rebuilt when the native shell changed (see the `decide`
# job). Web-only releases ship no new APK — existing installs update over the air.
if [ "$APK_BUILT" = "true" ]; then
ANDROID_SECTION="### Android App
Download the APK from the assets below and install on your Android device."
else
ANDROID_SECTION="### Android App
No new app build this release — installed apps update automatically over the air on next launch."
fi

# Build full release body with changelog + download info
cat > release_body.md << ENDOFBODY
$CHANGELOG
Expand All @@ -312,8 +366,7 @@ jobs:

## Downloads

### Android App
Download the APK from the assets below and install on your Android device.
$ANDROID_SECTION

### Docker Image
\`\`\`bash
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Self-hosted over-the-air (OTA) app updates.** The native mobile app now downloads the web bundle that matches the backend it's connected to, so the frontend and backend stay in sync without reinstalling the APK for every release — no paid live-update service required. Each server build ships the matching Capacitor bundle; on launch and when returning to the foreground the app checks the server version and, if it differs, downloads the bundle and prompts "Reload now" (with a "Later" option). A failed update automatically rolls back to the previous bundle. When a release changes native code (not just web assets), the app detects that its installed shell is too old and asks you to update from the store/APK instead. Releases that only change web assets no longer rebuild the APK — they update entirely over the air.

## [0.48.1] - 2026-05-31

### Fixed
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ This project uses **semantic versioning** (semver) with a single source of truth
- **Frontend**: Vite injects VERSION as `__APP_VERSION__` constant, displayed in the sidebar footer
- **Docker**: VERSION is copied into the image and set as OCI labels

#### `MIN_NATIVE_VERSION` (OTA native-compatibility floor)

The native (Capacitor) app receives web-bundle updates over the air: each Docker image ships the matching Capacitor bundle under `/app/ota`, served via `/api/v1/native/bundle/{manifest,download}`, and the app downloads it when the served version differs (see `useNativeUpdate`). The `MIN_NATIVE_VERSION` file at the project root is the **minimum native app (APK/IPA) version** the current web bundle requires — an OTA can only swap web assets, never native code.

- `scripts/promote.sh` bumps `MIN_NATIVE_VERSION` to the release version automatically when it detects a native change between `main` and `dev` (a `frontend/capacitor.config.ts` change, a committed change under `frontend/android`/`frontend/ios`, or an added/removed/bumped `@capacitor*`/`@capgo` dependency in `frontend/package.json`). Web-only releases leave it untouched.
- CI (`docker-publish.yml` `decide` job) compares `MIN_NATIVE_VERSION` against the previous tag: if it moved, it builds and attaches a fresh APK; if not, the **Android build is skipped** and the release ships Docker-only — existing installs update over the air.
- The app refuses a bundle whose `minNativeVersion` exceeds the installed native app version and prompts the user to update from the store/APK instead.
- Edge case the detector can't see: a native-affecting change that lands **only** via `pnpm-lock.yaml` (no `package.json` range change). Force a rebuild by editing `MIN_NATIVE_VERSION` manually that release.

### Releasing a Version

Releases are managed by `scripts/promote.sh`, which creates a PR from `dev` to `main` with the version bump and changelog stamp. Only code owners (@jordandrako, @LeeJMorel) can run this script.
Expand Down
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
FROM node:24-alpine AS frontend-build
WORKDIR /frontend
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN apk add --no-cache zip
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend .
Expand All @@ -9,7 +10,18 @@ ARG VITE_API_URL=/api/v1
ARG VITE_VERSION_SUFFIX=
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_VERSION_SUFFIX=$VITE_VERSION_SUFFIX
# Browser SPA build (base "/") served by the backend at /app/static.
RUN pnpm run build
# Capacitor-flavored OTA bundle (base "", __IS_CAPACITOR__=true) shipped at /app/ota so the
# native app can download the web bundle matching this backend version. build:capacitor
# overwrites dist/, so stash the browser build first, then zip the capacitor build with
# index.html at the zip root (cd dist before zipping — do NOT nest under dist/).
RUN cp -r dist /tmp/browser-dist \
&& pnpm build:capacitor \
&& mkdir -p /ota \
&& (cd dist && zip -qr /ota/bundle.zip .) \
&& sha256sum /ota/bundle.zip | cut -d' ' -f1 > /ota/bundle.sha256 \
&& rm -rf dist && mv /tmp/browser-dist dist

FROM python:3.12-slim AS backend-runtime
ARG VERSION=0.1.0
Expand All @@ -23,8 +35,11 @@ RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY backend/ .
COPY VERSION ./VERSION
COPY MIN_NATIVE_VERSION ./MIN_NATIVE_VERSION
COPY CHANGELOG.md ./CHANGELOG.md
COPY --from=frontend-build /frontend/dist ./static
COPY --from=frontend-build /ota/bundle.zip ./ota/bundle.zip
COPY --from=frontend-build /ota/bundle.sha256 ./ota/bundle.sha256
RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app/uploads
COPY backend/entrypoint.sh /entrypoint.sh
Expand Down
1 change: 1 addition & 0 deletions MIN_NATIVE_VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.48.0
3 changes: 2 additions & 1 deletion backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from fastapi import APIRouter

from app.api.v1.endpoints import access_grants, admin, ai_settings, attachments, auth, auto_subscriptions, calendar_events, collaboration, comments, config, counters, documents, events, guilds, imports, initiatives, notifications, projects, property_definitions, push, queues, recents, settings, tags, task_statuses, tasks, trash, user_view_preferences, users, version
from app.api.v1.endpoints import access_grants, admin, ai_settings, attachments, auth, auto_subscriptions, calendar_events, collaboration, comments, config, counters, documents, events, guilds, imports, initiatives, native, notifications, projects, property_definitions, push, queues, recents, settings, tags, task_statuses, tasks, trash, user_view_preferences, users, version

api_router = APIRouter()
api_router.include_router(version.router, tags=["version"])
api_router.include_router(native.router, tags=["native"])
api_router.include_router(config.router, tags=["config"])
api_router.include_router(auto_subscriptions.router, prefix="/auto", tags=["auto"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
Expand Down
62 changes: 62 additions & 0 deletions backend/app/api/v1/endpoints/native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Native (Capacitor) over-the-air bundle endpoints.

Each Docker image bundles the Capacitor-flavored web build (a zip with ``index.html`` at
its root) under ``/app/ota`` so the native app can download the web bundle that matches the
backend it is talking to. The native app polls ``/native/bundle/manifest`` and, when the
served version differs from the bundle it is running, downloads ``/native/bundle/download``
and swaps it in via ``@capgo/capacitor-updater`` (manual mode).

See ``backend/app/main.py`` for the analogous static/upload ``FileResponse`` patterns.
"""

from pathlib import Path

from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse

from app.core.config import settings
from app.core.messages import NativeMessages
from app.core.version import __version__, get_min_native_version

router = APIRouter()

# Docker layout: /app/app/api/v1/endpoints/native.py -> parents[4] == /app
# The Dockerfile copies the OTA artifacts to /app/ota (see Dockerfile stage 2).
# In a local dev checkout this directory does not exist, so the endpoints 404 — the OTA
# flow is only exercised against a built image.
_OTA_DIR = Path(__file__).resolve().parents[4] / "ota"
Comment thread
jordandrako marked this conversation as resolved.
_BUNDLE_PATH = _OTA_DIR / "bundle.zip"
_CHECKSUM_PATH = _OTA_DIR / "bundle.sha256"


@router.get("/native/bundle/manifest")
def get_bundle_manifest() -> dict[str, object]:
"""Describe the OTA bundle this backend serves.

Returns the bundle ``version`` (equal to the app version), a ``url`` the client joins to
its server origin to download the zip, the ``checksum`` (sha256 hex) the updater verifies,
and ``minNativeVersion`` — the minimum native app (APK/IPA) version the bundle requires.
The client refuses the update (and prompts to update from the store) when its installed
native app version is older.
"""
if not _BUNDLE_PATH.is_file() or not _CHECKSUM_PATH.is_file():
raise HTTPException(status_code=404, detail=NativeMessages.OTA_BUNDLE_NOT_AVAILABLE)
return {
"version": __version__,
"url": f"{settings.API_V1_STR}/native/bundle/download",
"checksum": _CHECKSUM_PATH.read_text().strip(),
"minNativeVersion": get_min_native_version(),
}


@router.get("/native/bundle/download")
def download_bundle() -> FileResponse:
"""Serve the Capacitor web bundle zip (immutable per version)."""
if not _BUNDLE_PATH.is_file():
raise HTTPException(status_code=404, detail=NativeMessages.OTA_BUNDLE_NOT_AVAILABLE)
return FileResponse(
_BUNDLE_PATH,
media_type="application/zip",
filename=f"initiative-{__version__}.zip",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
72 changes: 72 additions & 0 deletions backend/app/api/v1/endpoints/native_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Integration tests for the native OTA bundle endpoints.

The native (Capacitor) app polls these to decide whether to download a matching web bundle.
A local dev/test checkout has no ``/app/ota`` directory, so the endpoints must 404 cleanly;
when an image ships the artifacts, the manifest must advertise a checksum that actually
matches the served zip.
"""

from __future__ import annotations

import hashlib

import pytest
from httpx import AsyncClient

from app.api.v1.endpoints import native
from app.core.messages import NativeMessages
from app.core.version import __version__


@pytest.mark.integration
async def test_manifest_404_when_bundle_absent(client: AsyncClient):
"""No OTA artifacts present (the default outside a built image) → 404, not a 500."""
response = await client.get("/api/v1/native/bundle/manifest")
assert response.status_code == 404
assert response.json()["detail"] == NativeMessages.OTA_BUNDLE_NOT_AVAILABLE


@pytest.mark.integration
async def test_download_404_when_bundle_absent(client: AsyncClient):
response = await client.get("/api/v1/native/bundle/download")
assert response.status_code == 404
assert response.json()["detail"] == NativeMessages.OTA_BUNDLE_NOT_AVAILABLE


@pytest.mark.integration
async def test_manifest_advertises_matching_checksum(
client: AsyncClient, tmp_path, monkeypatch
):
"""The checksum in the manifest must be the sha256 of the exact zip the download
endpoint serves — the updater rejects the bundle otherwise."""
bundle = tmp_path / "bundle.zip"
bundle.write_bytes(b"PK\x03\x04 fake zip payload")
digest = hashlib.sha256(bundle.read_bytes()).hexdigest()
checksum = tmp_path / "bundle.sha256"
checksum.write_text(f"{digest}\n")

monkeypatch.setattr(native, "_BUNDLE_PATH", bundle)
monkeypatch.setattr(native, "_CHECKSUM_PATH", checksum)

response = await client.get("/api/v1/native/bundle/manifest")
assert response.status_code == 200
body = response.json()
assert body["version"] == __version__
assert body["url"] == "/api/v1/native/bundle/download"
assert body["checksum"] == digest
assert isinstance(body["minNativeVersion"], str)


@pytest.mark.integration
async def test_download_serves_zip(client: AsyncClient, tmp_path, monkeypatch):
bundle = tmp_path / "bundle.zip"
payload = b"PK\x03\x04 fake zip payload"
bundle.write_bytes(payload)

monkeypatch.setattr(native, "_BUNDLE_PATH", bundle)

response = await client.get("/api/v1/native/bundle/download")
assert response.status_code == 200
assert response.headers["content-type"] == "application/zip"
assert "immutable" in response.headers["cache-control"]
assert response.content == payload
4 changes: 4 additions & 0 deletions backend/app/core/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,7 @@ class WebhookSubscriptionMessages:
class AIMessages:
INVALID_BASE_URL = "AI_INVALID_BASE_URL"
PROVIDER_NOT_ALLOWED = "AI_PROVIDER_NOT_ALLOWED"


class NativeMessages:
OTA_BUNDLE_NOT_AVAILABLE = "NATIVE_OTA_BUNDLE_NOT_AVAILABLE"
20 changes: 20 additions & 0 deletions backend/app/core/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,24 @@ def get_version() -> str:
return "0.0.0"


def get_min_native_version() -> str:
"""Read the minimum native app version from the MIN_NATIVE_VERSION file at project root.

This is the semver of the release in which the native shell last changed (Capacitor
plugins or config). The OTA flow refuses a web bundle whose ``minNativeVersion`` exceeds
the installed native app version, prompting a store/APK update instead — because a newer
web bundle may call a native API the older shell lacks. Resolution mirrors ``get_version``
(Docker path first).
"""
# Try Docker path first: /app/app/core/version.py -> /app/MIN_NATIVE_VERSION
min_version_file = Path(__file__).parent.parent.parent / "MIN_NATIVE_VERSION"
if not min_version_file.exists():
# Fall back to development path: -> repo_root/MIN_NATIVE_VERSION
min_version_file = Path(__file__).parent.parent.parent.parent / "MIN_NATIVE_VERSION"
try:
return min_version_file.read_text().strip()
except FileNotFoundError:
return "0.0.0"


__version__ = get_version()
1 change: 1 addition & 0 deletions frontend/android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies {
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capgo-capacitor-updater')

}

Expand Down
3 changes: 3 additions & 0 deletions frontend/android/capacitor.settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@

include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications/android')

include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-updater@8.47.5_@capacitor+core@8.3.4/node_modules/@capgo/capacitor-updater/android')
12 changes: 12 additions & 0 deletions frontend/capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ const config: CapacitorConfig = {
// allowMixedContent: true,
},
plugins: {
// Self-hosted OTA live updates. We drive download/set entirely from JS (manual mode):
// the backend serves the web bundle matching its version, and useNativeUpdate downloads
// it then prompts the user to reload. autoUpdate/directUpdate stay off so the plugin
// never swaps the bundle on its own; appReadyTimeout arms the auto-rollback safety net
// if a swapped-in bundle fails to call notifyAppReady().
CapacitorUpdater: {
autoUpdate: false,
directUpdate: false,
resetWhenUpdate: true,
appReadyTimeout: 10000,
responseTimeout: 20,
},
// Disable built-in SystemBars insets handling - safe-area plugin handles it
SystemBars: {
insetsHandling: "disable",
Expand Down
Loading