14 Commits

Author SHA1 Message Date
jonathan 669584cdb4 Merge pull request 'Phase 4 slice 3 - hosted build profile + SSE-safe HAProxy front template + deploy wiring (REQ-84/85 legs)' (#34) from po/phase4-host-live into milestone/v0.3-hosted
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m18s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m12s
Reviewed-on: #34
2026-10-31 21:24:23 +00:00
coreci-researchx-po 36d428f569 phase4 deploy: hosted build args for the frontend image + /api/config 401 auth smoke (T-4.3/T-4.7(1) host leg)
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m21s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m15s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m17s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 2m14s
2026-10-31 20:44:15 +00:00
coreci-researchx-po 7598b1707c phase4 HAProxy front config as documentation (T-4.5, RESEARCH-v03 s9; G-104 rate limit template; D-019 notes)
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m18s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m11s
2026-10-31 20:44:10 +00:00
coreci-researchx-po ce9d1baecf phase4 host-live build profile: ARG/ENV NEXT_PUBLIC_* + hosted build assert (T-4.3, RESEARCH-v03 s10)
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m18s
2026-10-31 20:44:09 +00:00
jonathan c31b61be7f Merge pull request 'Phase 4 slice 2: two-service release deploy (backend + frontend) on the v0.3 line' (#33) from po/phase4-deploy-two-service into milestone/v0.3-hosted
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m20s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m8s
Reviewed-on: #33
2026-10-31 08:28:27 +00:00
jonathan 79d83bd188 Merge branch 'milestone/v0.3-hosted' into po/phase4-deploy-two-service
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m12s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m15s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 2m8s
2026-10-31 07:36:11 +00:00
jonathan ad404b6659 Merge pull request 'Phase 4 slice 1: backend release image on the v0.3 line (single worker, non-root) + the phase-4 gate ported onto it' (#32) from po/phase4-backend-image into milestone/v0.3-hosted
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m14s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m6s
Reviewed-on: #32
2026-10-31 07:35:48 +00:00
coreci-researchx-po 690925c6d1 ci: port the phase-4 gate onto the two-service branch so the deploy script is gated before it merges
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m18s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 2m9s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m15s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 2m10s
2026-10-31 07:15:11 +00:00
coreci-researchx-po b964c60e72 fix(backend): serve the production entrypoint, not the test factory
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m58s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 1m13s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m59s
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.
2026-10-31 03:42:47 +00:00
coreci-researchx-po 593be870eb fix(release): own the backend data dir for the non-root container user (phase 4 slice 2, REQ-83) 2026-10-31 03:00:22 +00:00
coreci-researchx-po 8afbf1cd83 release: deploy two services (backend + frontend) - phase 4 slice 2 (REQ-83) 2026-10-31 01:17:32 +00:00
coreci-researchx-po 1eebe94beb ci: port the phase-4 gate onto the v0.3 line and extend its pull_request trigger to the milestone branch
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 59s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m37s
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (pull_request) Successful in 56s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (pull_request) Successful in 1m59s
2026-10-30 23:52:48 +00:00
coreci-researchx-po 6c85ad2824 build(backend): keep secrets, caches and the local data dir out of the build context
ci / backend (uv, Python 3.12: ruff/mypy/pytest) (push) Successful in 57s
ci / frontend (Next.js on pnpm: typecheck/lint/test/build) (push) Successful in 1m40s
2026-10-30 23:52:48 +00:00
coreci-researchx-po b72f16a944 build(backend): the release image for the v0.3 line - single worker, non-root, data dir + /healthz (REQ-83) 2026-10-30 23:52:42 +00:00
6 changed files with 410 additions and 20 deletions
+97 -20
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# ResearchX release deploy (ciagent release system)
#
# Builds the frontend at a given version tag on the Docker host and
# runs it as a container. Idempotent: re-deploying a version replaces
# the running container, keeping the previous one as rollback.
# Builds the frontend and the backend at a given version tag on the
# Docker host and runs them as two containers. Idempotent: re-deploying
# a version replaces the running containers, keeping the previous ones
# as rollback.
#
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
#
@@ -21,8 +22,14 @@ IMAGE_NAME="researchx"
CONTAINER_NAME="researchx"
CONTAINER_PREV="researchx-prev"
HOST_PORT="3000"
IMAGE_BACKEND="researchx-backend"
CONTAINER_BACKEND="researchx-backend"
CONTAINER_BACKEND_PREV="researchx-backend-prev"
HOST_PORT_BACKEND="8000"
BACKEND_DATA_DIR="/var/lib/researchx"
REMOTE_BASE="/opt/researchx"
HEALTH_PATH="/ask"
BACKEND_HEALTH_PATH="/healthz"
HEALTH_RETRIES=15
HEALTH_INTERVAL=2
# -----------------------------------------------------------------------
@@ -38,6 +45,7 @@ VERSION="$1"
# ---- dependency checks --------------------------------------------------
command -v ssh >/dev/null || die "ssh not found" 2
command -v uv >/dev/null || die "uv not found (backend gates need it: pip install uv)" 2
# ---- secrets (env file ONLY) -------------------------------------------
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
@@ -156,37 +164,75 @@ if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
WORKTREE=$(mktemp -d)
git -C "$REPO_ROOT" worktree add --detach "$WORKTREE" "$TAG_COMMIT" >/dev/null
trap 'git -C "$REPO_ROOT" worktree remove --force "$WORKTREE" 2>/dev/null || true' EXIT
BUILD_DIR="$WORKTREE/frontend"
BUILD_ROOT="$WORKTREE"
else
BUILD_DIR="$REPO_ROOT/frontend"
BUILD_ROOT="$REPO_ROOT"
fi
BUILD_DIR="$BUILD_ROOT/frontend"
BACKEND_DIR="$BUILD_ROOT/backend"
(cd "$BUILD_DIR" && pnpm install --frozen-lockfile >/dev/null 2>&1)
(cd "$BUILD_DIR" && pnpm typecheck >/dev/null) || die "typecheck failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm lint >/dev/null) || die "lint failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm test >/dev/null 2>&1) || die "tests failed at $VERSION" 3
(cd "$BUILD_DIR" && pnpm build >/dev/null 2>&1) || die "build failed at $VERSION" 3
log "gates passed: typecheck, lint, test, build"
log "frontend gates passed: typecheck, lint, test, build"
cd "$REPO_ROOT"
# Backend gates: the repo's own commands, same as the CI backend job
# (.gitea/workflows/ci.yml), run from the committed lockfile.
(cd "$BACKEND_DIR" && uv sync --frozen >/dev/null 2>&1) || die "backend dependency sync failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run ruff check src tests >/dev/null) || die "backend ruff failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run mypy src >/dev/null) || die "backend mypy failed at $VERSION" 3
(cd "$BACKEND_DIR" && uv run pytest >/dev/null) || die "backend pytest failed at $VERSION" 3
log "backend gates passed: ruff, mypy, pytest"
# ---- remote host prep ----------------------------------------------------
log "connecting to $DEPLOY_USER@${DEPLOY_HOST%%:*} and preparing $REMOTE_BASE"
SSH "mkdir -p '$REMOTE_BASE/releases/$BASE_VERSION'" || die "cannot reach host or create remote dir" 4
SSH "command -v docker >/dev/null" || die "docker not found on host" 4
# ---- sync source ---------------------------------------------------------
log "syncing source to host ($REMOTE_BASE/releases/$BASE_VERSION) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$REMOTE_BASE/releases/$BASE_VERSION" \
|| die "source sync failed" 4
RELEASE_DIR="$REMOTE_BASE/releases/$BASE_VERSION"
log "syncing source to host ($RELEASE_DIR) via $SYNC_TRANSPORT"
sync_source "$BUILD_DIR" "$RELEASE_DIR/frontend" || die "frontend source sync failed" 4
sync_source "$BACKEND_DIR" "$RELEASE_DIR/backend" || die "backend source sync failed" 4
# ---- remote build --------------------------------------------------------
log "building docker image $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$REMOTE_BASE/releases/$BASE_VERSION' && \
docker build -t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|| die "docker build failed on host" 4
log "building docker images $IMAGE_BACKEND:$VERSION and $IMAGE_NAME:$VERSION on host (this takes a few minutes)"
SSH "cd '$RELEASE_DIR/backend' && \
docker build -t '$IMAGE_BACKEND:$VERSION' -t '$IMAGE_BACKEND:latest' ." \
|| die "backend docker build failed on host" 4
SSH "cd '$RELEASE_DIR/frontend' && \
docker build \
--build-arg NEXT_PUBLIC_RESEARCHX_API_MODE=live \
--build-arg NEXT_PUBLIC_API_BASE_URL=\"\" \
-t '$IMAGE_NAME:$VERSION' -t '$IMAGE_NAME:latest' ." \
|| die "frontend docker build failed on host" 4
# ---- swap container ------------------------------------------------------
log "swapping container (previous kept as $CONTAINER_PREV for rollback)"
log "swapping backend container (previous kept as $CONTAINER_BACKEND_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_BACKEND_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_BACKEND'; then
docker rename '$CONTAINER_BACKEND' '$CONTAINER_BACKEND_PREV'
docker stop '$CONTAINER_BACKEND_PREV' >/dev/null 2>&1 || true
fi
mkdir -p '$BACKEND_DATA_DIR'
# The container runs as the image's non-root user and a bind mount
# SHADOWS the image's own (correctly owned) /var/lib/researchx, so a
# root-owned host dir makes every write to the data root (users.json,
# sessions.json, keys/) fail with EACCES while /healthz still answers
# 200. Take the owner from the image itself.
chown -R \$(docker run --rm --entrypoint stat '$IMAGE_BACKEND:$VERSION' -c '%u:%g' /var/lib/researchx) '$BACKEND_DATA_DIR'
docker run -d --name '$CONTAINER_BACKEND' --restart unless-stopped \
-p '$HOST_PORT_BACKEND:8000' \
-v '$BACKEND_DATA_DIR:$BACKEND_DATA_DIR' \
-e RESEARCHX_DATA_DIR='$BACKEND_DATA_DIR' \
'$IMAGE_BACKEND:$VERSION'
" || die "backend docker run failed" 5
log "swapping frontend container (previous kept as $CONTAINER_PREV for rollback)"
SSH "
docker rm -f '$CONTAINER_PREV' 2>/dev/null || true
if docker ps -a --format '{{.Names}}' | grep -qx '$CONTAINER_NAME'; then
@@ -195,19 +241,50 @@ SSH "
fi
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
" || die "docker run failed" 5
" || die "frontend docker run failed" 5
# ---- health check ---------------------------------------------------------
log "health-checking http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
log "health-checking backend http://$DEPLOY_HOST:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH'"; then
log "backend healthy after $((i + 1)) check(s)"
break
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
[ "$i" -lt "$HEALTH_RETRIES" ] || die "backend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
# The health check above is a GET: it proves the app answers, not that it
# can persist. Probe the write path the auth/key stores use, so a
# mis-owned data root fails the deploy instead of the first signup.
log "checking the backend can write its data root"
SSH "docker exec '$CONTAINER_BACKEND' sh -c 'touch \"\$RESEARCHX_DATA_DIR/.write-probe\" && rm -f \"\$RESEARCHX_DATA_DIR/.write-probe\"'" \
|| die "backend data root is not writable by the container user" 5
# Auth smoke (RESEARCH-v03.md section 10 pitfall; PLAN.md T-4.7(1) host-side
# leg): the auth-guarded surface answers 401 unauthenticated. This proves
# the guarded surface is up through the deployed backend -- it does NOT
# discriminate the serving entrypoint (both create_app shapes default
# auth_enabled=True; the entrypoint is pinned by the Dockerfile CMD + the
# build-stage assert). The live-mode tier marker (an authenticated
# /api/config) rides the staged smoke suite, owner directive (D-019).
log "checking GET /api/config answers 401 unauthenticated"
AUTH_CODE=$(SSH "curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:$HOST_PORT_BACKEND/api/config'")
[ "$AUTH_CODE" = "401" ] || die "GET /api/config answered '$AUTH_CODE' unauthenticated, expected 401" 5
log "health-checking frontend http://$DEPLOY_HOST:$HOST_PORT$HEALTH_PATH (retries: $HEALTH_RETRIES)"
i=0
while [ "$i" -lt "$HEALTH_RETRIES" ]; do
if SSH "curl -sf -o /dev/null --max-time 5 'http://127.0.0.1:$HOST_PORT$HEALTH_PATH'"; then
log "healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (container $CONTAINER_NAME)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker start $CONTAINER_NAME'"
log "frontend healthy after $((i + 1)) check(s)"
log "RELEASED: $VERSION — http://${DEPLOY_HOST%%:*}:$HOST_PORT (containers $CONTAINER_NAME, $CONTAINER_BACKEND)"
log "backend: http://${DEPLOY_HOST%%:*}:$HOST_PORT_BACKEND$BACKEND_HEALTH_PATH (container $CONTAINER_BACKEND)"
log "rollback: ssh $DEPLOY_USER@${DEPLOY_HOST%%:*} 'docker stop $CONTAINER_NAME $CONTAINER_BACKEND; docker rename $CONTAINER_PREV $CONTAINER_NAME; docker rename $CONTAINER_BACKEND_PREV $CONTAINER_BACKEND; docker start $CONTAINER_NAME $CONTAINER_BACKEND'"
exit 0
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
die "frontend container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
+59
View File
@@ -0,0 +1,59 @@
# research.coreci.dev — HAProxy front config for phase 4 (REQ-84, G-104)
#
# DOCUMENTATION + idempotent template (PLAN.md T-4.5, RESEARCH-v03.md §9):
# the host already runs HAProxy 2.8 with the TLS frontend for
# research.coreci.dev. This file is the reviewed shape to fold into that
# front. It is applied to the live host ONLY on owner deploy directive
# (D-019) — never autonomously from a deploy run. The host's live config
# is not readable from the authoring seat, so `timeout client` /
# `defaults` values below are stated as requirements, not as the
# host's current numbers.
# ---- requirements the host's defaults must already satisfy ----------------
# timeout client 3600s # MUST exceed the 15s sse-starlette ping
# # interval (RESEARCH-v03.md §9) or idle SSE
# # streams are cut mid-run.
# keep-alive default ON # keep http-keep-alive; the SSE stream rides it.
# ---- frontend routes -------------------------------------------------------
frontend https-in
acl is_api path_beg /api
acl is_health path /healthz
use_backend backend_api if is_api or is_health
default_backend frontend_next
# G-104 per-IP login rate limit (PLAN.md T-4.5 scope): stick-table on
# /api/auth/*. Threshold below is a STARTING VALUE for the
# security-engineer review, not a measured traffic figure — rate
# before applying. 30 auth requests / 60s / IP.
acl is_auth path_beg /api/auth
stick-table type ip size 100k expire 60s store http_req_rate(60s)
http-request track-sc0 src if is_auth
http-request deny deny_status 429 if is_auth { sc_http_req_rate(0) gt 30 }
# ---- backend: the API container --------------------------------------------
# SSE hazards (RESEARCH-v03.md §9): HAProxy 2.x does NOT buffer response
# bodies in HTTP mode by default, so text/event-stream streams natively.
# The three things that would break it are deliberately absent here:
# - NO `option http-buffer-response` on this backend
# - NO compression (gzip) on this backend — gzip buffers
# text/event-stream; the "classic" silent SSE killer
# - keep the sse-starlette ping (15s) untouched
backend backend_api
option httpchk GET /healthz
default-server inter 5s fall 3 rise 2
server api1 127.0.0.1:8000 check
timeout server 3600s
timeout tunnel 3600s
# ---- backend: the Next.js container ----------------------------------------
backend frontend_next
timeout server 300s
server next1 127.0.0.1:3000 check
# ---- health contract -------------------------------------------------------
# GET /healthz -> 200 {"status":"ok"} (public router, outside /api per
# D-103; the only unauthenticated route — T-4.8).
# The API container must publish on 127.0.0.1:8000 (loopback only: the
# public surface is the TLS front, so the backend is never directly
# reachable from outside the host).
+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"]
+17
View File
@@ -14,6 +14,23 @@ FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Hosted build profile (PLAN.md T-4.3, RESEARCH-v03.md section 10): Next.js
# inlines NEXT_PUBLIC_* at BUILD time, so the profile is ARG/ENV here --
# never runtime flags. live mode + EMPTY base ("" = same-origin through the
# front proxy's /api path route): a non-empty base would break the
# same-origin session cookie behind the TLS front.
ARG NEXT_PUBLIC_RESEARCHX_API_MODE=live
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_RESEARCHX_API_MODE="${NEXT_PUBLIC_RESEARCHX_API_MODE}" \
NEXT_PUBLIC_API_BASE_URL="${NEXT_PUBLIC_API_BASE_URL}"
# Build assert: the hosted profile is hardwired -- mode=live, empty base --
# or the build fails before a wrong image can be produced.
RUN [ "$NEXT_PUBLIC_RESEARCHX_API_MODE" = "live" ] \
&& [ -z "$NEXT_PUBLIC_API_BASE_URL" ] \
|| { echo "hosted build profile violated: mode=$NEXT_PUBLIC_RESEARCHX_API_MODE" >&2; exit 1; }
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build