From 9cc499cce18cb57d9222000953ef4f069883f9cd Mon Sep 17 00:00:00 2001 From: kikiola Date: Fri, 17 Jul 2026 08:15:30 +0100 Subject: [PATCH 1/2] feat: implement staging deployment pipeline with Docker support, automated migrations, and rollback procedures --- .dockerignore | 14 +++++ .github/workflows/deploy-staging.yml | 86 ++++++++++++++++++++++++++++ Dockerfile | 33 +++++++++++ config/staging.env.example | 13 +++++ docker-compose.staging.yml | 39 +++++++++++++ docs/RUNBOOK.md | 53 +++++++++++++++++ scripts/deploy-staging.sh | 53 +++++++++++++++++ scripts/rollback-staging.sh | 38 ++++++++++++ scripts/smoke-test.sh | 12 ++++ 9 files changed, 341 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy-staging.yml create mode 100644 Dockerfile create mode 100644 config/staging.env.example create mode 100644 docker-compose.staging.yml create mode 100644 docs/RUNBOOK.md create mode 100755 scripts/deploy-staging.sh create mode 100755 scripts/rollback-staging.sh create mode 100755 scripts/smoke-test.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c22a159e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore +.git +.gitignore +.env +.env.* +!.env.example +dist +coverage +tests +lint_output.json +*.txt diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..9e77073f --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,86 @@ +name: Deploy Staging + +on: + push: + branches: + - main + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: staging + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 # needed to get previous tag for rollback + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,format=long + + # In a real scenario, this would authenticate to a registry + # For Phase 0, we just build the image locally and tag it with the SHA + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + load: true # load into local docker daemon + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # We simulate the staging deployment locally on the GitHub Runner + # since there's no remote target specified yet. + - name: Deploy to Staging (Runner) + env: + DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL || 'postgresql://learnault:staging_password@localhost:5432/learnault_staging?schema=public' }} + JWT_SECRET: ${{ secrets.STAGING_JWT_SECRET || 'fallback-staging-secret' }} + run: | + echo "Actor: ${{ github.actor }}" + echo "Digest/Tag: ${{ steps.meta.outputs.version }}" + + # We need to run the postgres db as a mock for staging + docker compose -f docker-compose.staging.yml up -d db + sleep 10 # Wait for db to initialize + + # Run deployment + ./scripts/deploy-staging.sh ${{ steps.meta.outputs.version }} + + - name: Run Smoke Tests + run: ./scripts/smoke-test.sh + + - name: Handle Failure & Rollback + if: failure() + run: | + echo "Deployment or Smoke Tests failed. Initiating Rollback..." + + # Get the previous commit SHA to rollback to + PREV_SHA=$(git rev-parse HEAD^) + PREV_TAG="sha-$PREV_SHA" + + echo "Rolling back to tag: $PREV_TAG" + + # Since this is a CI mock, we just run the rollback script + # In reality, this tag would be pulled from the registry + # We'll just build it to simulate + docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV_TAG . + + ./scripts/rollback-staging.sh ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$PREV_TAG diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a192be51 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM node:20-alpine AS base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable +WORKDIR /app + +FROM base AS deps +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN pnpm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json pnpm-lock.yaml ./ +# Install only prod dependencies +RUN pnpm install --prod --frozen-lockfile + +# Copy generated Prisma client and built code +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma ./prisma + +EXPOSE 5000 + +CMD ["npm", "start"] diff --git a/config/staging.env.example b/config/staging.env.example new file mode 100644 index 00000000..ed2a2d49 --- /dev/null +++ b/config/staging.env.example @@ -0,0 +1,13 @@ +# Staging Environment Configuration Contract +# This file documents the required environment variables for the staging deployment. +# Values are injected by the CI/CD pipeline. + +PORT=5000 +NODE_ENV=staging +STELLAR_NETWORK=testnet + +# The connection string for the staging database +DATABASE_URL= + +# Secret for signing JWTs +JWT_SECRET= diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 00000000..350d5e92 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + api: + image: learnault-api:${IMAGE_TAG:-staging} + container_name: learnault-api-staging + restart: unless-stopped + ports: + - "5000:5000" + environment: + - PORT=5000 + - NODE_ENV=${NODE_ENV:-staging} + - STELLAR_NETWORK=${STELLAR_NETWORK:-testnet} + - DATABASE_URL=${DATABASE_URL} + - JWT_SECRET=${JWT_SECRET} + depends_on: + db: + condition: service_healthy + + db: + image: postgres:15-alpine + container_name: learnault-db-staging + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-learnault} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-staging_password} + POSTGRES_DB: ${POSTGRES_DB:-learnault_staging} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-learnault} -d ${POSTGRES_DB:-learnault_staging}"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 00000000..0b792331 --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,53 @@ +# Staging Deployment Runbook + +This document details the processes and configurations for the Learnault API Staging Environment. + +## Overview + +The staging environment is deployed using an immutable Docker image digest. The deployment automates configuration validation, database migrations, readiness checks, smoke tests, and graceful rollbacks. + +## Deployment Pipeline + +1. **Build & Tag:** A new Docker image is built for every commit to `main` using a multi-stage `Dockerfile`. +2. **Predeploy Checks:** The `deploy-staging.sh` script validates the environment configuration (`config/staging.env.example`). +3. **Migrations:** Prisma migrations are applied *before* the application starts using the newly built image (`npx prisma migrate deploy`). +4. **Deploy & Await:** The API container is deployed via Docker Compose and the script polls `/health` until readiness is confirmed. +5. **Smoke Tests:** `smoke-test.sh` runs a suite of safe, read-only API tests (e.g., `/health`) to verify operational sanity. +6. **Rollback:** If any step fails, the `rollback-staging.sh` script is triggered automatically to revert the API container to the *previous* immutable image digest. + +## Migration-Forward Policy + +**CRITICAL: We never rollback the database.** + +In the event of a failed deployment that included a bad database migration: +1. The rollback script *only* reverts the API container to the previous image. +2. Because the schema cannot be safely rolled back in PostgreSQL without risking data loss, **the previous API version must be backward compatible with the new schema**, or the environment will remain broken. +3. If the environment is broken, the engineering team must immediately write a *forward migration* (a new PR) to fix the schema or drop the problematic changes safely. + +### How to apply a fix: +1. Create a new branch. +2. Fix the broken logic or write a new Prisma migration (`npx prisma migrate dev --name fix_schema`). +3. Merge the PR. The pipeline will automatically build a new image, run the new migration, and deploy. + +## Configuration (Secrets Contract) + +See `config/staging.env.example` for the list of required environment variables. These are typically stored in GitHub Secrets and injected during the CI/CD pipeline. + +## Manual Execution + +To rehearse the deployment locally: +```bash +# 1. Build an image +docker build -t learnault-api:test-tag . + +# 2. Deploy +export DATABASE_URL="postgresql://user:pass@localhost:5432/db" +export JWT_SECRET="secret" +./scripts/deploy-staging.sh test-tag + +# 3. Smoke Test +./scripts/smoke-test.sh + +# 4. Rollback (Requires a previously built tag) +./scripts/rollback-staging.sh old-tag +``` diff --git a/scripts/deploy-staging.sh b/scripts/deploy-staging.sh new file mode 100755 index 00000000..756e0d86 --- /dev/null +++ b/scripts/deploy-staging.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -e + +echo "Starting Staging Deployment..." + +# Validate configuration +if [ -z "$DATABASE_URL" ]; then + echo "Error: DATABASE_URL is not set." + exit 1 +fi + +if [ -z "$JWT_SECRET" ]; then + echo "Error: JWT_SECRET is not set." + exit 1 +fi + +IMAGE_TAG=${1:-staging} +echo "Deploying image learnault-api:$IMAGE_TAG" + +# Run safe Prisma migrations +# Use the same image to ensure Prisma CLI matches the schema +echo "Running database migrations..." +docker run --rm \ + --network host \ + -e DATABASE_URL="$DATABASE_URL" \ + learnault-api:$IMAGE_TAG \ + npx prisma migrate deploy + +# Deploy immutable image +echo "Deploying application containers..." +export IMAGE_TAG +docker compose -f docker-compose.staging.yml up -d + +echo "Awaiting readiness..." +# Wait for health endpoint +MAX_RETRIES=15 +RETRY_COUNT=0 +HEALTH_URL="http://localhost:5000/health" + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -s $HEALTH_URL | grep '"status":"ok"'; then + echo "" + echo "Application is ready!" + exit 0 + fi + echo "Waiting for app to be ready... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 + RETRY_COUNT=$((RETRY_COUNT+1)) +done + +echo "Application failed to become ready in time." +docker compose -f docker-compose.staging.yml logs api +exit 1 diff --git a/scripts/rollback-staging.sh b/scripts/rollback-staging.sh new file mode 100755 index 00000000..3d568673 --- /dev/null +++ b/scripts/rollback-staging.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -e + +PREVIOUS_IMAGE_TAG=$1 + +if [ -z "$PREVIOUS_IMAGE_TAG" ]; then + echo "Error: PREVIOUS_IMAGE_TAG must be provided for rollback." + echo "Usage: ./rollback-staging.sh " + exit 1 +fi + +echo "Initiating Rollback to image tag: $PREVIOUS_IMAGE_TAG" +echo "Note: This uses a migration-forward policy. Database is NOT rolled back." + +# Deploy previous image +export IMAGE_TAG=$PREVIOUS_IMAGE_TAG +docker compose -f docker-compose.staging.yml up -d + +echo "Awaiting readiness after rollback..." +# Wait for health endpoint +MAX_RETRIES=15 +RETRY_COUNT=0 +HEALTH_URL="http://localhost:5000/health" + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -s $HEALTH_URL | grep '"status":"ok"'; then + echo "" + echo "Rollback successful. Application is ready!" + exit 0 + fi + echo "Waiting for app to be ready... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 + RETRY_COUNT=$((RETRY_COUNT+1)) +done + +echo "Rollback application failed to become ready in time." +docker compose -f docker-compose.staging.yml logs api +exit 1 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 00000000..48ca2b7a --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +echo "Running Smoke Tests on Staging..." + +API_URL="http://localhost:5000" + +echo "Testing /health endpoint..." +curl -s -f "$API_URL/health" || { echo "Health check failed!"; exit 1; } + +echo "" +echo "Smoke tests passed successfully!" From bfef379fe6b9c6144262ff6cb9e9e8dc33cb96fb Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 18 Jul 2026 06:05:12 +0100 Subject: [PATCH 2/2] chore: remove docker additions to fix conflicts --- .dockerignore | 14 -------------- Dockerfile | 33 -------------------------------- docker-compose.staging.yml | 39 -------------------------------------- 3 files changed, 86 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 docker-compose.staging.yml diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c22a159e..00000000 --- a/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -node_modules -npm-debug.log -Dockerfile -.dockerignore -.git -.gitignore -.env -.env.* -!.env.example -dist -coverage -tests -lint_output.json -*.txt diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a192be51..00000000 --- a/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM node:20-alpine AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -WORKDIR /app - -FROM base AS deps -COPY package.json pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile - -FROM base AS builder -COPY --from=deps /app/node_modules ./node_modules -COPY . . -RUN npx prisma generate -RUN pnpm run build - -FROM base AS runner -WORKDIR /app -ENV NODE_ENV=production - -COPY package.json pnpm-lock.yaml ./ -# Install only prod dependencies -RUN pnpm install --prod --frozen-lockfile - -# Copy generated Prisma client and built code -COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma -COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/prisma ./prisma - -EXPOSE 5000 - -CMD ["npm", "start"] diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml deleted file mode 100644 index 350d5e92..00000000 --- a/docker-compose.staging.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: '3.8' - -services: - api: - image: learnault-api:${IMAGE_TAG:-staging} - container_name: learnault-api-staging - restart: unless-stopped - ports: - - "5000:5000" - environment: - - PORT=5000 - - NODE_ENV=${NODE_ENV:-staging} - - STELLAR_NETWORK=${STELLAR_NETWORK:-testnet} - - DATABASE_URL=${DATABASE_URL} - - JWT_SECRET=${JWT_SECRET} - depends_on: - db: - condition: service_healthy - - db: - image: postgres:15-alpine - container_name: learnault-db-staging - restart: unless-stopped - environment: - POSTGRES_USER: ${POSTGRES_USER:-learnault} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-staging_password} - POSTGRES_DB: ${POSTGRES_DB:-learnault_staging} - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-learnault} -d ${POSTGRES_DB:-learnault_staging}"] - interval: 5s - timeout: 5s - retries: 5 - -volumes: - pgdata: