Skip to content
Merged
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
300 changes: 24 additions & 276 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,37 +1,24 @@
name: Deploy to Production
name: Release

on:
push:
branches: [main]
workflow_dispatch:
inputs:
validate_image:
description: 'Run container validation (normally skipped - PR CI validates)'
required: false
default: false
type: boolean

# Ensure only one deployment runs at a time
concurrency:
group: production-deploy
group: release
cancel-in-progress: false

env:
AWS_REGION: us-east-1
ECR_REPOSITORY: genealogy-frontend

jobs:
# Semantic release FIRST - creates version, changelog, and GitHub release
release:
name: Semantic Release
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
outputs:
new_version: ${{ steps.get-version.outputs.version }}
released: ${{ steps.get-version.outputs.released }}
sha: ${{ steps.get-version.outputs.sha }}
version: ${{ steps.get-version.outputs.version }}

steps:
- name: Checkout
Expand All @@ -50,277 +37,38 @@ jobs:
run: npm ci

- name: Run semantic-release
id: semantic
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: npx semantic-release

- name: Get release info
id: get-version
run: |
# Fetch any new tags/commits created by semantic-release
git fetch --tags
git fetch origin main
# Get the latest commit SHA (may be a new release commit)
LATEST_SHA=$(git rev-parse origin/main)
# Get the latest semantic version tag
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
VERSION=${LATEST_TAG#v}
echo "sha=$LATEST_SHA" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "released=true" >> $GITHUB_OUTPUT
echo "Using version: $VERSION (sha: $LATEST_SHA)"

build-and-deploy:
runs-on: ubuntu-latest
needs: release
permissions:
id-token: write
contents: read

steps:
- name: Checkout latest code (includes release commit)
uses: actions/checkout@v4
with:
# Use the SHA from release job - this includes the version bump commit
ref: ${{ needs.release.outputs.sha || github.sha }}
fetch-depth: 0

- name: Setup Node.js with cache
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}

- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log version from package.json
run: |
echo "Building with version from package.json:"
node -p "require('./package.json').version"

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
build-args: |
NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }}
cache-from: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:buildcache
cache-to: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:buildcache,mode=max
provenance: false
sbom: false

- name: Validate Docker image
if: ${{ github.event.inputs.validate_image == 'true' }}
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
echo "Starting container for health check validation..."
docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
CONTAINER_ID=$(docker run -d -p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@localhost:5432/test" \
-e NEXTAUTH_URL="http://localhost:3000" \
-e NEXTAUTH_SECRET="test-secret-for-validation" \
$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG)

echo "Waiting for container to start..."
sleep 10

# Check if container is still running
if ! docker ps -q --filter "id=$CONTAINER_ID" | grep -q .; then
echo "Container exited unexpectedly!"
docker logs $CONTAINER_ID
exit 1
fi

echo "Testing health endpoint..."
HEALTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/health || echo "000")
docker stop $CONTAINER_ID > /dev/null && docker rm $CONTAINER_ID > /dev/null

if [ "$HEALTH_STATUS" = "000" ]; then
echo "Health check failed - container not responding"
exit 1
fi
echo "Image validation passed (health endpoint returned $HEALTH_STATUS)"

- name: Deploy to App Runner
run: |
# Check if service exists
SERVICE_ARN=$(aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='genealogy-frontend'].ServiceArn" --output text)

if [ -z "$SERVICE_ARN" ]; then
echo "Creating new App Runner service WITHOUT VPC connector first..."
aws apprunner create-service \
--service-name genealogy-frontend \
--source-configuration '{
"AuthenticationConfiguration": {
"AccessRoleArn": "${{ secrets.APPRUNNER_ECR_ROLE_ARN }}"
},
"ImageRepository": {
"ImageIdentifier": "${{ steps.login-ecr.outputs.registry }}/genealogy-frontend:latest",
"ImageRepositoryType": "ECR",
"ImageConfiguration": {
"Port": "3000",
"RuntimeEnvironmentVariables": {
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
"NEXTAUTH_URL": "${{ secrets.NEXTAUTH_URL }}",
"NEXTAUTH_SECRET": "${{ secrets.NEXTAUTH_SECRET }}",
"GOOGLE_CLIENT_ID": "${{ secrets.GOOGLE_CLIENT_ID }}",
"GOOGLE_CLIENT_SECRET": "${{ secrets.GOOGLE_CLIENT_SECRET }}",
"AUTH_TRUST_HOST": "true"
}
}
}
}' \
--instance-configuration '{
"Cpu": "1024",
"Memory": "2048",
"InstanceRoleArn": "${{ secrets.APPRUNNER_INSTANCE_ROLE_ARN }}"
}' \
--health-check-configuration '{
"Protocol": "HTTP",
"Path": "/api/health",
"Interval": 10,
"Timeout": 5,
"HealthyThreshold": 1,
"UnhealthyThreshold": 5
}' \
--tags Key=Project,Value=genealogy Key=Environment,Value=production
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -n "$LATEST_TAG" ]]; then
echo "released=true" >> $GITHUB_OUTPUT
echo "version=${LATEST_TAG}" >> $GITHUB_OUTPUT
echo "New release: $LATEST_TAG"
else
echo "Updating service with new image and ensuring env vars are set..."
MAX_ATTEMPTS=120 # 10 minutes max (120 x 5s)
DEPLOYED=false

for i in $(seq 1 $MAX_ATTEMPTS); do
STATUS=$(aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='genealogy-frontend'].Status" --output text)
echo "Attempt $i/$MAX_ATTEMPTS - Service status: $STATUS"

if [ "$STATUS" = "RUNNING" ]; then
echo "Service is RUNNING, updating service configuration..."
# Use update-service instead of start-deployment to ensure env vars are always set
aws apprunner update-service \
--service-arn "$SERVICE_ARN" \
--source-configuration '{
"AuthenticationConfiguration": {
"AccessRoleArn": "${{ secrets.APPRUNNER_ECR_ROLE_ARN }}"
},
"ImageRepository": {
"ImageIdentifier": "${{ steps.login-ecr.outputs.registry }}/genealogy-frontend:latest",
"ImageRepositoryType": "ECR",
"ImageConfiguration": {
"Port": "3000",
"RuntimeEnvironmentVariables": {
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
"NEXTAUTH_URL": "${{ secrets.NEXTAUTH_URL }}",
"NEXTAUTH_SECRET": "${{ secrets.NEXTAUTH_SECRET }}",
"GOOGLE_CLIENT_ID": "${{ secrets.GOOGLE_CLIENT_ID }}",
"GOOGLE_CLIENT_SECRET": "${{ secrets.GOOGLE_CLIENT_SECRET }}",
"AUTH_TRUST_HOST": "true"
}
}
}
}' && DEPLOYED=true && break
echo "Update failed, service may have changed state. Retrying..."
fi

sleep 5
done

if [ "$DEPLOYED" != "true" ]; then
echo "Error: Could not update service after $MAX_ATTEMPTS attempts"
exit 1
fi
echo "released=false" >> $GITHUB_OUTPUT
echo "version=" >> $GITHUB_OUTPUT
echo "No new release"
fi

- name: Wait for deployment to complete
run: |
SERVICE_ARN=$(aws apprunner list-services --query "ServiceSummaryList[?ServiceName=='genealogy-frontend'].ServiceArn" --output text)
echo "Waiting for deployment to complete..."

MAX_WAIT=120 # 10 minutes max (120 x 5s)
for i in $(seq 1 $MAX_WAIT); do
STATUS=$(aws apprunner describe-service --service-arn "$SERVICE_ARN" --query 'Service.Status' --output text)
echo "[$i/$MAX_WAIT] Service status: $STATUS"

if [ "$STATUS" = "RUNNING" ]; then
echo "✅ Deployment completed successfully!"

# Verify health endpoint
SERVICE_URL=$(aws apprunner describe-service --service-arn "$SERVICE_ARN" --query 'Service.ServiceUrl' --output text)
echo "Verifying health at https://$SERVICE_URL/api/health"
HEALTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$SERVICE_URL/api/health" || echo "000")

if [ "$HEALTH_STATUS" = "200" ]; then
echo "✅ Health check passed!"
exit 0
else
echo "⚠️ Health check returned $HEALTH_STATUS (may still be warming up)"
exit 0 # Don't fail - App Runner health checks will handle this
fi
elif [ "$STATUS" = "OPERATION_IN_PROGRESS" ]; then
echo "Deployment in progress..."
elif [ "$STATUS" = "CREATE_FAILED" ] || [ "$STATUS" = "DELETE_FAILED" ]; then
echo "❌ Deployment failed with status: $STATUS"
exit 1
fi

sleep 5
done

echo "❌ Timeout waiting for deployment to complete"
exit 1

# Cleanup runs as a separate job to not block deployment
cleanup:
dispatch-deploy:
name: Trigger Deploy
runs-on: ubuntu-latest
needs: build-and-deploy
if: success()
permissions:
id-token: write
contents: read
needs: release
if: needs.release.outputs.released == 'true'

steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1

- name: Cleanup old ECR images
- name: Dispatch to kindred-infra
run: |
echo "Cleaning up old ECR images (keeping last 10)..."
IMAGES_TO_DELETE=$(aws ecr describe-images \
--repository-name genealogy-frontend \
--query 'sort_by(imageDetails,&imagePushedAt)[:-10].imageDigest' \
--output text)

if [ -z "$IMAGES_TO_DELETE" ] || [ "$IMAGES_TO_DELETE" = "None" ]; then
echo "No old images to delete"
else
for DIGEST in $IMAGES_TO_DELETE; do
echo "Deleting image: $DIGEST"
aws ecr batch-delete-image \
--repository-name genealogy-frontend \
--image-ids imageDigest=$DIGEST \
|| echo "Failed to delete $DIGEST (may be in use)"
done
echo "Cleanup complete"
fi
echo "Triggering deploy of ${{ needs.release.outputs.version }} in kindred-infra"
curl -sf -X POST \
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/pmilano1/kindred-infra/dispatches" \
-d "{\"event_type\":\"deploy\",\"client_payload\":{\"tag\":\"${{ needs.release.outputs.version }}\",\"sha\":\"${{ github.sha }}\"}}"
echo "Deploy dispatched for ${{ needs.release.outputs.version }}"
Loading