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:
@@ -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.
|
||||
@@ -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`.
|
||||
Executable
+195
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user