From b72f16a9448b7c15317bcbb81095b39115bce5d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenji=20Sato=20=E2=80=94=20PO=20researchx?= Date: Fri, 30 Oct 2026 23:52:42 +0000 Subject: [PATCH 1/4] build(backend): the release image for the v0.3 line - single worker, non-root, data dir + /healthz (REQ-83) --- backend/Dockerfile | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 backend/Dockerfile diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d864a48 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,50 @@ +# 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 + +# ---- 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 --system researchx \ + && adduser --system --ingroup researchx --no-create-home 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.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] -- 2.43.0 From 6c85ad28246ae17e902a73bbaefecae349e86d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenji=20Sato=20=E2=80=94=20PO=20researchx?= Date: Fri, 30 Oct 2026 23:52:48 +0000 Subject: [PATCH 2/4] build(backend): keep secrets, caches and the local data dir out of the build context --- backend/.dockerignore | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/.dockerignore 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 -- 2.43.0 From 1eebe94beb0ce44fba770d700bc7987e2864386a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenji=20Sato=20=E2=80=94=20PO=20researchx?= Date: Fri, 30 Oct 2026 23:52:48 +0000 Subject: [PATCH 3/4] ci: port the phase-4 gate onto the v0.3 line and extend its pull_request trigger to the milestone branch --- .gitea/workflows/ci.yml | 162 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .gitea/workflows/ci.yml 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 -- 2.43.0 From b964c60e723511d484dab3482b878e4be69f0c28 Mon Sep 17 00:00:00 2001 From: Kenji Sato Date: Sat, 31 Oct 2026 03:42:47 +0000 Subject: [PATCH 4/4] fix(backend): serve the production entrypoint, not the test factory The image's CMD ran researchx.api.app:app - the module-global built by create_app() with no arguments, which serves FakeModelAdapter and cwd-based storage. Hosting it would have shipped fakes on the public URL while /healthz answered 200 (P-3 closure; RESEARCH-v03 S8, PLAN T-4.1). Now: researchx.api.main:app with --proxy-headers, so the TLS front's forwarded headers are honoured. Also pins uid/gid 10001 for the non-root user, so the host-side volume chown in the deploy script is deterministic. --- backend/Dockerfile | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index d864a48..22a451f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,6 +11,14 @@ # 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 @@ -36,8 +44,8 @@ ENV PATH="/app/.venv/bin:$PATH" \ 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 --system researchx \ - && adduser --system --ingroup researchx --no-create-home 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 @@ -47,4 +55,4 @@ EXPOSE 8000 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.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"] -- 2.43.0