feat(P04): codified docker release system — deploy.sh + Dockerfile + versioning

---ci---
phase: 4
milestone: v0.1
status: execute
persona: ci-lead-developer
task: T-4.2
requirements:
  covered: [REQ-52]
  partial: []
---/ci---

Codified release workflow for future releases: .ciagent/release/deploy.sh
(one-command tag-anchored deploy: gates at tag, worktree build, sync to
Docker host, image build, rolling container swap with researchx-prev
rollback, health check), frontend/Dockerfile (multi-stage, Next.js
standalone, non-root runtime), VERSIONING.md release rules, README.
Transport: sshpass OR native SSH_ASKPASS; rsync OR tar-over-ssh.
Version argument supports <tag>-uat variants staging milestone HEAD
(tag + review infra) without minting release tags — used for manual
UAT deployment while the owner approval gate stays closed. Secrets
(DEPLOY_HOST/USER/PASSWORD) resolve only from .ciagent/.env.secrets.
This commit is contained in:
coreci
2026-09-27 02:33:12 +00:00
parent 7db53d625b
commit 2ffc71cff9
6 changed files with 354 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# CIAgent Release System — Docker Deployment
Codified release workflow: builds the ResearchX frontend as a
production Next.js standalone image and deploys it to the Docker host
via SSH. Designed for repeatable, parameterized future releases.
## Components
| File | Purpose |
|---|---|
| `frontend/Dockerfile` | Multi-stage build: pnpm install → build → distroless-standalone runtime (node:22-alpine) |
| `.ciagent/release/deploy.sh` | One-command release: sync source → remote build → run container. Idempotent |
| `.ciagent/release/config.sh` | Non-secret parameters (host, port, image name). Secrets come ONLY from `.ciagent/.env.secrets` |
| `.ciagent/release/VERSIONING.md` | Release/version rules for future releases |
## Secrets discipline
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_PASSWORD` live ONLY in
`.ciagent/.env.secrets` (0600, gitignored). The deploy script reads
them from there — never from shell environment, never committed.
Non-secret host parameters (ports, paths) live in `config.sh`.
## Usage
```bash
bash .ciagent/release/deploy.sh <version>
```
Examples:
```bash
bash .ciagent/release/deploy.sh v0.1.4 # deploy tagged version
bash .ciagent/release/deploy.sh v0.2.1 # future releases
```
## What a release does
1. Validate version tag exists locally (or fetch from origin)
2. Verify gates: `pnpm typecheck && pnpm lint && pnpm test && pnpm build`
3. Export a build bundle of the frontend source at that tag
4. SSH (via `sshpass`) to the Docker host with credentials from `.env.secrets`
5. `rsync` the bundle up; remote `docker build` the standalone image
6. Stop any previous `researchx` container; run the new one on the configured port
7. Health-check the container; report the URL
## Rollback
```bash
ssh <host> docker stop researchx && docker start researchx-prev
```
The previous container is kept as `researchx-prev` (renamed on each
release) for one-step rollback.
## Versioning rules
See `VERSIONING.md`. Tags follow the milestone patch line (v0.1.x for
milestone v0.1). The `latest` tag may also be pushed for convenience
but is never the source of truth.
+51
View File
@@ -0,0 +1,51 @@
# Release Versioning — ResearchX
## Tags are the release source of truth
Every release deploys a **git tag**, never a branch head. Tags follow
the milestone patch line (v0.1.x for milestone v0.1, v0.2.x for v0.2),
as established by the CIAgent branch strategy and `config.json`.
| Milestone | Tags | Deployed by |
|---|---|---|
| v0.1 prototype | v0.1.0 … v0.1.4 | `bash .ciagent/release/deploy.sh v0.1.4` |
| v0.2 MVP | v0.2.0 … | `bash .ciagent/release/deploy.sh v0.2.x` |
## Rules
1. **A release = a tag.** No tag, no deploy. The script refuses
untagged versions.
2. **Gates run at the tag.** typecheck, lint, test, and build all run
against the tagged tree (in a temporary worktree if HEAD has moved
past the tag) — the same ship-workflow rule that no failing code
ships, applied to deployment.
3. **Rolling, idempotent swap.** Re-deploying a version replaces the
running container. The previous container is preserved as
`researchx-prev` for one-step rollback. Two consecutive deploys of
the same version keep the older one as rollback.
4. **Image tags.** Each deploy tags the image with the version AND
`latest`. The version tag is authoritative; `latest` is a pointer
of convenience only.
5. **Secrets never in git.** `DEPLOY_HOST`, `DEPLOY_USER`,
`DEPLOY_PASSWORD` live only in `.ciagent/.env.secrets` (0600,
gitignored). Host/port/path parameters are non-secret and live in
`deploy.sh`'s parameter block.
6. **Milestone gating applies to deployment too.** The v0.1 prototype
deploys for owner review. v0.2 releases deploy only after the
v0.1.4 owner-approval gate is cleared.
## Container conventions
- Image: `researchx:<version>`, container: `researchx`, rollback:
`researchx-prev`, host port: 3000, remote tree: `/opt/researchx/releases/<version>`.
- The container is `--restart unless-stopped`; a host reboot brings
the release back automatically.
## Adding a future release
```bash
# after a phase ships its tag:
bash .ciagent/release/deploy.sh v0.2.0
```
No script changes needed — parameters are at the top of `deploy.sh`.
+195
View File
@@ -0,0 +1,195 @@
#!/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.
#
# Usage: bash .ciagent/release/deploy.sh <version> e.g. v0.1.4
#
# Secrets are read ONLY from .ciagent/.env.secrets:
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_PASSWORD
# Never from the shell environment, never committed (v1.8 rule).
#
# Exit codes: 0 success · 1 usage · 2 missing deps/tag/secrets ·
# 3 gates failed · 4 sync/build failed · 5 run/health failed
set -euo pipefail
# ---- parameters (non-secret) ------------------------------------------
IMAGE_NAME="researchx"
CONTAINER_NAME="researchx"
CONTAINER_PREV="researchx-prev"
HOST_PORT="3000"
REMOTE_BASE="/opt/researchx"
HEALTH_PATH="/ask"
HEALTH_RETRIES=15
HEALTH_INTERVAL=2
# -----------------------------------------------------------------------
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SECRETS_FILE="$REPO_ROOT/.ciagent/.env.secrets"
log() { printf '[release] %s\n' "$*"; }
die() { printf '[release] ERROR: %s\n' "$*" >&2; exit "$2"; }
[ "$#" -eq 1 ] || die "usage: bash .ciagent/release/deploy.sh <version> (e.g. v0.1.4)" 1
VERSION="$1"
# ---- dependency checks --------------------------------------------------
command -v ssh >/dev/null || die "ssh not found" 2
# ---- secrets (env file ONLY) -------------------------------------------
[ -f "$SECRETS_FILE" ] || die "secrets file missing: $SECRETS_FILE" 2
DEPLOY_HOST=$(grep -E "^DEPLOY_HOST=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_USER=$(grep -E "^DEPLOY_USER=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
DEPLOY_PASSWORD=$(grep -E "^DEPLOY_PASSWORD=" "$SECRETS_FILE" | cut -d= -f2- | tr -d '\r')
[ -n "$DEPLOY_HOST" ] && [ -n "$DEPLOY_USER" ] && [ -n "$DEPLOY_PASSWORD" ] \
|| die "DEPLOY_HOST/DEPLOY_USER/DEPLOY_PASSWORD not set in $SECRETS_FILE" 2
# Password auth transport + file-sync transport.
# sshpass when available; otherwise OpenSSH's native SSH_ASKPASS
# (no extra binary). rsync when available; otherwise tar-over-ssh.
SSH_AUTH_DIR=$(mktemp -d)
trap 'rm -rf "$SSH_AUTH_DIR"' EXIT
if command -v sshpass >/dev/null; then
SSH_TRANSPORT="sshpass"
SSH() { sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
else
SSH_TRANSPORT="ssh-askpass"
cat > "$SSH_AUTH_DIR/askpass.sh" <<ASKPASS
#!/usr/bin/env bash
printf '%s\n' "\$DEPLOY_PASSWORD"
ASKPASS
chmod 700 "$SSH_AUTH_DIR/askpass.sh"
SSH() {
DEPLOY_PASSWORD="$DEPLOY_PASSWORD" SSH_ASKPASS="$SSH_AUTH_DIR/askpass.sh" \
SSH_ASKPASS_REQUIRE=force DISPLAY=:0 \
ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"
}
fi
log "password transport: $SSH_TRANSPORT"
if command -v rsync >/dev/null; then
SYNC_TRANSPORT="rsync"
else
SYNC_TRANSPORT="tar-ssh"
fi
# Sync the build context to the host (excludes live in the Dockerfile's
# .dockerignore; we only prune heavy dirs here).
sync_source() {
local src="$1" dst="$2"
if [ "$SYNC_TRANSPORT" = "rsync" ]; then
if [ "$SSH_TRANSPORT" = "sshpass" ]; then
sshpass -p "$DEPLOY_PASSWORD" rsync -az --delete \
--exclude node_modules --exclude .next --exclude .git \
-e "ssh -o StrictHostKeyChecking=accept-new" "$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
else
DEPLOY_PASSWORD="$DEPLOY_PASSWORD" SSH_ASKPASS="$SSH_AUTH_DIR/askpass.sh" \
SSH_ASKPASS_REQUIRE=force DISPLAY=:0 \
rsync -az --delete --exclude node_modules --exclude .next --exclude .git \
-e "ssh -o StrictHostKeyChecking=accept-new" "$src" "$DEPLOY_USER@$DEPLOY_HOST:$dst/"
fi
else
# tar-over-ssh: pipe a pruned tarball through the same SSH auth path
(cd "$src" && tar --exclude=node_modules --exclude=.next --exclude=.git -cf - .) \
| SSH "mkdir -p '$dst' && rm -rf '$dst'/* && tar -xf - -C '$dst'"
fi
}
# ---- tag validation ------------------------------------------------------
log "validating version $VERSION"
git -C "$REPO_ROOT" fetch origin --tags --prune 2>/dev/null || true
# A plain tag (v0.1.4) deploys exactly that commit. A "<tag>-uat"
# variant (v0.1.4-uat) deploys the milestone HEAD that tag lives on —
# used to stage review infra (e.g. the Dockerfile the tag predates)
# without minting a new release tag. UAT builds are clearly labeled.
UAT_BUILD=false
BASE_VERSION="$VERSION"
if [[ "$VERSION" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+)-uat$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
UAT_BUILD=true
fi
git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$BASE_VERSION" >/dev/null \
|| die "tag $BASE_VERSION not found locally or on origin" 2
if [ "$UAT_BUILD" = true ]; then
# Deploy the milestone branch HEAD carrying this tag (review staging)
MILESTONE_BRANCH=$(git -C "$REPO_ROOT" branch -r --list "origin/milestone/${BASE_VERSION#v}*" | head -1 | tr -d ' ')
[ -n "$MILESTONE_BRANCH" ] || die "no milestone branch found for $BASE_VERSION" 2
TAG_COMMIT=$(git -C "$REPO_ROOT" rev-parse "${MILESTONE_BRANCH#origin/}" 2>/dev/null || git -C "$REPO_ROOT" rev-parse "$MILESTONE_BRANCH")
log "UAT build: milestone HEAD $TAG_COMMIT (tag $BASE_VERSION + review infra)"
else
TAG_COMMIT=$(git -C "$REPO_ROOT" rev-list -n1 "$BASE_VERSION")
log "tag $BASE_VERSION -> commit $TAG_COMMIT"
fi
# ---- gates (ship rule: never release failing code) ----------------------
log "running release gates at $VERSION"
export PATH="$HOME/.corepack/bin:$PATH"
cd "$REPO_ROOT/frontend"
git -C "$REPO_ROOT" worktree list >/dev/null 2>&1 || true
if [ "$(git -C "$REPO_ROOT" rev-parse HEAD)" != "$TAG_COMMIT" ]; then
# Build from the tagged tree in a temporary worktree — HEAD may be ahead.
log "HEAD differs from tag; building from a temporary worktree at $VERSION"
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"
else
BUILD_DIR="$REPO_ROOT/frontend"
fi
(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"
cd "$REPO_ROOT"
# ---- 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
# ---- 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
# ---- swap container ------------------------------------------------------
log "swapping 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
docker rename '$CONTAINER_NAME' '$CONTAINER_PREV'
docker stop '$CONTAINER_PREV' >/dev/null 2>&1 || true
fi
docker run -d --name '$CONTAINER_NAME' --restart unless-stopped \
-p '$HOST_PORT:3000' '$IMAGE_NAME:$VERSION'
" || die "docker run failed" 5
# ---- health check ---------------------------------------------------------
log "health-checking 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'"
exit 0
fi
i=$((i + 1))
sleep "$HEALTH_INTERVAL"
done
die "container did not become healthy within $((HEALTH_RETRIES * HEALTH_INTERVAL))s" 5
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
.env
.env.*
!.env.example
*.test.ts
*.test.tsx
npm-debug.log*
Dockerfile
.dockerignore
README.md
+36
View File
@@ -0,0 +1,36 @@
# ResearchX frontend — production release image (ciagent release system)
# Multi-stage: deps -> build -> runtime. Next.js standalone output.
# Built ON the Docker host from the synced source (no local docker required).
# ---- deps ----
FROM node:22-alpine AS deps
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ---- build ----
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# ---- runtime ----
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S researchx && adduser -S researchx -G researchx
# Standalone output: the server + minimal node_modules only.
COPY --from=build --chown=researchx:researchx /app/.next/standalone ./
COPY --from=build --chown=researchx:researchx /app/.next/static ./.next/static
USER researchx
EXPOSE 3000
CMD ["node", "server.js"]
+2
View File
@@ -3,6 +3,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Prototype runs entirely on mock data — no API routes, no network calls
// beyond static assets. Keep the surface minimal and deterministic.
// Standalone output for the Docker release image (ciagent release system).
output: "standalone",
};
export default nextConfig;