Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions config/staging.env.example
Original file line number Diff line number Diff line change
@@ -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=
53 changes: 53 additions & 0 deletions docs/RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -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
```
53 changes: 53 additions & 0 deletions scripts/deploy-staging.sh
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions scripts/rollback-staging.sh
Original file line number Diff line number Diff line change
@@ -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 <previous_tag>"
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
12 changes: 12 additions & 0 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
@@ -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!"
Loading