From 60aeaa9b4b0f58b9ec04ca28e359e0e7a8564e0c Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 4 Oct 2026 23:06:55 +0300 Subject: [PATCH 1/2] feat: prepare versioned server images and production deployment --- .dockerignore | 5 + .github/workflows/release-checks.yml | 21 +++++ .github/workflows/release.yml | 84 +++++++++++++++++ Dockerfile | 14 ++- README.md | 7 ++ deploy/.gitignore | 4 + deploy/Caddyfile | 17 ++++ deploy/README.md | 112 +++++++++++++++++++++++ deploy/bootstrap-powersync.sql | 12 +++ deploy/compose.yml | 117 ++++++++++++++++++++++++ deploy/deploy.sh | 13 +++ deploy/production.env.example | 43 +++++++++ deploy/validate_config.py | 57 ++++++++++++ papyrus/__init__.py | 4 +- papyrus/main.py | 3 +- pyproject.toml | 2 +- tools/release_gate.py | 99 ++++++++++++++++++++ tools/tests/test_deploy.py | 54 +++++++++++ tools/tests/test_release.py | 131 +++++++++++++++++++++++++++ uv.lock | 2 +- 20 files changed, 793 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release-checks.yml create mode 100644 .github/workflows/release.yml create mode 100644 deploy/.gitignore create mode 100644 deploy/Caddyfile create mode 100644 deploy/README.md create mode 100644 deploy/bootstrap-powersync.sql create mode 100644 deploy/compose.yml create mode 100755 deploy/deploy.sh create mode 100644 deploy/production.env.example create mode 100644 deploy/validate_config.py create mode 100644 tools/release_gate.py create mode 100644 tools/tests/test_deploy.py create mode 100644 tools/tests/test_release.py diff --git a/.dockerignore b/.dockerignore index c854a12..0ad690f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,8 @@ docs/ tests/ *.db *.sqlite3 + +.local/ +.media/ +deploy/secrets/ +deploy/*.env diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 0000000..5db3a52 --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,21 @@ +name: Release configuration checks + +on: + pull_request: + paths: ['tools/**', '.github/workflows/**', 'pyproject.toml', 'deploy/**'] + push: + branches: [master] + paths: ['tools/**', '.github/workflows/**', 'pyproject.toml', 'deploy/**'] + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m unittest discover -s tools/tests -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f95d2e2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release container + +on: + push: + branches: [master] + paths: ['pyproject.toml'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: server-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + version: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + outputs: + release: ${{ steps.version.outputs.release }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m unittest discover -s tools/tests -v + - name: Check committed version + id: version + env: + BEFORE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = workflow_dispatch ]; then + python tools/release_gate.py server --manual + else + python tools/release_gate.py server --base "$BEFORE" + fi + image: + needs: version + if: needs.version.outputs.release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + id: image + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/papyrusreader/server:${{ needs.version.outputs.version }} + ghcr.io/papyrusreader/server:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.version=${{ needs.version.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Record published digest + env: + DIGEST: ${{ steps.image.outputs.digest }} + VERSION: ${{ needs.version.outputs.version }} + run: printf 'ghcr.io/papyrusreader/server:%s\nDigest %s\n' "$VERSION" "$DIGEST" > image.txt + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.version.outputs.tag }} + target_commitish: ${{ github.sha }} + generate_release_notes: true + files: image.txt diff --git a/Dockerfile b/Dockerfile index 5bd018a..c00a36b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,21 @@ FROM python:3.12-slim AS builder WORKDIR /app -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /usr/local/bin/uv COPY pyproject.toml uv.lock README.md ./ -RUN uv sync --frozen --no-dev +COPY papyrus/ ./papyrus/ +RUN uv sync --locked --no-dev --no-editable FROM python:3.12-slim AS runtime WORKDIR /app +RUN groupadd --gid 10001 papyrus && useradd --uid 10001 --gid papyrus --no-create-home papyrus \ + && mkdir -p /var/lib/papyrus/media && chown papyrus:papyrus /var/lib/papyrus/media COPY --from=builder /app/.venv /app/.venv -COPY papyrus/ ./papyrus/ COPY alembic/ ./alembic/ COPY alembic.ini ./ -ENV PATH="/app/.venv/bin:$PATH" +ENV PATH="/app/.venv/bin:$PATH" \ + MEDIA_STORAGE_ROOT="/var/lib/papyrus/media" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +USER papyrus EXPOSE 8080 CMD ["papyrus-server"] diff --git a/README.md b/README.md index 47db1c4..385d29d 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,10 @@ uv run --locked python scripts/export_openapi.py ../docs/_static/openapi.json -- Export does not load `.env`, start services or connect to a database. Set the docs workflow's server revision to the source commit used for the snapshot. The runtime `/openapi.json` remains the specification for a configured deployment. + +## Production releases + +See [the deployment runbook](deploy/README.md) for version-triggered GHCR images +and the separate production Compose stack, HTTPS, PowerSync, migrations and +persistent database/media backups. API version metadata follows the installed +package version in `pyproject.toml`. diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..135c9c1 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,4 @@ +production.env +secrets/ +web/ +backups/ diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..fe64597 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,17 @@ +{ + email {$ACME_EMAIL} +} + +{$API_DOMAIN} { + reverse_proxy api:8080 +} + +{$SYNC_DOMAIN} { + reverse_proxy powersync:8080 +} + +{$APP_DOMAIN} { + root * /srv/web + try_files {path} /index.html + file_server +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..11c066b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,112 @@ +# Single-server production deployment + +This is a portable Docker Compose deployment for a Linux VM, including Hetzner +Cloud. It is separate from the local `docker-compose.yml`. Nothing here provisions +or changes a live server automatically. + +The API and migration job use `ghcr.io/papyrusreader/server:`, built for +amd64 and arm64 when the committed server version changes on `master`. API version +metadata comes from the installed Python package. GitHub releases record the +image digest; deploy a recorded digest instead of a mutable tag when stronger +artifact pinning is needed. The runtime uses UID/GID 10001, not root. + +## Host and domains + +Use a VM with Docker Engine/Compose v2 and Python 3.12+, enough disk for uploaded +books, and off-host backups. Avoid sizing from an untested load estimate; monitor +memory, database storage and replication lag during internal testing. Configure +SSH key access and a Hetzner firewall allowing SSH from your own IP and public +TCP 80/443 (UDP 443 is optional for HTTP/3). Databases and PowerSync's internal +listener have **no host port mappings**. + +Choose one domain you own. Use `api.`, `sync.` and `app.`. +Point their DNS A records to the VM (add AAAA only if IPv6 routing works). Caddy +obtains and renews HTTPS certificates and proxies PowerSync streaming. It also +serves the built Flutter web app for verification/password-reset links. The client +release environment must use the same API/sync origins. Set the Google OAuth web +client's authorized redirect URI to +`https://api./v1/auth/oauth/google/callback`; mobile callbacks remain +`papyrus://auth/callback`. + +## First deployment + +Check out the server release's source so PowerSync config and migrations match +the container version. Run these commands from `server/deploy` on the VM: + +```sh +cp production.env.example production.env +chmod 600 production.env +mkdir -p secrets web +chmod 700 secrets +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out secrets/powersync-private.pem +openssl pkey -in secrets/powersync-private.pem -pubout -out secrets/powersync-public.pem +sudo chown 10001:10001 secrets/powersync-private.pem secrets/powersync-public.pem +chmod 400 secrets/powersync-private.pem +chmod 444 secrets/powersync-public.pem +``` + +Edit `production.env` locally on the VM. Replace placeholder domains and set +`APP_PUBLIC_BASE_URL`, CORS and allowed web redirect hosts accordingly. Generate +**separate** values for `SECRET_KEY`, `POSTGRES_PASSWORD`, +`POWERSYNC_SOURCE_PASSWORD` and `POWERSYNC_STORAGE_PASSWORD` using +`openssl rand -hex 32`. Database values must be URL-safe because connection URLs +are assembled from them. Do not rotate the JWT private key on ordinary deploys. + +Configure a real SMTP provider with TLS, verified sender, and its credentials; +Mailpit is for local development. Add Google OAuth credentials if testing Google +sign-in. Extract the matching client's `web-release` artifact into `web/`, so +`web/index.html` exists. The mobile app does not require visiting the web app for +ordinary reading, but registration verification/reset emails use its routes. + +If the GHCR package is private, authenticate Docker on the VM with a restricted +read-packages token; never reuse the CI publishing token. Ensure the requested +image has actually been published. Then: + +```sh +./deploy.sh +``` + +The script validates settings without printing credentials, pulls images, waits +for both databases, stops app services, runs Alembic once, creates/updates the +restricted PowerSync replication role and publication, then starts services with +health checks. It intentionally causes a short maintenance window. If a migration +fails, services stay stopped; inspect the failure before restoring service. +Migrations do not run independently in every API replica. + +Verify externally: `https://api./health`, `/openapi.json` (release version), +`https://sync./probes/liveness`, the web app, SMTP verification/reset, Google +sign-in, book/media upload and sync from a Play-installed Android build. Monitor +PowerSync replication slots: a 1 GB WAL retention cap protects disk but an +extended outage can invalidate a slot and require a controlled resync. + +## Updating and recovery + +Deploy the backward-compatible server first, then roll out the client. Update +`PAPYRUS_VERSION`, check out the corresponding source/config and install the web +artifact before running `./deploy.sh`. Check Alembic current/head before/after a +release and investigate `alembic check` differences. Do not downgrade migrations +or assume rolling back an image reverses a data migration. + +Before each release, take a PostgreSQL dump of the application database and a +consistent media backup. Keep encrypted, off-host backups of the database, media, +production environment and JWT keys; perform an actual restore drill. The named +volumes retain application/PostgreSQL/PowerSync data and Caddy certificates across +container replacement. **Do not use `docker compose down -v`** for upgrades. +VM snapshots alone are not a verified database/media backup. PowerSync storage +can be rebuilt, but doing so requires coordinated client resync. + +For commands outside the script, always use: + +```sh +docker compose --env-file production.env -f compose.yml ps +docker compose --env-file production.env -f compose.yml logs --tail 100 api powersync +``` + +Do not expose logs containing tokens or environment values when asking for help. +The first release still needs public DNS, SMTP/OAuth setup, credentials and a +real-device sync/auth test; local container compilation is not a deployment test. + +References: +- [Hetzner Cloud firewalls](https://docs.hetzner.com/cloud/firewalls/overview/) +- [Docker Compose deployment](https://docs.docker.com/compose/how-tos/production/) +- [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https) diff --git a/deploy/bootstrap-powersync.sql b/deploy/bootstrap-powersync.sql new file mode 100644 index 0000000..9a86f74 --- /dev/null +++ b/deploy/bootstrap-powersync.sql @@ -0,0 +1,12 @@ +\set ON_ERROR_STOP on +\getenv source_password POWERSYNC_SOURCE_PASSWORD +SELECT 'CREATE ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'powersync_role') +\gexec +ALTER ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD :'source_password'; +GRANT USAGE ON SCHEMA public TO powersync_role; +GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items TO powersync_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role; +SELECT 'CREATE PUBLICATION powersync' WHERE NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') +\gexec +ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items; diff --git a/deploy/compose.yml b/deploy/compose.yml new file mode 100644 index 0000000..5fb9665 --- /dev/null +++ b/deploy/compose.yml @@ -0,0 +1,117 @@ +name: papyrus-production + +x-api: &api + image: ghcr.io/papyrusreader/server:${PAPYRUS_VERSION:?Set PAPYRUS_VERSION to the release number} + env_file: production.env + environment: + DEBUG: 'false' + HOST: 0.0.0.0 + PORT: '8080' + POSTGRES_HOST: database + POSTGRES_PORT: '5432' + MEDIA_STORAGE_ROOT: /var/lib/papyrus/media + PUBLIC_BASE_URL: https://${API_DOMAIN:?Set API_DOMAIN} + POWERSYNC_SERVICE_URL: https://${SYNC_DOMAIN:?Set SYNC_DOMAIN} + POWERSYNC_JWT_PRIVATE_KEY_FILE: /run/secrets/powersync-private.pem + POWERSYNC_JWT_PUBLIC_KEY_FILE: /run/secrets/powersync-public.pem + volumes: + - media:/var/lib/papyrus/media + - ./secrets/powersync-private.pem:/run/secrets/powersync-private.pem:ro + - ./secrets/powersync-public.pem:/run/secrets/powersync-public.pem:ro + depends_on: + database: + condition: service_healthy + +services: + database: + image: postgres:17-alpine + restart: unless-stopped + command: ['postgres', '-c', 'wal_level=logical', '-c', 'max_replication_slots=10', '-c', 'max_wal_senders=10', '-c', 'max_slot_wal_keep_size=1GB'] + environment: + POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB} + POWERSYNC_SOURCE_PASSWORD: ${POWERSYNC_SOURCE_PASSWORD:?Set POWERSYNC_SOURCE_PASSWORD} + volumes: + - database:/var/lib/postgresql/data + - ./bootstrap-powersync.sql:/bootstrap-powersync.sql:ro + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 5s + timeout: 3s + retries: 20 + powersync-storage: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POWERSYNC_STORAGE_USER:?Set POWERSYNC_STORAGE_USER} + POSTGRES_PASSWORD: ${POWERSYNC_STORAGE_PASSWORD:?Set POWERSYNC_STORAGE_PASSWORD} + POSTGRES_DB: ${POWERSYNC_STORAGE_DB:?Set POWERSYNC_STORAGE_DB} + volumes: + - powersync-storage:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 5s + timeout: 3s + retries: 20 + migrate: + <<: *api + profiles: [maintenance] + command: ['alembic', 'upgrade', 'head'] + restart: 'no' + api: + <<: *api + restart: unless-stopped + healthcheck: + test: ['CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=2)"] + interval: 10s + timeout: 3s + retries: 15 + powersync: + image: journeyapps/powersync-service:1.23.0 + restart: unless-stopped + command: ['start', '-r', 'unified'] + environment: + POWERSYNC_CONFIG_PATH: /config/service.yaml + PS_DATA_SOURCE_URI: postgresql://powersync_role:${POWERSYNC_SOURCE_PASSWORD}@database:5432/${POSTGRES_DB} + PS_STORAGE_URI: postgresql://${POWERSYNC_STORAGE_USER}:${POWERSYNC_STORAGE_PASSWORD}@powersync-storage:5432/${POWERSYNC_STORAGE_DB} + PS_JWKS_URI: http://api:8080/v1/auth/jwks + PS_AUDIENCE: ${POWERSYNC_JWT_AUDIENCE:?Set POWERSYNC_JWT_AUDIENCE} + volumes: + - ../powersync:/config:ro + depends_on: + api: + condition: service_healthy + powersync-storage: + condition: service_healthy + healthcheck: + test: ['CMD', 'node', '-e', "fetch('http://localhost:8080/probes/liveness').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 10s + timeout: 3s + retries: 15 + proxy: + image: caddy:2.10.2-alpine + restart: unless-stopped + ports: ['80:80', '443:443', '443:443/udp'] + environment: + API_DOMAIN: ${API_DOMAIN} + SYNC_DOMAIN: ${SYNC_DOMAIN} + APP_DOMAIN: ${APP_DOMAIN:?Set APP_DOMAIN for email verification and password reset} + ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + - ./web:/srv/web:ro + depends_on: + api: + condition: service_healthy + powersync: + condition: service_healthy + +volumes: + database: + powersync-storage: + media: + caddy-data: + caddy-config: diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..f661f82 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu +cd "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +python3 validate_config.py +compose() { docker compose --env-file production.env -f compose.yml "$@"; } +compose config --quiet +compose pull +compose up -d --wait database powersync-storage +compose stop api powersync proxy +compose run --rm migrate +# shellcheck disable=SC2016 +compose exec -T database sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /bootstrap-powersync.sql' +compose up -d --wait api powersync proxy diff --git a/deploy/production.env.example b/deploy/production.env.example new file mode 100644 index 0000000..aeee0d0 --- /dev/null +++ b/deploy/production.env.example @@ -0,0 +1,43 @@ +PAPYRUS_VERSION=1.0.0 +API_DOMAIN=api.example.com +SYNC_DOMAIN=sync.example.com +APP_DOMAIN=app.example.com +ACME_EMAIL= +DEBUG=false +HOST=0.0.0.0 +PORT=8080 +API_PREFIX=/v1 +CORS_ORIGINS=["https://app.example.com"] +SECRET_KEY= +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=30 +RATE_LIMIT_AUTH=5 +RATE_LIMIT_GENERAL=100 +RATE_LIMIT_UPLOAD=10 +RATE_LIMIT_BATCH=20 +POSTGRES_USER=papyrus +POSTGRES_DB=papyrus +POSTGRES_PASSWORD= +POWERSYNC_SOURCE_PASSWORD= +POWERSYNC_STORAGE_USER=powersync_storage +POWERSYNC_STORAGE_DB=powersync_storage +POWERSYNC_STORAGE_PASSWORD= +POWERSYNC_JWT_KEY_ID=papyrus-powersync-v1 +POWERSYNC_JWT_AUDIENCE=papyrus-production +APP_PUBLIC_BASE_URL=https://app.example.com +OAUTH_ALLOWED_REDIRECT_SCHEMES=["papyrus"] +OAUTH_ALLOWED_REDIRECT_HOSTS=["app.example.com"] +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +EMAIL_DELIVERY_ENABLED=true +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_USE_TLS=true +SMTP_USE_SSL=false +SMTP_FROM_EMAIL= +SMTP_FROM_NAME=Papyrus +ACQUISITION_ENABLED=false +OPDS_RELAY_ENABLED=true diff --git a/deploy/validate_config.py b/deploy/validate_config.py new file mode 100644 index 0000000..daf610c --- /dev/null +++ b/deploy/validate_config.py @@ -0,0 +1,57 @@ +"""Validate host deployment settings without printing secrets.""" + +import json +import re +from pathlib import Path + + +def validate(values: dict[str, str]) -> None: + for key in ("API_DOMAIN", "SYNC_DOMAIN", "APP_DOMAIN"): + value = values.get(key, "") + if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", value) or "." not in value: + raise ValueError(f"{key} must be a DNS hostname") + if value.endswith(("example.com", ".invalid", ".localhost", ".local")): + raise ValueError(f"Replace the placeholder in {key} with your public hostname") + if len({values[key] for key in ("API_DOMAIN", "SYNC_DOMAIN", "APP_DOMAIN")}) != 3: + raise ValueError("API, sync and web app need distinct hostnames") + if values.get("API_PREFIX") != "/v1": + raise ValueError("API_PREFIX must remain /v1 for the current client and PowerSync configuration") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", values.get("PAPYRUS_VERSION", "")): + raise ValueError("PAPYRUS_VERSION must be a release number") + for key in ("POSTGRES_PASSWORD", "POWERSYNC_SOURCE_PASSWORD", "POWERSYNC_STORAGE_PASSWORD", "SECRET_KEY"): + if not re.fullmatch(r"[A-Za-z0-9_-]{32,}", values.get(key, "")): + raise ValueError(f"{key} needs at least 32 URL-safe characters; generate with openssl rand -hex 32") + for key in ("POSTGRES_USER", "POSTGRES_DB", "POWERSYNC_STORAGE_USER", "POWERSYNC_STORAGE_DB"): + if not re.fullmatch(r"[a-z][a-z0-9_]*", values.get(key, "")): + raise ValueError(f"{key} must be a lowercase database identifier") + for key in ("ACME_EMAIL", "SMTP_HOST", "SMTP_FROM_EMAIL", "POWERSYNC_JWT_KEY_ID", "POWERSYNC_JWT_AUDIENCE"): + if not values.get(key): + raise ValueError(f"{key} is required") + if values.get("APP_PUBLIC_BASE_URL") != f"https://{values['APP_DOMAIN']}": + raise ValueError("APP_PUBLIC_BASE_URL must match the public HTTPS APP_DOMAIN") + if json.loads(values.get("CORS_ORIGINS", "[]")) != [values["APP_PUBLIC_BASE_URL"]]: + raise ValueError("CORS_ORIGINS must contain the HTTPS web app origin") + if values.get("SMTP_USE_TLS") != "true" and values.get("SMTP_USE_SSL") != "true": + raise ValueError("Production SMTP must use TLS or SSL") + if bool(values.get("GOOGLE_OAUTH_CLIENT_ID")) != bool(values.get("GOOGLE_OAUTH_CLIENT_SECRET")): + raise ValueError("Configure both Google OAuth credentials or neither") + + +def main() -> None: + directory = Path(__file__).resolve().parent + values = {} + for line in (directory / "production.env").read_text().splitlines(): + if line.strip() and not line.lstrip().startswith("#"): + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip("'") + validate(values) + for name in ("powersync-private.pem", "powersync-public.pem"): + if not (directory / "secrets" / name).is_file(): + raise ValueError(f"Missing secrets/{name}; see the deployment runbook") + if not (directory / "web/index.html").is_file(): + raise ValueError("Missing web/index.html; extract the client web-release artifact into deploy/web") + print("Deployment configuration validated") + + +if __name__ == "__main__": + main() diff --git a/papyrus/__init__.py b/papyrus/__init__.py index b4a5677..ba5ff5a 100644 --- a/papyrus/__init__.py +++ b/papyrus/__init__.py @@ -1,3 +1,5 @@ """Papyrus Server - REST API for book management.""" -__version__ = "1.0.0" +from importlib.metadata import version + +__version__ = version("papyrus-server") diff --git a/papyrus/main.py b/papyrus/main.py index 7041c81..b619b08 100644 --- a/papyrus/main.py +++ b/papyrus/main.py @@ -16,6 +16,7 @@ from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded +from papyrus import __version__ from papyrus.api.routes import api_router, include_debug_routers from papyrus.config import get_settings from papyrus.core.database import async_session_maker @@ -72,7 +73,7 @@ def create_app() -> FastAPI: app = FastAPI( title="Papyrus Server API", - version="1.0.0", + version=__version__, description=f""" REST API for Papyrus - a cross-platform book management application. diff --git a/pyproject.toml b/pyproject.toml index 53b2bb1..ecce2b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "papyrus-server" -version = "0.1.0" +version = "1.0.0" description = "REST API server for Papyrus, a cross platform book management application." requires-python = ">=3.12" license = { text = "AGPL-3.0" } diff --git a/tools/release_gate.py b/tools/release_gate.py new file mode 100644 index 0000000..037abf3 --- /dev/null +++ b/tools/release_gate.py @@ -0,0 +1,99 @@ +"""Decide releases from committed versions, not file changes or workflow counters.""" + +import argparse +import ipaddress +import os +import re +import subprocess +import tomllib +from collections.abc import Mapping +from pathlib import Path +from urllib.parse import urlsplit + + +def parse(text: str, component: str) -> tuple[str, int]: + if component == "client": + match = re.search(r"^version: ([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)\s*$", text, re.M) + if not match: + raise ValueError("pubspec version must be MAJOR.MINOR.PATCH+BUILD") + version, number = match.groups() + if not 0 < int(number) <= 2100000000: + raise ValueError("Android build number must be between 1 and 2100000000") + return version, int(number) + version = str(tomllib.loads(text)["project"]["version"]) + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError("Server version must be MAJOR.MINOR.PATCH") + return version, 0 + + +def decide(current: tuple[str, int], previous: tuple[str, int], component: str) -> bool: + if current == previous: + return False + if tuple(map(int, current[0].split("."))) < tuple(map(int, previous[0].split("."))): + raise ValueError("Release version cannot decrease") + if component == "client" and current[1] <= previous[1]: + raise ValueError("Every Android release must increase the committed build number") + return True + + +def validate_endpoints(environ: Mapping[str, str]) -> None: + for key in ("PAPYRUS_API_BASE_URL", "POWERSYNC_SERVICE_URL"): + value = environ.get(key, "") + uri = urlsplit(value) + host = uri.hostname or "" + if uri.scheme != "https" or not host or uri.username or uri.password or uri.query or uri.fragment: + raise ValueError(f"{key} must be a public HTTPS base URL without credentials, query or fragment") + if uri.path not in ("", "/"): + raise ValueError(f"{key} must be the origin URL; the client adds /v1 itself") + if ( + host == "localhost" + or host.endswith((".localhost", ".local", ".invalid", ".test", ".example.com")) + or host == "example.com" + ): + raise ValueError(f"{key} must not point to a development host") + try: + address = ipaddress.ip_address(host) + except ValueError: + continue + if not address.is_global: + raise ValueError(f"{key} must not point to a private IP address") + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], text=True).strip() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("component", choices=("client", "server")) + parser.add_argument("--base") + parser.add_argument("--manual", action="store_true") + parser.add_argument("--endpoints", action="store_true") + args = parser.parse_args() + if args.endpoints: + validate_endpoints(os.environ) + return + path = "app/pubspec.yaml" if args.component == "client" else "pyproject.toml" + current = parse(git("show", f"HEAD:{path}"), args.component) + if args.manual: + release = True + elif args.base and set(args.base) != {"0"}: + release = decide(current, parse(git("show", f"{args.base}:{path}"), args.component), args.component) + else: + raise ValueError("A previous commit is required; use a manual build to bootstrap") + version, number = current + tag = f"v{version}+{number}" if args.component == "client" else f"v{version}" + existing = subprocess.run( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], capture_output=True, text=True + ) + if release and existing.returncode == 0 and existing.stdout.strip() != git("rev-parse", "HEAD"): + raise ValueError(f"{tag} already belongs to another commit; bump the version") + output = f"release={str(release).lower()}\nversion={version}\nbuild_number={number}\ntag={tag}\n" + if os.environ.get("GITHUB_OUTPUT"): + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as file: + file.write(output) + print(output, end="") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/test_deploy.py b/tools/tests/test_deploy.py new file mode 100644 index 0000000..87b7306 --- /dev/null +++ b/tools/tests/test_deploy.py @@ -0,0 +1,54 @@ +"""Reject incomplete production configuration without connecting to a database.""" + +import importlib.util +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "deploy_config", Path(__file__).resolve().parents[2] / "deploy/validate_config.py" +) +assert spec is not None and spec.loader is not None +config = importlib.util.module_from_spec(spec) +spec.loader.exec_module(config) + + +class DeploymentTest(unittest.TestCase): + def values(self) -> dict[str, str]: + return { + "API_DOMAIN": "api.papyrusreader.org", + "SYNC_DOMAIN": "sync.papyrusreader.org", + "APP_DOMAIN": "app.papyrusreader.org", + "APP_PUBLIC_BASE_URL": "https://app.papyrusreader.org", + "CORS_ORIGINS": '["https://app.papyrusreader.org"]', + "API_PREFIX": "/v1", + "PAPYRUS_VERSION": "1.0.0", + "SECRET_KEY": "a" * 64, + "POSTGRES_PASSWORD": "b" * 64, + "POWERSYNC_SOURCE_PASSWORD": "c" * 64, + "POWERSYNC_STORAGE_PASSWORD": "d" * 64, + "POSTGRES_USER": "papyrus", + "POSTGRES_DB": "papyrus", + "POWERSYNC_STORAGE_USER": "powersync", + "POWERSYNC_STORAGE_DB": "powersync", + "ACME_EMAIL": "admin@papyrusreader.org", + "SMTP_HOST": "smtp.papyrusreader.org", + "SMTP_FROM_EMAIL": "support@papyrusreader.org", + "SMTP_USE_TLS": "true", + "POWERSYNC_JWT_KEY_ID": "v1", + "POWERSYNC_JWT_AUDIENCE": "papyrus-production", + } + + def test_valid_config(self) -> None: + config.validate(self.values()) + + def test_placeholder_secret_or_url_drift_is_rejected(self) -> None: + for key, value in ( + ("API_DOMAIN", "api.example.com"), + ("SECRET_KEY", ""), + ("POSTGRES_PASSWORD", "password"), + ("APP_PUBLIC_BASE_URL", "http://localhost"), + ("SMTP_USE_TLS", "false"), + ("POSTGRES_DB", "name/with/slashes"), + ): + with self.subTest(key=key), self.assertRaises(ValueError): + config.validate({**self.values(), key: value}) diff --git a/tools/tests/test_release.py b/tools/tests/test_release.py new file mode 100644 index 0000000..ee9757e --- /dev/null +++ b/tools/tests/test_release.py @@ -0,0 +1,131 @@ +"""Fast, database-free regression checks for release decisions.""" + +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +GATE_PATH = Path(__file__).resolve().parents[1] / "release_gate.py" +spec = importlib.util.spec_from_file_location("release_gate", GATE_PATH) +assert spec is not None and spec.loader is not None +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class ReleaseGateTest(unittest.TestCase): + def test_dependency_edit_does_not_release(self) -> None: + first = gate.parse("version: 1.0.0+1\ndependencies: old\n", "client") + second = gate.parse("version: 1.0.0+1\ndependencies: new\n", "client") + self.assertFalse(gate.decide(second, first, "client")) + + def test_each_android_upload_requires_a_new_number(self) -> None: + self.assertTrue(gate.decide(("1.0.0", 2), ("1.0.0", 1), "client")) + self.assertTrue(gate.decide(("1.1.0", 3), ("1.0.0", 2), "client")) + for current in (("1.1.0", 1), ("1.1.0", 2), ("0.9.0", 3)): + with self.assertRaises(ValueError): + gate.decide(current, ("1.0.0", 2), "client") + + def test_server_dependency_edit_does_not_release(self) -> None: + first = gate.parse('[project]\nversion = "1.0.0"\ndependencies = []', "server") + second = gate.parse('[project]\nversion = "1.0.0"\ndependencies = ["fastapi"]', "server") + self.assertFalse(gate.decide(second, first, "server")) + self.assertTrue(gate.decide(("1.0.1", 0), first, "server")) + with self.assertRaises(ValueError): + gate.decide(("0.9.0", 0), first, "server") + + def test_invalid_manifest_versions_are_rejected(self) -> None: + for version in ("1.0.0", "1.0.0+0", "1.0.0+2100000001", "latest"): + with self.assertRaises(ValueError): + gate.parse(f"version: {version}", "client") + + def test_release_endpoints_require_public_https_origins(self) -> None: + good = { + "PAPYRUS_API_BASE_URL": "https://api.papyrusreader.org", + "POWERSYNC_SERVICE_URL": "https://sync.papyrusreader.org", + } + gate.validate_endpoints(good) + for value in ( + "", + "http://api.papyrusreader.org", + "https://localhost", + "https://127.0.0.1", + "https://192.168.1.2", + "https://api.example.invalid", + "https://api.example.com", + "https://api.papyrusreader.org/v1", + "https://user:secret@api.papyrusreader.org", + "https://api.papyrusreader.org?token=x", + ): + with self.subTest(value=value), self.assertRaises(ValueError): + gate.validate_endpoints({**good, "PAPYRUS_API_BASE_URL": value}) + + +class CommittedGateTest(unittest.TestCase): + def setUp(self) -> None: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.command("init", "-q") + (self.root / "app").mkdir() + self.manifest = self.root / "app/pubspec.yaml" + self.manifest.write_text("version: 1.0.0+1\n") + self.commit() + self.base = self.command("rev-parse", "HEAD") + + def command(self, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(self.root), *args], text=True, stderr=subprocess.DEVNULL + ).strip() + + def commit(self) -> None: + self.command("add", ".") + self.command( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ) + + def run_gate(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(GATE_PATH), "client", *args], + cwd=self.root, + capture_output=True, + text=True, + ) + + def test_compares_committed_values_and_skips_dependency_edits(self) -> None: + self.manifest.write_text("version: 1.0.0+1\ndependencies: changed\n") + self.commit() + result = self.run_gate("--base", self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("release=false", result.stdout) + self.manifest.write_text("version: 1.0.0+2\n") + self.commit() + result = self.run_gate("--base", self.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("tag=v1.0.0+2", result.stdout) + self.assertIn("release=true", result.stdout) + + def test_released_tag_cannot_be_reused_for_another_commit(self) -> None: + self.command("tag", "v1.0.0+1") + self.manifest.write_text("version: 1.0.0+1\ndependencies: changed\n") + self.commit() + result = self.run_gate("--manual") + self.assertNotEqual(result.returncode, 0) + self.assertIn("already belongs to another commit", result.stderr) + + def test_bootstrap_requires_explicit_manual_build(self) -> None: + self.assertNotEqual(self.run_gate("--base", "0" * 40).returncode, 0) + self.assertEqual(self.run_gate("--manual").returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 3c965dc..af82d71 100644 --- a/uv.lock +++ b/uv.lock @@ -789,7 +789,7 @@ wheels = [ [[package]] name = "papyrus-server" -version = "0.1.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From ba285dfe60011f5a0a4cc4f9ee88f7f391b85fad Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 4 Oct 2026 23:14:20 +0300 Subject: [PATCH 2/2] chore: configure registered Papyrus deployment domain --- deploy/README.md | 13 +++++++------ deploy/production.env.example | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 11c066b..7a442e5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -19,13 +19,14 @@ SSH key access and a Hetzner firewall allowing SSH from your own IP and public TCP 80/443 (UDP 443 is optional for HTTP/3). Databases and PowerSync's internal listener have **no host port mappings**. -Choose one domain you own. Use `api.`, `sync.` and `app.`. +The registered domain is `papyrus-reader.com`. Use `api.papyrus-reader.com`, +`sync.papyrus-reader.com` and `app.papyrus-reader.com`. Point their DNS A records to the VM (add AAAA only if IPv6 routing works). Caddy obtains and renews HTTPS certificates and proxies PowerSync streaming. It also serves the built Flutter web app for verification/password-reset links. The client release environment must use the same API/sync origins. Set the Google OAuth web client's authorized redirect URI to -`https://api./v1/auth/oauth/google/callback`; mobile callbacks remain +`https://api.papyrus-reader.com/v1/auth/oauth/google/callback`; mobile callbacks remain `papyrus://auth/callback`. ## First deployment @@ -45,8 +46,8 @@ chmod 400 secrets/powersync-private.pem chmod 444 secrets/powersync-public.pem ``` -Edit `production.env` locally on the VM. Replace placeholder domains and set -`APP_PUBLIC_BASE_URL`, CORS and allowed web redirect hosts accordingly. Generate +Edit `production.env` locally on the VM. The domain, `APP_PUBLIC_BASE_URL`, CORS and allowed web redirect hosts already +match `papyrus-reader.com`; keep these aligned if you change a hostname. Generate **separate** values for `SECRET_KEY`, `POSTGRES_PASSWORD`, `POWERSYNC_SOURCE_PASSWORD` and `POWERSYNC_STORAGE_PASSWORD` using `openssl rand -hex 32`. Database values must be URL-safe because connection URLs @@ -73,8 +74,8 @@ health checks. It intentionally causes a short maintenance window. If a migratio fails, services stay stopped; inspect the failure before restoring service. Migrations do not run independently in every API replica. -Verify externally: `https://api./health`, `/openapi.json` (release version), -`https://sync./probes/liveness`, the web app, SMTP verification/reset, Google +Verify externally: `https://api.papyrus-reader.com/health`, `/openapi.json` (release version), +`https://sync.papyrus-reader.com/probes/liveness`, the web app, SMTP verification/reset, Google sign-in, book/media upload and sync from a Play-installed Android build. Monitor PowerSync replication slots: a 1 GB WAL retention cap protects disk but an extended outage can invalidate a slot and require a controlled resync. diff --git a/deploy/production.env.example b/deploy/production.env.example index aeee0d0..cd9e406 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -1,13 +1,13 @@ PAPYRUS_VERSION=1.0.0 -API_DOMAIN=api.example.com -SYNC_DOMAIN=sync.example.com -APP_DOMAIN=app.example.com +API_DOMAIN=api.papyrus-reader.com +SYNC_DOMAIN=sync.papyrus-reader.com +APP_DOMAIN=app.papyrus-reader.com ACME_EMAIL= DEBUG=false HOST=0.0.0.0 PORT=8080 API_PREFIX=/v1 -CORS_ORIGINS=["https://app.example.com"] +CORS_ORIGINS=["https://app.papyrus-reader.com"] SECRET_KEY= ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=60 @@ -25,9 +25,9 @@ POWERSYNC_STORAGE_DB=powersync_storage POWERSYNC_STORAGE_PASSWORD= POWERSYNC_JWT_KEY_ID=papyrus-powersync-v1 POWERSYNC_JWT_AUDIENCE=papyrus-production -APP_PUBLIC_BASE_URL=https://app.example.com +APP_PUBLIC_BASE_URL=https://app.papyrus-reader.com OAUTH_ALLOWED_REDIRECT_SCHEMES=["papyrus"] -OAUTH_ALLOWED_REDIRECT_HOSTS=["app.example.com"] +OAUTH_ALLOWED_REDIRECT_HOSTS=["app.papyrus-reader.com"] GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= EMAIL_DELIVERY_ENABLED=true