From 2f67a762a5178b40f7fdf97429c5d827073e427e Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Thu, 28 May 2026 21:13:19 +0100 Subject: [PATCH 1/4] feat(be): add release pipeline for backend (BE-30) - Add backend-ci.yml: lint, type-check, unit + integration tests - Add backend-release.yml: build Docker image, run migrations, deploy staging, deploy production (with manual approval gate), rollback job - Add composite actions: run-migrations and deploy-app - Add multi-stage Dockerfile and .dockerignore for backend - Deprecate old backend.yml stub Closes #416 --- .github/actions/deploy-app/action.yml | 60 +++++++++ .github/actions/run-migrations/action.yml | 52 +++++++ .github/workflows/backend-ci.yml | 81 +++++++++++ .github/workflows/backend-release.yml | 157 ++++++++++++++++++++++ .github/workflows/backend.yml | 59 ++------ app/backend/.dockerignore | 8 ++ app/backend/Dockerfile | 28 ++++ 7 files changed, 397 insertions(+), 48 deletions(-) create mode 100644 .github/actions/deploy-app/action.yml create mode 100644 .github/actions/run-migrations/action.yml create mode 100644 .github/workflows/backend-ci.yml create mode 100644 .github/workflows/backend-release.yml create mode 100644 app/backend/.dockerignore create mode 100644 app/backend/Dockerfile 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..85adf2e3 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,81 @@ +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: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: app/backend/pnpm-lock.yaml + + - uses: pnpm/action-setup@v4 + with: + run_install: false + + - 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"] From f94c751892564921e63604a74599291ddf32eba2 Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Thu, 28 May 2026 23:22:10 +0100 Subject: [PATCH 2/4] fix(ci): move pnpm setup before setup-node to fix cache error --- .github/workflows/backend-ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 85adf2e3..82ce42f9 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -44,16 +44,16 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + - uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm' cache-dependency-path: app/backend/pnpm-lock.yaml - - uses: pnpm/action-setup@v4 - with: - run_install: false - - name: Install dependencies run: pnpm install --frozen-lockfile From f7686f3711c0a908e9f4db3b25cbd9b19590e9f0 Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Fri, 29 May 2026 11:16:29 +0100 Subject: [PATCH 3/4] fix(ci): resolve pnpm cache path error in backend CI workflow --- .github/workflows/backend-ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 82ce42f9..aef56b4f 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -46,13 +46,22 @@ jobs: - uses: pnpm/action-setup@v4 with: + version: 9 run_install: false - uses: actions/setup-node@v4 with: node-version: '20' - cache: 'pnpm' - cache-dependency-path: app/backend/pnpm-lock.yaml + + - 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 From bf8a61448e9551d14fd66df29c22295c16ee799f Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Sat, 30 May 2026 18:17:57 +0100 Subject: [PATCH 4/4] fixed workflow --- .github/workflows/backend-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index aef56b4f..42ad9b84 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -45,9 +45,12 @@ jobs: - 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: