Rewrite CI/CD pipeline with deploy-tag change detection and verified deployments
Replace HEAD~1 diff with git tag-based change detection to catch all changes since last successful deploy. Split into 4 parallel jobs: detect, frontend, API (with migrations, health check, and rollback), and deploy tagging.
This commit is contained in:
@@ -8,70 +8,107 @@ env:
|
|||||||
AWS_REGION: eu-central-1
|
AWS_REGION: eu-central-1
|
||||||
S3_BUCKET: filamenteka-frontend
|
S3_BUCKET: filamenteka-frontend
|
||||||
INSTANCE_ID: i-03956ecf32292d7d9
|
INSTANCE_ID: i-03956ecf32292d7d9
|
||||||
|
API_DIR: /home/ubuntu/filamenteka-api
|
||||||
|
GITEA_RAW: https://git.demirix.dev/dax/Filamenteka/raw/branch/main
|
||||||
NEXT_PUBLIC_API_URL: https://api.filamenteka.rs/api
|
NEXT_PUBLIC_API_URL: https://api.filamenteka.rs/api
|
||||||
NEXT_PUBLIC_MATOMO_URL: https://analytics.demirix.dev
|
NEXT_PUBLIC_MATOMO_URL: https://analytics.demirix.dev
|
||||||
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
|
NEXT_PUBLIC_MATOMO_SITE_ID: "7"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
# ── Job 1: Change Detection ──────────────────────────────────────────
|
||||||
|
detect:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
frontend: ${{ steps.changes.outputs.frontend }}
|
||||||
|
api: ${{ steps.changes.outputs.api }}
|
||||||
|
migrations: ${{ steps.changes.outputs.migrations }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Detect changes
|
- name: Detect changes since last deploy
|
||||||
id: changes
|
id: changes
|
||||||
run: |
|
run: |
|
||||||
FRONTEND_CHANGED=false
|
# Find the latest deploy tag
|
||||||
API_CHANGED=false
|
LAST_TAG=$(git tag -l 'deploy-*' --sort=-creatordate | head -n 1)
|
||||||
|
|
||||||
if git diff --name-only HEAD~1 HEAD | grep -qvE '^api/'; then
|
if [ -z "$LAST_TAG" ]; then
|
||||||
FRONTEND_CHANGED=true
|
echo "No deploy tag found — deploying everything"
|
||||||
|
echo "frontend=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "api=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "migrations=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if git diff --name-only HEAD~1 HEAD | grep -qE '^api/'; then
|
echo "Last deploy tag: $LAST_TAG"
|
||||||
API_CHANGED=true
|
DIFF_FILES=$(git diff --name-only "$LAST_TAG"..HEAD)
|
||||||
|
echo "Changed files since $LAST_TAG:"
|
||||||
|
echo "$DIFF_FILES"
|
||||||
|
|
||||||
|
FRONTEND=false
|
||||||
|
API=false
|
||||||
|
MIGRATIONS=false
|
||||||
|
|
||||||
|
if echo "$DIFF_FILES" | grep -qvE '^(api/|database/)'; then
|
||||||
|
FRONTEND=true
|
||||||
|
fi
|
||||||
|
if echo "$DIFF_FILES" | grep -qE '^api/'; then
|
||||||
|
API=true
|
||||||
|
fi
|
||||||
|
if echo "$DIFF_FILES" | grep -qE '^database/migrations/'; then
|
||||||
|
MIGRATIONS=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "frontend=$FRONTEND_CHANGED" >> $GITHUB_OUTPUT
|
echo "frontend=$FRONTEND" >> $GITHUB_OUTPUT
|
||||||
echo "api=$API_CHANGED" >> $GITHUB_OUTPUT
|
echo "api=$API" >> $GITHUB_OUTPUT
|
||||||
echo "Frontend changed: $FRONTEND_CHANGED"
|
echo "migrations=$MIGRATIONS" >> $GITHUB_OUTPUT
|
||||||
echo "API changed: $API_CHANGED"
|
echo "Frontend: $FRONTEND | API: $API | Migrations: $MIGRATIONS"
|
||||||
|
|
||||||
|
# ── Job 2: Frontend Deploy ───────────────────────────────────────────
|
||||||
|
deploy-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.frontend == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# ── Frontend Deploy ──────────────────────────────────────────────
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Security check & tests
|
- name: Security check & tests
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: |
|
run: |
|
||||||
npm run security:check
|
npm run security:check
|
||||||
npm test -- --passWithNoTests
|
npm test -- --passWithNoTests
|
||||||
|
|
||||||
- name: Build Next.js
|
- name: Build Next.js
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Verify build output
|
||||||
|
run: |
|
||||||
|
if [ ! -d "out" ]; then
|
||||||
|
echo "ERROR: out/ directory not found after build"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Build output verified: $(find out -type f | wc -l) files"
|
||||||
|
|
||||||
- name: Setup AWS
|
- name: Setup AWS
|
||||||
if: steps.changes.outputs.frontend == 'true' || steps.changes.outputs.api == 'true'
|
|
||||||
run: |
|
run: |
|
||||||
pip3 install -q --break-system-packages awscli
|
pip3 install -q --break-system-packages awscli
|
||||||
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
||||||
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
||||||
aws configure set region eu-central-1
|
aws configure set region ${{ env.AWS_REGION }}
|
||||||
|
|
||||||
- name: Deploy to S3 & invalidate CloudFront
|
- name: Deploy to S3 & invalidate CloudFront
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
|
||||||
run: |
|
run: |
|
||||||
|
# HTML files — no cache
|
||||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
--delete \
|
--delete \
|
||||||
--exclude "*" \
|
--exclude "*" \
|
||||||
@@ -79,9 +116,11 @@ jobs:
|
|||||||
--cache-control "public, max-age=0, must-revalidate" \
|
--cache-control "public, max-age=0, must-revalidate" \
|
||||||
--content-type "text/html"
|
--content-type "text/html"
|
||||||
|
|
||||||
|
# Next.js assets — immutable
|
||||||
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \
|
||||||
--cache-control "public, max-age=31536000, immutable"
|
--cache-control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Everything else — 24h cache
|
||||||
aws s3 sync out/ s3://$S3_BUCKET/ \
|
aws s3 sync out/ s3://$S3_BUCKET/ \
|
||||||
--exclude "*.html" \
|
--exclude "*.html" \
|
||||||
--exclude "_next/*" \
|
--exclude "_next/*" \
|
||||||
@@ -91,20 +130,168 @@ jobs:
|
|||||||
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
|
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
|
||||||
--paths "/*"
|
--paths "/*"
|
||||||
|
|
||||||
# ── API Deploy ───────────────────────────────────────────────────
|
# ── Job 3: API & Migrations Deploy ──────────────────────────────────
|
||||||
- name: Deploy API via SSM
|
deploy-api:
|
||||||
if: steps.changes.outputs.api == 'true'
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect
|
||||||
|
if: needs.detect.outputs.api == 'true' || needs.detect.outputs.migrations == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Setup AWS
|
||||||
run: |
|
run: |
|
||||||
|
pip3 install -q --break-system-packages awscli
|
||||||
|
aws configure set aws_access_key_id "${{ secrets.AWS_ACCESS_KEY_ID }}"
|
||||||
|
aws configure set aws_secret_access_key "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
|
||||||
|
aws configure set region ${{ env.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
if: needs.detect.outputs.migrations == 'true'
|
||||||
|
run: |
|
||||||
|
CMD_ID=$(aws ssm send-command \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--instance-ids "$INSTANCE_ID" \
|
||||||
|
--document-name "AWS-RunShellScript" \
|
||||||
|
--parameters 'commands=[
|
||||||
|
"set -e",
|
||||||
|
"mkdir -p '"$API_DIR"'/database/migrations",
|
||||||
|
"cd '"$API_DIR"'",
|
||||||
|
"curl -sf -o database/schema.sql '"$GITEA_RAW"'/database/schema.sql",
|
||||||
|
"curl -sf -o api/migrate.js '"$GITEA_RAW"'/api/migrate.js",
|
||||||
|
"for f in $(curl -sf '"https://git.demirix.dev/api/v1/repos/dax/Filamenteka/contents/database/migrations"' | python3 -c \"import sys,json; [print(x[\\\"name\\\"]) for x in json.load(sys.stdin) if x[\\\"name\\\"].endswith(\\\".sql\\\")]\"); do curl -sf -o database/migrations/$f '"$GITEA_RAW"'/database/migrations/$f; done",
|
||||||
|
"echo Migration files downloaded:",
|
||||||
|
"ls -la database/migrations/",
|
||||||
|
"cd api && node migrate.js"
|
||||||
|
]' \
|
||||||
|
--output json \
|
||||||
|
--query "Command.CommandId" --output text)
|
||||||
|
|
||||||
|
echo "Migration SSM command: $CMD_ID"
|
||||||
|
|
||||||
|
# Poll for completion (max 2 minutes)
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
STATUS=$(aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||||
|
|
||||||
|
echo "Attempt $i: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "Success" ]; then
|
||||||
|
echo "Migrations completed successfully"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardOutputContent" --output text
|
||||||
|
break
|
||||||
|
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||||
|
echo "Migration failed with status: $STATUS"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardErrorContent" --output text
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "Success" ]; then
|
||||||
|
echo "Migration timed out after 2 minutes"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy server.js
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
CMD_ID=$(aws ssm send-command \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--instance-ids "$INSTANCE_ID" \
|
||||||
|
--document-name "AWS-RunShellScript" \
|
||||||
|
--parameters 'commands=[
|
||||||
|
"set -e",
|
||||||
|
"cd '"$API_DIR"'",
|
||||||
|
"cp server.js server.js.backup",
|
||||||
|
"curl -sf -o server.js '"$GITEA_RAW"'/api/server.js",
|
||||||
|
"sudo systemctl restart node-api",
|
||||||
|
"echo API deployed and restarted"
|
||||||
|
]' \
|
||||||
|
--output json \
|
||||||
|
--query "Command.CommandId" --output text)
|
||||||
|
|
||||||
|
echo "Deploy SSM command: $CMD_ID"
|
||||||
|
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
sleep 5
|
||||||
|
STATUS=$(aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "Status" --output text 2>/dev/null || echo "Pending")
|
||||||
|
|
||||||
|
echo "Attempt $i: $STATUS"
|
||||||
|
|
||||||
|
if [ "$STATUS" = "Success" ]; then
|
||||||
|
echo "API deploy completed"
|
||||||
|
break
|
||||||
|
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Cancelled" ] || [ "$STATUS" = "TimedOut" ]; then
|
||||||
|
echo "API deploy failed with status: $STATUS"
|
||||||
|
aws ssm get-command-invocation \
|
||||||
|
--command-id "$CMD_ID" \
|
||||||
|
--instance-id "$INSTANCE_ID" \
|
||||||
|
--query "StandardErrorContent" --output text
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$STATUS" != "Success" ]; then
|
||||||
|
echo "API deploy timed out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
id: health
|
||||||
|
run: |
|
||||||
|
echo "Waiting for API to be ready..."
|
||||||
|
for i in $(seq 1 6); do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api.filamenteka.rs/api/filaments || echo "000")
|
||||||
|
echo "Health check attempt $i: HTTP $HTTP_CODE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "API is healthy"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Health check failed after 6 attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Rollback on failure
|
||||||
|
if: failure() && steps.deploy.outcome == 'success'
|
||||||
|
run: |
|
||||||
|
echo "Rolling back to server.js.backup..."
|
||||||
aws ssm send-command \
|
aws ssm send-command \
|
||||||
--region $AWS_REGION \
|
--region $AWS_REGION \
|
||||||
--instance-ids "$INSTANCE_ID" \
|
--instance-ids "$INSTANCE_ID" \
|
||||||
--document-name "AWS-RunShellScript" \
|
--document-name "AWS-RunShellScript" \
|
||||||
--parameters 'commands=[
|
--parameters 'commands=[
|
||||||
"cd /home/ubuntu/filamenteka-api",
|
"cd '"$API_DIR"'",
|
||||||
"cp server.js server.js.backup",
|
"if [ -f server.js.backup ]; then cp server.js.backup server.js && sudo systemctl restart node-api && echo Rollback complete; else echo No backup found; fi"
|
||||||
"curl -o server.js https://git.demirix.dev/dax/Filamenteka/raw/branch/main/api/server.js",
|
|
||||||
"sudo systemctl restart node-api",
|
|
||||||
"sudo systemctl status node-api"
|
|
||||||
]' \
|
]' \
|
||||||
--output json
|
--output json
|
||||||
echo "API deploy command sent via SSM"
|
echo "Rollback command sent"
|
||||||
|
|
||||||
|
# ── Job 4: Tag Successful Deploy ────────────────────────────────────
|
||||||
|
tag-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect, deploy-frontend, deploy-api]
|
||||||
|
if: always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled')
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Create deploy tag
|
||||||
|
run: |
|
||||||
|
TAG="deploy-$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
git tag "$TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
echo "Tagged successful deploy: $TAG"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
// API v1.1 - sales, customers, analytics
|
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|||||||
Reference in New Issue
Block a user