diff --git a/.github/actions/deploy-app/action.yml b/.github/actions/deploy-app/action.yml new file mode 100644 index 00000000..f4a47781 --- /dev/null +++ b/.github/actions/deploy-app/action.yml @@ -0,0 +1,60 @@ +name: Deploy Backend App +description: Deploys a Docker image to the target environment and verifies health. + +inputs: + image_tag: + description: Full Docker image tag to deploy + required: true + environment: + description: Target environment (staging | production) + required: true + git_sha: + description: Git SHA being deployed + required: true + deploy_token: + description: Deployment platform token / webhook secret + required: true + app_url: + description: Base URL of the deployed app (for health check) + required: true + +runs: + using: composite + steps: + - name: Trigger deployment + shell: bash + env: + DEPLOY_TOKEN: ${{ inputs.deploy_token }} + run: | + # Replace this curl with your actual deploy platform call + # (Render deploy hook, Railway redeploy, Fly.io deploy, etc.) + # The IMAGE_TAG and GIT_SHA are passed so the platform can pull the right image. + curl -fsSL -X POST "${{ inputs.app_url }}/_deploy" \ + -H "Authorization: Bearer ${DEPLOY_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "image": "${{ inputs.image_tag }}", + "sha": "${{ inputs.git_sha }}", + "environment": "${{ inputs.environment }}" + }' || true # remove `|| true` once real endpoint is wired up + + - name: Wait for health check + shell: bash + run: | + echo "Waiting for ${{ inputs.app_url }}/health ..." + for i in $(seq 1 24); do + STATUS=$(curl -o /dev/null -s -w "%{http_code}" "${{ inputs.app_url }}/health" || echo "000") + if [ "$STATUS" = "200" ]; then + echo "Health check passed (attempt ${i})" + exit 0 + fi + echo "Attempt ${i}/24 — status ${STATUS}, retrying in 10s..." + sleep 10 + done + echo "::error::Health check failed after 4 minutes" + exit 1 + + - name: Log release metadata + shell: bash + run: | + echo "::notice title=Release [${{ inputs.environment }}]::image=${{ inputs.image_tag }} | sha=${{ inputs.git_sha }} | env=${{ inputs.environment }} | ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/.github/actions/run-migrations/action.yml b/.github/actions/run-migrations/action.yml new file mode 100644 index 00000000..0ae6f7c0 --- /dev/null +++ b/.github/actions/run-migrations/action.yml @@ -0,0 +1,52 @@ +name: Run Supabase Migrations +description: Applies pending Supabase migrations exactly once and logs the result. + +inputs: + supabase_url: + description: Supabase project URL + required: true + supabase_service_key: + description: Supabase service-role key + required: true + environment: + description: Target environment (staging | production) + required: true + git_sha: + description: Git SHA being deployed + required: true + +runs: + using: composite + steps: + - name: Install Supabase CLI + shell: bash + run: | + curl -fsSL https://github.com/supabase/cli/releases/latest/download/supabase_linux_amd64.tar.gz \ + | tar -xz -C /usr/local/bin supabase + + - name: Link project + shell: bash + env: + SUPABASE_ACCESS_TOKEN: ${{ inputs.supabase_service_key }} + run: | + supabase link --project-ref "$(echo '${{ inputs.supabase_url }}' | sed 's|https://||;s|\.supabase\.co.*||')" + + - name: Run migrations + id: migrate + shell: bash + env: + SUPABASE_ACCESS_TOKEN: ${{ inputs.supabase_service_key }} + run: | + echo "::group::Migration output" + supabase db push --include-all 2>&1 | tee /tmp/migration.log + echo "::endgroup::" + + STATUS="${PIPESTATUS[0]}" + APPLIED=$(grep -c 'Applying migration' /tmp/migration.log || true) + echo "migrations_applied=${APPLIED}" >> "$GITHUB_OUTPUT" + exit "$STATUS" + + - name: Log migration metadata + shell: bash + run: | + echo "::notice title=Migration [${{ inputs.environment }}]::Applied ${{ steps.migrate.outputs.migrations_applied }} migration(s) | sha=${{ inputs.git_sha }} | env=${{ inputs.environment }} | ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..42ad9b84 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,93 @@ +name: Backend CI + +on: + push: + branches: [main, develop, 'feat/**', 'fix/**'] + paths: + - 'app/backend/**' + - '.github/workflows/backend-ci.yml' + pull_request: + branches: [main, develop] + paths: + - 'app/backend/**' + - '.github/workflows/backend-ci.yml' + +jobs: + ci: + name: Lint, Type-check & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: app/backend + + services: + supabase: + image: supabase/postgres:15.1.0.147 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: quickex_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + NODE_ENV: test + STELLAR_NETWORK: testnet + SUPABASE_URL: http://localhost:54321 + SUPABASE_ANON_KEY: test-anon-key + PORT: 4000 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 +<<<<<<< Updated upstream + with: + version: 9 + run_install: false +======= +>>>>>>> Stashed changes + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Get pnpm store path + id: pnpm-cache + run: echo "store=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.store }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('app/backend/pnpm-lock.yaml') }} + restore-keys: pnpm-store-${{ runner.os }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Type-check + run: pnpm run type-check + + - name: Build + run: pnpm run build + + - name: Unit tests + run: pnpm run test:unit + + - name: Integration tests + run: pnpm run test:int + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ github.sha }} + path: app/backend/coverage/ + retention-days: 7 diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml new file mode 100644 index 00000000..96eab35a --- /dev/null +++ b/.github/workflows/backend-release.yml @@ -0,0 +1,157 @@ +name: Backend Release + +on: + push: + branches: [main] + paths: + - 'app/backend/**' + - '.github/workflows/backend-release.yml' + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + default: 'staging' + type: choice + options: [staging, production] + rollback_sha: + description: 'Git SHA to roll back to (leave empty for normal deploy)' + required: false + +concurrency: + group: backend-release-${{ github.event.inputs.environment || 'staging' }} + cancel-in-progress: false + +jobs: + # ─── 1. Build & publish Docker image ──────────────────────────────────────── + build: + name: Build Image + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + git_sha: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + + - name: Set image metadata + id: meta + run: | + SHORT_SHA="${GITHUB_SHA::8}" + echo "image_tag=ghcr.io/${{ github.repository_owner }}/quickex-backend:${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: app/backend + push: true + tags: | + ${{ steps.meta.outputs.image_tag }} + ghcr.io/${{ github.repository_owner }}/quickex-backend:latest + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + + # ─── 2. Deploy to staging ─────────────────────────────────────────────────── + deploy-staging: + name: Deploy → Staging + needs: build + runs-on: ubuntu-latest + environment: staging + env: + DEPLOY_ENV: staging + IMAGE_TAG: ${{ needs.build.outputs.image_tag }} + GIT_SHA: ${{ needs.build.outputs.git_sha }} + + steps: + - uses: actions/checkout@v4 + + - name: Run migrations (staging) + uses: ./.github/actions/run-migrations + with: + supabase_url: ${{ secrets.STAGING_SUPABASE_URL }} + supabase_service_key: ${{ secrets.STAGING_SUPABASE_SERVICE_KEY }} + environment: staging + git_sha: ${{ env.GIT_SHA }} + + - name: Deploy app (staging) + uses: ./.github/actions/deploy-app + with: + image_tag: ${{ env.IMAGE_TAG }} + environment: staging + git_sha: ${{ env.GIT_SHA }} + deploy_token: ${{ secrets.STAGING_DEPLOY_TOKEN }} + app_url: ${{ secrets.STAGING_APP_URL }} + + # ─── 3. Deploy to production (manual gate) ────────────────────────────────── + deploy-production: + name: Deploy → Production + needs: [build, deploy-staging] + runs-on: ubuntu-latest + environment: production # requires manual approval in GitHub Environments + if: github.event_name == 'push' || github.event.inputs.environment == 'production' + env: + DEPLOY_ENV: production + IMAGE_TAG: ${{ needs.build.outputs.image_tag }} + GIT_SHA: ${{ needs.build.outputs.git_sha }} + + steps: + - uses: actions/checkout@v4 + + - name: Run migrations (production) + uses: ./.github/actions/run-migrations + with: + supabase_url: ${{ secrets.PROD_SUPABASE_URL }} + supabase_service_key: ${{ secrets.PROD_SUPABASE_SERVICE_KEY }} + environment: production + git_sha: ${{ env.GIT_SHA }} + + - name: Deploy app (production) + uses: ./.github/actions/deploy-app + with: + image_tag: ${{ env.IMAGE_TAG }} + environment: production + git_sha: ${{ env.GIT_SHA }} + deploy_token: ${{ secrets.PROD_DEPLOY_TOKEN }} + app_url: ${{ secrets.PROD_APP_URL }} + + # ─── 4. Rollback (manual trigger only) ────────────────────────────────────── + rollback: + name: Rollback + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_sha != '' + environment: ${{ github.event.inputs.environment }} + env: + TARGET_ENV: ${{ github.event.inputs.environment }} + ROLLBACK_SHA: ${{ github.event.inputs.rollback_sha }} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.ROLLBACK_SHA }} + + - name: Resolve rollback image + id: rollback_image + run: | + SHORT_SHA="${ROLLBACK_SHA::8}" + echo "image_tag=ghcr.io/${{ github.repository_owner }}/quickex-backend:${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Deploy previous image + uses: ./.github/actions/deploy-app + with: + image_tag: ${{ steps.rollback_image.outputs.image_tag }} + environment: ${{ env.TARGET_ENV }} + git_sha: ${{ env.ROLLBACK_SHA }} + deploy_token: ${{ env.TARGET_ENV == 'production' && secrets.PROD_DEPLOY_TOKEN || secrets.STAGING_DEPLOY_TOKEN }} + app_url: ${{ env.TARGET_ENV == 'production' && secrets.PROD_APP_URL || secrets.STAGING_APP_URL }} + + - name: Log rollback event + run: | + echo "::notice title=Rollback::Rolled ${{ env.TARGET_ENV }} back to ${{ env.ROLLBACK_SHA }} at $(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 13a8f21b..60173eac 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -1,52 +1,15 @@ -name: Backend CI - +# This workflow has been superseded by: +# .github/workflows/backend-ci.yml (CI: lint, type-check, test) +# .github/workflows/backend-release.yml (Release: build, migrate, deploy) +# +# This file is kept as a placeholder to avoid broken branch protection rules +# that reference the old workflow name. It can be deleted once branch rules +# are updated to reference the new workflow files. +name: Backend CI (deprecated — see backend-ci.yml) on: - push: - branches: [ main, develop ] - paths: - - 'app/backend/**' - pull_request: - branches: [ main, develop ] - paths: - - 'app/backend/**' - + workflow_dispatch: jobs: - build-and-test: - name: Build and Test + noop: runs-on: ubuntu-latest - defaults: - run: - working-directory: app/backend - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Lint - run: pnpm run lint - - - name: Build - run: pnpm run build - - # Tests are run separately to avoid CI failures during development - # Uncomment when tests are stable - # - name: Run Unit Tests - # run: pnpm run test:unit - # env: - # NODE_ENV: test - # STELLAR_NETWORK: testnet - # SUPABASE_URL: https://test.supabase.co - # SUPABASE_ANON_KEY: test-key + - run: echo "Use backend-ci.yml and backend-release.yml instead." diff --git a/app/backend/.dockerignore b/app/backend/.dockerignore new file mode 100644 index 00000000..7e56fba6 --- /dev/null +++ b/app/backend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +coverage +.env +.env.* +!.env.example +*.log +.turbo diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile new file mode 100644 index 00000000..3d7a21a7 --- /dev/null +++ b/app/backend/Dockerfile @@ -0,0 +1,28 @@ +# ── Build stage ────────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app + +RUN npm install -g pnpm@9 + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build + +# ── Production stage ────────────────────────────────────────────────────────── +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN npm install -g pnpm@9 + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +COPY --from=builder /app/dist ./dist + +EXPOSE 4000 + +CMD ["node", "dist/main"]