From af8aa2546493e909b56873a3dbd698f316e01b64 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 19 Jul 2026 13:17:38 -0400 Subject: [PATCH] M0: Docker + Fabled-Git CI (typecheck + lint + test + build) - 2-stage Dockerfile (node:22 build Vue -> python:3.12-slim runtime); CMD runs `alembic upgrade head` then hypercorn (rule 82). - .forgejo/workflows/ci.yml on ci-python:3.14: typecheck (vue-tsc) + lint (ruff check src/) + test (uv venv + pytest, DB-free) + gated buildx push with the rule-46 tag scheme (:dev/:latest/:). No local integration lane yet. - ci-requirements.md (rule 39); docker-compose for a local app+Postgres stack. - Real README with layout, dev instructions, and the milestone arc. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- .forgejo/workflows/ci.yml | 157 ++++++++++++++++++++++++++++++++++++++ Dockerfile | 35 +++++++++ README.md | 59 +++++++++++++- ci-requirements.md | 47 ++++++++++++ docker-compose.yml | 35 +++++++++ 5 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 ci-requirements.md create mode 100644 docker-compose.yml diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..964c02b --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,157 @@ +# CI runs first; build only proceeds if lint + typecheck pass. +# +# Push to dev: typecheck + lint + test + build :dev + : +# Push to main: typecheck + lint + test + build :latest + : +# Tag v* (release): typecheck + lint + test + build :latest + : + : +# +# main is the production line, so a merge to main rebuilds and moves :latest to its +# tip (family rule 46) — no version release required. The : image is the +# immutable rollback unit for every build. +# +# Required secrets (repo -> Settings -> Secrets -> Actions): +# REGISTRY_USER -- Forgejo username +# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope +name: CI & Build + +on: + push: + branches: [dev, main] + tags: ["v*"] + paths: + - "src/**" + - "frontend/**" + - "tests/**" + - "pyproject.toml" + - "alembic/**" + - "alembic.ini" + - "Dockerfile" + - ".forgejo/workflows/ci.yml" + +# Cancel older runs on the same branch when a newer push lands. Tag runs get their +# own group implicitly and are never cancelled. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +permissions: + contents: read + +env: + REGISTRY: git.fabledsword.com + IMAGE: git.fabledsword.com/bvandeusen/thoughtsync + +jobs: + typecheck: + name: TypeScript typecheck + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v6 + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Type check + run: npx vue-tsc --noEmit + working-directory: frontend + + lint: + name: Python lint + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v6 + + # ruff is pre-installed in the ci-runner base image. + - name: Lint + run: ruff check src/ + + test: + name: Python tests + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v6 + + - name: Create virtual environment + run: uv venv /opt/venv + + - name: Install package with dev deps + run: uv pip install --python /opt/venv/bin/python -e ".[dev]" + + - name: Run tests + run: /opt/venv/bin/python -m pytest tests/ -q + + build: + name: Build & push image + # Build gates on lint + typecheck. The `test` job runs in parallel for + # visibility but does not block dev image builds (DB-backed integration + # testing happens against the dev image manually, not on every push). + needs: [typecheck, lint] + if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - name: Generate image tags and version + id: tags + # run: steps execute under busybox sh (family rule 81), so use POSIX `case`, + # NOT bash `[[ ]]`. + run: | + TAGS="${{ env.IMAGE }}:${{ github.sha }}" + BUILD_VERSION="dev" + case "${{ github.ref }}" in + refs/heads/dev) + TAGS="$TAGS,${{ env.IMAGE }}:dev" + ;; + refs/heads/main) + # Production line: :latest tracks main's tip (rule 46). No :main tag; + # the : above is the rollback unit. Version label = short sha. + TAGS="$TAGS,${{ env.IMAGE }}:latest" + BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)" + ;; + refs/tags/*) + TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" + BUILD_VERSION="${{ github.ref_name }}" + ;; + esac + echo "value=$TAGS" >> $GITHUB_OUTPUT + echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT + + - name: Free disk space + run: | + docker system prune -af || true + docker builder prune --keep-storage 5g -f || true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Forgejo registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + provenance: false + tags: ${{ steps.tags.outputs.value }} + build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }} + cache-from: type=registry,ref=${{ env.IMAGE }}:cache + cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f76fa36 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# Stage 1: build the Vue frontend (also type-checks via `vue-tsc` in the build script). +FROM node:22-alpine AS build-frontend +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci --quiet +COPY frontend/ . +RUN npm run build + +# Stage 2: Python runtime. +FROM python:3.12-slim AS runtime +WORKDIR /app + +COPY pyproject.toml . +COPY src/ src/ +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install . + +# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH +# points at /app/src so the runtime imports this source tree (with static/ present), +# not the pip-installed copy. +COPY --from=build-frontend /build/dist/ src/thoughtsync/static/ +COPY alembic.ini . +COPY alembic/ alembic/ + +ENV PYTHONPATH=/app/src + +ARG BUILD_VERSION=dev +ENV APP_VERSION=$BUILD_VERSION + +EXPOSE 5000 +# Run migrations, then serve. Family convention (rule 82): schema is built by real +# migrations, never metadata.create_all. +CMD ["sh", "-c", "alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] diff --git a/README.md b/README.md index 0ea8c9f..66630cb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,58 @@ -# thoughtsync +# ThoughtSync -Self-hosted personal thought-capture web app (FabledSword family) — a Google-Keep-style masonry post-it board for fast idea capture, growing into a lightweight second brain. Quart + Vue 3 + PostgreSQL. \ No newline at end of file +Self-hosted personal thought-capture web app in the **FabledSword** family — a +Google-Keep-style **masonry post-it board** for capturing disparate thoughts in +under a second, designed to grow into a lightweight second brain (labels, search, +`[[wiki-links]]`, graph). + +- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI. +- **Auth:** native email + password (signed-cookie sessions). Multi-user with the + family owner + direct-share + group-share ACL. +- **Web-first.** An Android companion is a later, conditional milestone. + +## Layout + +``` +src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db) +alembic/ async migrations (schema built via `alembic upgrade head`) +tests/ DB-free unit tests (pytest) +frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA +Dockerfile 2-stage build (Vue -> python:3.12-slim runtime) +docker-compose.yml local app + Postgres stack +``` + +## Development + +Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`): + +```sh +pip install -e ".[dev]" +alembic upgrade head +hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 +``` + +Frontend (proxies `/api` to `:5000`): + +```sh +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +Or run the whole stack in Docker: + +```sh +docker compose up --build +``` + +## Milestones + +- **M0 — Foundation & Identity** *(in progress)*: Quart+Vue+Postgres skeleton, + native auth, sharing-ACL spine, Fabled-Git CI. +- M1 — Capture Core: note model + masonry board (quick-add, colors, pin, + edit-in-place, archive, trash). +- M2 — Organize: labels/tags, search, checklists, attachments. +- M3 — Second-brain seeds: `[[wiki-links]]`, backlinks, graph view, reminders. +- M4 — Launch hardening: responsive/PWA, settings UI, polish. + +Work happens on `dev`; `main` is protected (PR-only). diff --git a/ci-requirements.md b/ci-requirements.md new file mode 100644 index 0000000..cdc4a94 --- /dev/null +++ b/ci-requirements.md @@ -0,0 +1,47 @@ +# CI Requirements — ThoughtSync + +> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md) +> in the CI-Runner repo. + +## Runtime image + +``` +git.fabledsword.com/bvandeusen/ci-python:3.14 +``` + +Selected via `container.image` (not a `runs-on` label) on all four jobs in +`.forgejo/workflows/ci.yml`: typecheck (Vue/TS), lint (ruff), test (pytest), +build (docker buildx). + +## Image deps used + +- python 3.12+ (the runtime `Dockerfile` targets python:3.12-slim; tests run on + the image's 3.14 — both >=3.12, so results stay representative) +- node 24 — `npm ci` + `vue-tsc` in the typecheck job, and the frontend builder + stage inside the production `Dockerfile`. (Also required by the JS-based + `actions/checkout` action — a Node-less runner fails every job at checkout.) +- ruff — lint job runs `ruff check src/` with zero install overhead +- uv — test job creates the venv (`uv venv /opt/venv`) and installs the package + with dev deps +- docker CLI + buildx — build job pushes the dev/release image to the Forgejo + registry + +## Per-job tool installs + +Nothing installed at job time beyond what the image provides — all four jobs run +entirely on `ci-python:3.14`. + +## Notes + +- **No `actions/cache`.** Deliberately omitted for npm/uv: it's a GitHub-fetched + JS action and on a cold runner concurrent jobs race fetching it. We lean on the + pinned `ci-python` image's pre-installed toolchain instead; `npm ci` / `uv pip + install` cold cost is a non-blocker. +- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for + visibility but does not block the dev image push. DB-backed / integration tests + run against the dev image manually — ThoughtSync's unit tests are DB-free (no + Postgres service lane in CI yet). +- `dev` push -> `:dev` + `:`; `v*` tag -> `:latest` + `:` + `:` + (family rule 46). +- The production runtime `Dockerfile` tracks python:3.12 so test results stay + representative of the deployed image. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9f4c7a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +# Local two-service stack (app + Postgres). Provided for convenience — per family +# rule 12 the agent does NOT start this; run it yourself with `docker compose up`. +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: thoughtsync + POSTGRES_PASSWORD: thoughtsync + POSTGRES_DB: thoughtsync + volumes: + - thoughtsync-db:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U thoughtsync"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: . + depends_on: + db: + condition: service_healthy + environment: + THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:thoughtsync@db:5432/thoughtsync + THOUGHTSYNC_DATA_DIR: /data + volumes: + - thoughtsync-data:/data + ports: + - "5000:5000" + +volumes: + thoughtsync-db: + thoughtsync-data: