Phase 4 slice 1: backend release image on the v0.3 line (single worker, non-root) + the phase-4 gate ported onto it #32

Merged
jonathan merged 4 commits from po/phase4-backend-image into milestone/v0.3-hosted 2026-10-31 07:35:49 +00:00
3 changed files with 237 additions and 0 deletions
+162
View File
@@ -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
+17
View File
@@ -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
+58
View File
@@ -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"]