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..7a442e5 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,113 @@ +# 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**. + +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.papyrus-reader.com/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. 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 +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.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. + +## 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..cd9e406 --- /dev/null +++ b/deploy/production.env.example @@ -0,0 +1,43 @@ +PAPYRUS_VERSION=1.0.0 +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.papyrus-reader.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.papyrus-reader.com +OAUTH_ALLOWED_REDIRECT_SCHEMES=["papyrus"] +OAUTH_ALLOWED_REDIRECT_HOSTS=["app.papyrus-reader.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" },