diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ac2719b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,162 @@ +# .gitea/workflows/ci.yml - ResearchX phase-4 CI gate (researchx#24). +# +# Ported to the v0.3 line (milestone/v0.3-hosted): phase 4 ships on the +# milestone branch, and the pull_request trigger is extended to it so a +# phase-4 pull request gets a gate run there. The file itself is the same +# gate that ran green on main (researchx#24). +# +# This file IS the gate. The pre-code clause (coreci/company#105, c14057): +# a code phase opens only when the product repo's gating workflow file has +# dispatched at least one run on `main` and every job in that file is green +# in that run. Until this file exists, coreci/researchx has no workflow and +# no run at all - that is the case the clause was amended for (researchx#24, +# c14093). A half-green pipeline does not open the phase either. +# +# Two jobs: +# backend - uv + Python 3.12: ruff, mypy, pytest +# frontend - Node 22 + pnpm 12.6.0: typecheck, lint, test, build +# +# Runner label: `ubuntu-latest` - the org CI fleet's published label whose +# image is docker.gitea.com/runner-images:ubuntu-latest (label map read at +# source: coreci/nextcraft deploy/ci/docker-compose.yaml + deploy/ci/README.md, +# R-3). The previous string `employee-runner` was a fleet NAME, not a label: +# the employee fleet's labels are `employee-py` and `employee-coder` (read +# from a live job log of this seat's own home), and the CI fleet's are node / +# ubuntu-latest / python / release - no runner carried the old label, so a +# merged file asking for it could still buy no run. +# +# Toolchain: measured, not assumed. From the runner's own logs (job 56393, +# coreci/nova-platform, 2026-10-26T23:17Z, and job 57005, coreci/inboxos-v1, +# 2026-10-27T03:01Z): the image carries python 3.12.3, pip, git, node (the +# actions run in it), pypi REACHABLE and github REACHABLE; actions/checkout@v4, +# actions/setup-python@v5 and actions/setup-go@v5 all run inside it. It does +# NOT carry uv or pnpm, so this file installs them: uv from pypi (pip), pnpm +# 12.6.0 through corepack - the repo's own documented way (README command map). +# The gates are the repo's documented commands: backend `uv run ruff check src +# tests`, `uv run mypy src`, `uv run pytest`; frontend `pnpm typecheck`, +# `pnpm lint`, `pnpm test`, `pnpm build`. The frontend lane mirrors the +# release image's own recipe (frontend/Dockerfile: node:22-alpine, corepack, +# pnpm install --frozen-lockfile, pnpm build), so the gate tests what the +# product ships. +# +# Owner of a red leg (the clause asks for the owner of the fix next to the +# owner of the product line): toolchain / image / dispatch problems -> +# coreci-sre (employee-runner-fleet + gitea-instance); product-line problems +# -> coreci-researchx-po. Approval for this file rides the merge request +# (merge = approval, constitution v1.3). + +name: ci + +on: + push: + pull_request: + branches: ["main", "milestone/v0.3-hosted"] + workflow_dispatch: + +jobs: + backend: + name: "backend (uv, Python 3.12: ruff/mypy/pytest)" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # uv is not in the CI image; pypi is reachable from it (measured). The + # install is a toolchain leg, not a product gate: the gates still run + # from the repo's own committed lockfile (backend/uv.lock). + - name: Install uv (from pypi) + run: | + set -eu + python -m pip install --upgrade uv + uv --version + + - name: Install dependencies (uv, from the committed lockfile) + working-directory: backend + run: uv sync --frozen + + - name: Lint (ruff) + working-directory: backend + run: uv run ruff check src tests + + - name: Typecheck (mypy) + working-directory: backend + run: uv run mypy src + + - name: Test (pytest) + working-directory: backend + run: uv run pytest + + frontend: + name: "frontend (Next.js on pnpm: typecheck/lint/test/build)" + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Node 22 to mirror the release image (frontend/Dockerfile: node:22). + - name: Setup Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Setup Python 3.12 (for the real-stack e2e bridge) + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv (from pypi) + run: | + set -eu + python -m pip install --upgrade uv + + # pnpm 12.6.0 is pinned by frontend/package.json `packageManager`; + # corepack reads it from there. If the image carries no corepack, npm + # installs the same pinned version. + - name: pnpm 12.6.0 (corepack, pinned by frontend/package.json) + working-directory: frontend + run: | + set -eu + node --version + corepack enable pnpm 2>/dev/null || true + if ! pnpm --version >/dev/null 2>&1; then + npm i -g pnpm@12.6.0 + fi + test "$(pnpm --version)" = "12.6.0" + + - name: Install dependencies (from the committed lockfile) + working-directory: frontend + run: pnpm install --frozen-lockfile + + # frontend/src/lib/api/e2e-real-stack.test.ts drives the real FastAPI + # stack: it spawns `uv run python -m researchx.api.dev_bridge` from + # ../backend (loopback :8000, fakes only, zero API keys). Without a + # synced backend venv that test cannot come up, so the lane provisions + # it. Provisioning only - the backend gates stay in the backend job. + - name: Backend venv for the real-stack e2e bridge + working-directory: backend + run: uv sync --frozen + + - name: Typecheck + working-directory: frontend + run: pnpm typecheck + + - name: Lint + working-directory: frontend + run: pnpm lint + + - name: Test + working-directory: frontend + run: pnpm test + + - name: Build + working-directory: frontend + env: + NEXT_TELEMETRY_DISABLED: "1" + run: pnpm build diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..6aebebb --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,17 @@ +# Build-context hygiene for the backend image (mirrors frontend/.dockerignore). +.venv +__pycache__ +*.pyc +*.pyo +.pytest_cache +.mypy_cache +.ruff_cache +.git +.env +.env.* +!.env.example +var +tests +Dockerfile +.dockerignore +README.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..22a451f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,58 @@ +# ResearchX backend — production release image (ciagent release system) +# Multi-stage: deps -> runtime. FastAPI + uvicorn, single worker, non-root. +# Built ON the Docker host from the synced source (no local docker required), +# mirroring frontend/Dockerfile: the build context is backend/. +# +# Contracts this image holds (REQ-83, v0.3 line): +# - one uvicorn worker — the run registry lives in process memory +# - non-root user "researchx" — house recipe, same as frontend/Dockerfile +# - the mutable data root is RESEARCHX_DATA_DIR (default /var/lib/researchx); +# that is the variable researchx.config.paths.data_root() reads (D-107), +# so the volume mount point is the directory the app actually writes +# - /healthz is the public health surface (outside /api, never auth-wrapped, +# D-103) — the container healthcheck below uses it +# - the serving entrypoint is researchx.api.main (the PRODUCTION wiring: +# auth_enabled=True and cookie_secure=True hardwired, the live model +# factory reading per-user -> server -> keyless credentials from the +# runtime env file). researchx.api.app's module-global is the TEST/DEV +# factory — it serves FakeModelAdapter and cwd-based storage, so +# hosting it would silently ship fakes (P-3 closure, RESEARCH-v03 §8). +# - uid/gid 10001 is fixed so the host-side volume chown is deterministic +# (the deploy script reads the ownership from this image) + +# ---- deps ---- +FROM python:3.12-slim AS deps +# uv is the repo's package manager (backend/uv.lock). The repo pins no uv +# version, so this install is bounded to a major range; pin it exactly once +# the deploy host's uv version is known. +RUN pip install --no-cache-dir "uv>=0.8,<2" +WORKDIR /app +ENV UV_PYTHON_DOWNLOADS=never +COPY pyproject.toml uv.lock .python-version ./ +COPY src ./src +# --frozen: use uv.lock as written (no lock rewrite). --no-dev: no test/lint +# dependencies in the runtime image. --no-editable: copy the package into +# site-packages instead of linking the package source tree. +RUN uv sync --frozen --no-dev --no-editable --no-cache --python 3.12 + +# ---- runtime ---- +FROM python:3.12-slim AS runtime +WORKDIR /app +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + RESEARCHX_DATA_DIR=/var/lib/researchx +COPY --from=deps /app/.venv /app/.venv +# Non-root app user (house recipe: frontend/Dockerfile runs as researchx). +RUN addgroup --gid 10001 researchx \ + && adduser --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin researchx \ + && mkdir -p /var/lib/researchx \ + && chown -R researchx:researchx /var/lib/researchx +USER researchx +EXPOSE 8000 +# Public health surface (outside /api, never auth-wrapped). python is already +# in the image, so the check needs no extra package. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)"] +# Single worker (REQ-83): the run registry lives in process memory. +CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"]