diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 65a228d..8ce607e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -8,70 +8,107 @@ env: AWS_REGION: eu-central-1 S3_BUCKET: filamenteka-frontend 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_MATOMO_URL: https://analytics.demirix.dev NEXT_PUBLIC_MATOMO_SITE_ID: "7" jobs: - deploy: + # ── Job 1: Change Detection ────────────────────────────────────────── + detect: runs-on: ubuntu-latest + outputs: + frontend: ${{ steps.changes.outputs.frontend }} + api: ${{ steps.changes.outputs.api }} + migrations: ${{ steps.changes.outputs.migrations }} steps: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 0 - - name: Detect changes + - name: Detect changes since last deploy id: changes run: | - FRONTEND_CHANGED=false - API_CHANGED=false + # Find the latest deploy tag + LAST_TAG=$(git tag -l 'deploy-*' --sort=-creatordate | head -n 1) - if git diff --name-only HEAD~1 HEAD | grep -qvE '^api/'; then - FRONTEND_CHANGED=true + if [ -z "$LAST_TAG" ]; then + 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 - if git diff --name-only HEAD~1 HEAD | grep -qE '^api/'; then - API_CHANGED=true + echo "Last deploy tag: $LAST_TAG" + 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 - echo "frontend=$FRONTEND_CHANGED" >> $GITHUB_OUTPUT - echo "api=$API_CHANGED" >> $GITHUB_OUTPUT - echo "Frontend changed: $FRONTEND_CHANGED" - echo "API changed: $API_CHANGED" + echo "frontend=$FRONTEND" >> $GITHUB_OUTPUT + echo "api=$API" >> $GITHUB_OUTPUT + echo "migrations=$MIGRATIONS" >> $GITHUB_OUTPUT + 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 - if: steps.changes.outputs.frontend == 'true' uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies - if: steps.changes.outputs.frontend == 'true' run: npm ci - name: Security check & tests - if: steps.changes.outputs.frontend == 'true' run: | npm run security:check npm test -- --passWithNoTests - name: Build Next.js - if: steps.changes.outputs.frontend == 'true' 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 - if: steps.changes.outputs.frontend == 'true' || steps.changes.outputs.api == 'true' 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 eu-central-1 + aws configure set region ${{ env.AWS_REGION }} - name: Deploy to S3 & invalidate CloudFront - if: steps.changes.outputs.frontend == 'true' run: | + # HTML files — no cache aws s3 sync out/ s3://$S3_BUCKET/ \ --delete \ --exclude "*" \ @@ -79,9 +116,11 @@ jobs: --cache-control "public, max-age=0, must-revalidate" \ --content-type "text/html" + # Next.js assets — immutable aws s3 sync out/_next/ s3://$S3_BUCKET/_next/ \ --cache-control "public, max-age=31536000, immutable" + # Everything else — 24h cache aws s3 sync out/ s3://$S3_BUCKET/ \ --exclude "*.html" \ --exclude "_next/*" \ @@ -91,20 +130,168 @@ jobs: --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ --paths "/*" - # ── API Deploy ─────────────────────────────────────────────────── - - name: Deploy API via SSM - if: steps.changes.outputs.api == 'true' + # ── Job 3: API & Migrations Deploy ────────────────────────────────── + deploy-api: + runs-on: ubuntu-latest + needs: detect + if: needs.detect.outputs.api == 'true' || needs.detect.outputs.migrations == 'true' + steps: + - name: Setup AWS 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 \ --region $AWS_REGION \ --instance-ids "$INSTANCE_ID" \ --document-name "AWS-RunShellScript" \ --parameters 'commands=[ - "cd /home/ubuntu/filamenteka-api", - "cp server.js server.js.backup", - "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" + "cd '"$API_DIR"'", + "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" ]' \ --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" diff --git a/api/server.js b/api/server.js index d66e16a..fbc6786 100644 --- a/api/server.js +++ b/api/server.js @@ -1,7 +1,6 @@ const express = require('express'); const { Pool } = require('pg'); const cors = require('cors'); -// API v1.1 - sales, customers, analytics const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); require('dotenv').config();