تخطى إلى المحتوى
·29 دقيقة قراءة

GitHub Actions CI/CD: عمليات نشر بدون توقف تعمل فعلاً

إعداد GitHub Actions الكامل: وظائف اختبار متوازية، وتخزين بناء Docker المؤقت، ونشر SSH إلى VPS، وصفر توقف مع PM2 reload، وإدارة الأسرار، وأنماط سير العمل التي صقلتها على مدار سنتين.

مشاركة:X / TwitterLinkedIn

كل مشروع عملت عليه يصل في النهاية إلى نفس نقطة التحول: عملية النشر تصبح مؤلمة جداً لفعلها يدوياً. تنسى تشغيل الاختبارات. تبني محلياً لكن تنسى رفع الإصدار. تدخل SSH إلى الإنتاج وتدرك أن آخر شخص نشر ترك ملف .env قديماً.

GitHub Actions حلّت لي هذا قبل سنتين. ليس بشكل مثالي من اليوم الأول — أول سير عمل كتبته كان كابوس YAML من 200 سطر ينتهي وقته نصف الوقت ولا يخزن أي شيء مؤقتاً. لكن تكراراً بعد تكرار، وصلت إلى شيء ينشر هذا الموقع بشكل موثوق، بدون أي توقف، في أقل من أربع دقائق.

هذا هو سير العمل ذاك، مشروحاً قسماً بقسم. ليس نسخة التوثيق. النسخة التي تصمد أمام الاتصال بالإنتاج.

فهم اللبنات الأساسية#

قبل أن ندخل في خط الأنابيب الكامل، تحتاج نموذجاً ذهنياً واضحاً لكيفية عمل GitHub Actions. إذا استخدمت Jenkins أو CircleCI، انسَ معظم ما تعرفه. المفاهيم تتطابق بشكل فضفاض، لكن نموذج التنفيذ مختلف بما يكفي ليوقعك في الأخطاء.

المحفزات: متى يعمل سير العمل#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Every Monday at 6 AM UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

أربعة محفزات، كل واحد يخدم غرضاً مختلفاً:

  • push إلى main هو محفز نشر الإنتاج. تم دمج الكود؟ اشحنه.
  • pull_request يشغّل فحوصات CI على كل طلب سحب. هنا تعيش أدوات التدقيق وفحص الأنواع والاختبارات.
  • schedule هو cron لمستودعك. أستخدمه لفحوصات التبعيات الأسبوعية وتنظيف التخزين المؤقت القديم.
  • workflow_dispatch يعطيك زر "نشر" يدوي في واجهة GitHub مع معلمات إدخال. لا غنى عنه عندما تحتاج لنشر staging بدون تغيير في الكود — ربما حدّثت متغير بيئة أو تحتاج لإعادة سحب صورة Docker أساسية.

شيء يوقع الناس: pull_request يعمل ضد commit الدمج، وليس HEAD فرع طلب السحب. هذا يعني أن CI يختبر كيف سيبدو الكود بعد الدمج. هذا فعلاً ما تريده، لكنه يفاجئ الناس عندما يتحول فرع أخضر إلى أحمر بعد rebase.

الوظائف والخطوات والمُشغّلات#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

الوظائف تعمل بالتوازي بشكل افتراضي. كل وظيفة تحصل على جهاز افتراضي جديد ("المُشغّل"). ubuntu-latest يعطيك جهازاً قوياً بشكل معقول — 4 وحدات معالجة افتراضية، 16 جيجابايت ذاكرة اعتباراً من 2026. هذا مجاني للمستودعات العامة، 2000 دقيقة/شهر للخاصة.

الخطوات تعمل بالتسلسل داخل الوظيفة. كل خطوة uses: تسحب إجراءً قابلاً لإعادة الاستخدام من السوق. كل خطوة run: تنفذ أمر shell.

علامة --frozen-lockfile حاسمة. بدونها، قد يحدّث pnpm install ملف القفل أثناء CI، مما يعني أنك لا تختبر نفس التبعيات التي عمل لها المطور commit. رأيت هذا يسبب فشل اختبارات وهمية تختفي محلياً لأن ملف القفل على جهاز المطور صحيح بالفعل.

متغيرات البيئة مقابل الأسرار#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

متغيرات البيئة المُعينة بـ env: على مستوى سير العمل هي نص عادي، مرئية في السجلات. استخدمها للإعدادات غير الحساسة: NODE_ENV، أعلام القياس، تبديلات الميزات.

الأسرار (${{ secrets.X }}) مشفرة أثناء الراحة، مخفية في السجلات، ومتاحة فقط لسير العمل في نفس المستودع. يتم تعيينها في Settings > Secrets and variables > Actions.

سطر environment: production مهم. بيئات GitHub تتيح لك تحديد نطاق الأسرار لأهداف نشر محددة. مفتاح SSH الخاص بـ staging ومفتاح SSH الخاص بالإنتاج يمكن أن يكون اسمهما SSH_PRIVATE_KEY لكن بقيم مختلفة حسب البيئة التي تستهدفها الوظيفة. هذا أيضاً يفتح المراجعين المطلوبين — يمكنك تقييد نشر الإنتاج خلف موافقة يدوية.

خط أنابيب CI الكامل#

إليك كيف أهيكل نصف CI من خط الأنابيب. الهدف: التقاط كل فئة من الأخطاء في أسرع وقت ممكن.

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

لماذا هذه الهيكلة#

التدقيق وفحص الأنواع والاختبار تعمل بالتوازي. ليس لها تبعيات على بعضها. خطأ في الأنواع لا يمنع التدقيق من العمل، واختبار فاشل لا يحتاج لانتظار فاحص الأنواع. في تشغيل نموذجي، الثلاثة تكتمل في 30-60 ثانية أثناء العمل في نفس الوقت.

البناء ينتظر الثلاثة. سطر needs: [lint, typecheck, test] يعني أن وظيفة البناء تبدأ فقط إذا نجح التدقيق وفحص الأنواع والاختبار جميعها. لا فائدة من بناء مشروع به أخطاء تدقيق أو فشل في الأنواع.

concurrency مع cancel-in-progress: true موفر كبير للوقت. إذا دفعت commit اثنين بسرعة، يتم إلغاء أول تشغيل CI. بدون هذا، ستكون لديك تشغيلات قديمة تستهلك ميزانية دقائقك وتزدحم واجهة الفحوصات.

رفع التغطية مع if: always() يعني أنك تحصل على تقرير التغطية حتى عند فشل الاختبارات. هذا مفيد للتصحيح — يمكنك رؤية أي اختبارات فشلت وما غطتها.

الفشل السريع مقابل تركها جميعاً تعمل#

بشكل افتراضي، إذا فشلت وظيفة واحدة في مصفوفة، يلغي GitHub الباقي. لـ CI، أريد هذا السلوك فعلاً — إذا فشل التدقيق، لا أهتم بنتائج الاختبار. أصلح التدقيق أولاً.

لكن لمصفوفات الاختبار (مثلاً، الاختبار عبر Node 20 وNode 22)، قد تريد رؤية جميع الفشل مرة واحدة:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false يترك كلا ساقي المصفوفة يكتملان. إذا فشل Node 22 لكن نجح Node 20، ترى تلك المعلومات فوراً بدلاً من الاضطرار لإعادة التشغيل.

التخزين المؤقت للسرعة#

أكبر تحسين يمكنك فعله لسرعة CI هو التخزين المؤقت. pnpm install بارد على مشروع متوسط يستغرق 30-45 ثانية. مع تخزين مؤقت دافئ، يستغرق 3-5 ثوانٍ. اضرب ذلك في أربع وظائف متوازية وتوفر دقيقتين في كل تشغيل.

تخزين مؤقت لمخزن pnpm#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

هذا السطر الواحد يخزن مؤقتاً مخزن pnpm (~/.local/share/pnpm/store). عند إصابة التخزين المؤقت، pnpm install --frozen-lockfile فقط يعمل روابط صلبة من المخزن بدلاً من التنزيل. هذا وحده يقطع وقت التثبيت بنسبة 80% في التشغيلات المتكررة.

إذا كنت بحاجة لمزيد من التحكم — مثلاً، تريد التخزين المؤقت بناءً على نظام التشغيل أيضاً — استخدم actions/cache مباشرة:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

بديل restore-keys مهم. إذا تغير pnpm-lock.yaml (تبعية جديدة)، المفتاح الدقيق لن يتطابق، لكن تطابق البادئة سيستعيد معظم الحزم المخزنة مؤقتاً. فقط الفرق يتم تنزيله.

تخزين بناء Next.js المؤقت#

لدى Next.js تخزين بناء مؤقت خاص في .next/cache. تخزينه مؤقتاً بين التشغيلات يعني بناءات تدريجية — فقط الصفحات والمكونات المتغيرة يتم إعادة تجميعها.

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

استراتيجية المفتاح ثلاثية المستويات تعني:

  1. تطابق دقيق: نفس التبعيات ونفس ملفات المصدر. إصابة تخزين مؤقت كاملة، البناء شبه فوري.
  2. تطابق جزئي (التبعيات): التبعيات نفسها لكن المصدر تغير. البناء يعيد تجميع الملفات المتغيرة فقط.
  3. تطابق جزئي (نظام التشغيل فقط): التبعيات تغيرت. البناء يعيد استخدام ما يمكنه.

أرقام حقيقية من مشروعي: البناء البارد يستغرق ~55 ثانية، البناء المخزن مؤقتاً يستغرق ~15 ثانية. هذا تخفيض بنسبة 73%.

تخزين طبقات Docker المؤقت#

بناءات Docker هي حيث يصبح التخزين المؤقت مؤثراً حقاً. بناء Docker كامل لـ Next.js — تثبيت تبعيات نظام التشغيل، نسخ المصدر، تشغيل pnpm install، تشغيل next build — يستغرق 3-4 دقائق بارداً. مع تخزين الطبقات مؤقتاً، 30-60 ثانية.

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha يستخدم واجهة التخزين المؤقت المدمجة في GitHub Actions. mode=max يخزن مؤقتاً جميع الطبقات، وليس فقط النهائية. هذا حاسم لبناءات متعددة المراحل حيث الطبقات الوسيطة (مثل pnpm install) هي الأكثر تكلفة لإعادة بنائها.

تخزين Turborepo البعيد المؤقت#

إذا كنت في monorepo مع Turborepo، التخزين المؤقت البعيد تحويلي. أول بناء يرفع مخرجات المهام إلى التخزين المؤقت. البناءات اللاحقة تنزّل بدلاً من إعادة الحساب.

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

رأيت أوقات CI لـ monorepo تنخفض من 8 دقائق إلى 90 ثانية مع تخزين Turbo البعيد المؤقت. الشرط: يتطلب حساب Vercel أو خادم Turbo مستضاف ذاتياً. للمستودعات ذات التطبيق الواحد، مبالغة.

بناء Docker والدفع#

إذا كنت تنشر إلى VPS (أو أي خادم)، Docker يعطيك بناءات قابلة للتكرار. نفس الصورة التي تعمل في CI هي نفس الصورة التي تعمل في الإنتاج. لا مزيد من "تعمل على جهازي" لأن الجهاز هو الصورة.

Dockerfile متعدد المراحل#

قبل أن نصل إلى سير العمل، إليك ملف Dockerfile الذي أستخدمه لـ Next.js:

dockerfile
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Stage 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

ثلاث مراحل، فصل واضح. الصورة النهائية ~150 ميجابايت بدلاً من ~1.2 جيجابايت التي ستحصل عليها بنسخ كل شيء. فقط مخرجات الإنتاج تصل إلى مرحلة المُشغّل.

سير عمل البناء والدفع#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

دعني أفكك القرارات المهمة هنا.

سجل حاويات GitHub (ghcr.io)#

أستخدم ghcr.io بدلاً من Docker Hub لثلاثة أسباب:

  1. المصادقة مجانية. GITHUB_TOKEN متاح تلقائياً في كل سير عمل — لا حاجة لتخزين بيانات اعتماد Docker Hub.
  2. القرب. يتم سحب الصور من نفس البنية التحتية التي يعمل عليها CI. السحب أثناء CI سريع.
  3. الوضوح. الصور مرتبطة بمستودعك في واجهة GitHub. تراها في تبويب Packages.

بناءات متعددة المنصات#

yaml
platforms: linux/amd64,linux/arm64

هذا السطر يضيف ربما 90 ثانية لبنائك، لكنه يستحق. صور ARM64 تعمل أصلياً على:

  • أجهزة Mac بمعالج Apple Silicon (M1/M2/M3/M4) أثناء التطوير المحلي مع Docker Desktop
  • حالات AWS Graviton (أرخص بـ 20-40% من مكافئات x86)
  • الطبقة المجانية لـ ARM في Oracle Cloud

بدون هذا، مطوروك على أجهزة Mac من سلسلة M يشغلون صور x86 عبر محاكاة Rosetta. تعمل، لكنها أبطأ بشكل ملحوظ وأحياناً تكشف أخطاء خاصة بالبنية المعمارية.

QEMU يوفر طبقة التجميع المتقاطع. Buildx ينسق البناء متعدد البنيات ويدفع قائمة manifest حتى يسحب Docker البنية الصحيحة تلقائياً.

استراتيجية الوسوم#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

كل صورة تحصل على ثلاثة وسوم:

  • abc1234 (هاش الـ commit): ثابت. يمكنك دائماً نشر commit بعينه.
  • main (اسم الفرع): متغير. يشير إلى آخر بناء من ذلك الفرع.
  • latest: متغير. يُعين فقط على الفرع الافتراضي. هذا ما يسحبه خادمك.

لا تنشر latest في الإنتاج أبداً بدون تسجيل الـ SHA في مكان ما أيضاً. عندما يتعطل شيء، تحتاج معرفة أي latest. أخزن الـ SHA المنشور في ملف على الخادم تقرأه نقطة نهاية الصحة.

نشر SSH إلى VPS#

هنا يجتمع كل شيء. CI نجح، صورة Docker بُنيت ودُفعت، الآن نحتاج لإخبار الخادم بسحب الصورة الجديدة وإعادة التشغيل.

إجراء SSH#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # سحب أحدث صورة
          docker pull "$IMAGE"
 
          # إيقاف وإزالة الحاوية القديمة
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # بدء حاوية جديدة
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # انتظار فحص الصحة
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # تسجيل الـ SHA المنشور
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # تنظيف الصور القديمة
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

بديل سكريبت النشر#

لأي شيء أبعد من سحب-وإعادة-تشغيل بسيط، أنقل المنطق إلى سكريبت على الخادم بدلاً من تضمينه في سير العمل:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# تسجيل الدخول إلى GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# سحب مع إعادة المحاولة
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# دالة فحص الصحة
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# بدء حاوية جديدة على منفذ بديل
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# التحقق من صحة الحاوية الجديدة
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# تبديل upstream في Nginx
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# إيقاف الحاوية القديمة
docker stop akousa-app || true
docker rm akousa-app || true
 
# إعادة تسمية الحاوية الجديدة
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

سير العمل يصبح أمر SSH واحد:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

هذا أفضل لأن: (1) منطق النشر مُتحكم بالإصدارات على الخادم، (2) يمكنك تشغيله يدوياً عبر SSH للتصحيح، و(3) لا تحتاج لعمل escape لـ YAML داخل YAML داخل bash.

استراتيجيات بدون توقف#

"بدون توقف" تبدو مثل كلام تسويقي، لكن لها معنى دقيق: لا طلب يحصل على رفض اتصال أو 502 أثناء النشر. إليك ثلاثة مناهج حقيقية، من الأبسط إلى الأكثر متانة.

الاستراتيجية 1: إعادة تحميل وضع المجموعة في PM2#

إذا كنت تشغل Node.js مباشرة (ليس في Docker)، وضع المجموعة في PM2 يعطيك أسهل مسار بدون توقف.

bash
# ecosystem.config.js already has:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload (وليس restart) يعمل إعادة تشغيل متدرجة. يشغّل عمّالاً جدداً، ينتظر حتى يكونوا جاهزين، ثم يقتل العمّال القدامى واحداً تلو الآخر. لا يوجد في أي لحظة صفر عمّال يخدمون حركة المرور.

علامة --update-env تعيد تحميل متغيرات البيئة من إعداد النظام البيئي. بدونها، تبقى البيئة القديمة حتى بعد نشر غيّر .env.

في سير العمل:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

هذا ما أستخدمه لهذا الموقع. بسيط، موثوق، والتوقف حرفياً صفر — اختبرته بمولد حمل يشغل 100 طلب/ثانية أثناء النشر. لا خطأ 5xx واحد.

الاستراتيجية 2: أزرق/أخضر مع Nginx Upstream#

للنشر بـ Docker، أزرق/أخضر يعطيك فصلاً نظيفاً بين النسخة القديمة والجديدة.

المفهوم: شغّل الحاوية القديمة ("الأزرق") على المنفذ 3000 والحاوية الجديدة ("الأخضر") على المنفذ 3001. Nginx يشير إلى الأزرق. تبدأ الأخضر، تتحقق من صحته، تبدّل Nginx إلى الأخضر، ثم توقف الأزرق.

إعداد upstream في Nginx:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

سكريبت التبديل:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# بدء حاوية جديدة على المنفذ البديل
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# انتظار الصحة
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# تبديل Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# إيقاف الحاوية القديمة
sleep 5  # ترك الطلبات الجارية تكتمل
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

الـ sleep لمدة 5 ثوانٍ بعد إعادة تحميل Nginx ليس كسلاً — إنه وقت سماح. إعادة تحميل Nginx لطيفة (الاتصالات الحالية تُبقى مفتوحة)، لكن بعض اتصالات الاستطلاع الطويل أو الاستجابات المتدفقة تحتاج وقتاً للاكتمال.

الاستراتيجية 3: Docker Compose مع فحوصات الصحة#

لمنهج أكثر هيكلة، Docker Compose يمكنه إدارة تبديل أزرق/أخضر:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

سطر order: start-first هو المفتاح. يعني "ابدأ الحاوية الجديدة قبل إيقاف القديمة." مع parallelism: 1، تحصل على تحديث متدرج — حاوية واحدة في كل مرة، مع الحفاظ دائماً على السعة.

النشر بـ:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose يراقب فحص الصحة ولن يوجه حركة المرور إلى الحاوية الجديدة حتى تنجح. إذا فشل فحص الصحة، failure_action: rollback يعود تلقائياً إلى النسخة السابقة. هذا أقرب ما يكون لنشر Kubernetes المتدرج على VPS واحد.

إدارة الأسرار#

إدارة الأسرار واحدة من تلك الأشياء التي من السهل فعلها "بشكل صحيح تقريباً" وبشكل كارثي خاطئ في الحالات الحدية المتبقية.

أسرار GitHub: الأساسيات#

yaml
# Set via GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # القيمة مخفية في السجلات
      echo "Connecting to database..."
      # هذا سيطبع "Connecting to ***" في السجلات
      echo "Connecting to $DB_URL"

GitHub يحجب تلقائياً قيم الأسرار من مخرجات السجل. إذا كان سرك p@ssw0rd123 وأي خطوة تطبع تلك السلسلة، السجلات تُظهر ***. هذا يعمل جيداً، مع تحفظ واحد: إذا كان سرك قصيراً (مثل رقم PIN من 4 أرقام)، قد لا يحجبه GitHub لأنه قد يطابق سلاسل بريئة. اجعل الأسرار معقدة بشكل معقول.

أسرار محددة النطاق بالبيئة#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

نفس اسم السر، قيم مختلفة لكل بيئة. حقل environment على الوظيفة يحدد أي مجموعة من الأسرار يتم حقنها.

بيئات الإنتاج يجب أن يكون فيها مراجعون مطلوبون مُفعّلون. هذا يعني أن الدفع إلى main يحفز سير العمل، CI يعمل تلقائياً، لكن وظيفة النشر تتوقف مؤقتاً وتنتظر شخصاً للنقر على "الموافقة" في واجهة GitHub. لمشروع فردي، قد يبدو هذا عبئاً إضافياً. لأي شيء به مستخدمون، إنه منقذ في أول مرة تدمج فيها شيئاً معطلاً بالخطأ.

OIDC: لا مزيد من بيانات الاعتماد الثابتة#

بيانات الاعتماد الثابتة (مفاتيح وصول AWS، ملفات JSON لحسابات خدمة GCP) المخزنة في أسرار GitHub هي مسؤولية. لا تنتهي صلاحيتها، لا يمكن تحديد نطاقها لتشغيل سير عمل محدد، وإذا تسربت، عليك تدويرها يدوياً.

OIDC (OpenID Connect) يحل هذا. GitHub Actions يعمل كموفر هوية، ومزود السحابة يثق به لإصدار بيانات اعتماد قصيرة الأمد على الطاير:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

لا مفتاح وصول. لا مفتاح سري. إجراء configure-aws-credentials يطلب رمزاً مؤقتاً من AWS STS باستخدام رمز OIDC من GitHub. الرمز محدد النطاق للمستودع والفرع والبيئة المحددين. ينتهي بعد تشغيل سير العمل.

إعداد هذا على جانب AWS يتطلب موفر هوية OIDC لـ IAM وسياسة ثقة الدور:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

شرط sub حاسم. بدونه، أي مستودع يحصل بطريقة ما على تفاصيل موفر OIDC الخاص بك يمكنه تولي الدور. معه، فقط فرع main من مستودعك المحدد يمكنه ذلك.

GCP لديه إعداد مكافئ مع Workload Identity Federation. Azure لديه بيانات اعتماد موحدة. إذا كانت سحابتك تدعم OIDC، استخدمه. لا سبب لتخزين بيانات اعتماد سحابة ثابتة في 2026.

مفاتيح SSH للنشر#

لنشر VPS عبر SSH، أنشئ زوج مفاتيح مخصص:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

أضف المفتاح العام إلى ~/.ssh/authorized_keys على الخادم مع قيود:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

بادئة restrict تعطل إعادة توجيه المنافذ، وإعادة توجيه الوكيل، وتخصيص PTY، وإعادة توجيه X11. بادئة command= تعني أن هذا المفتاح يمكنه فقط تنفيذ سكريبت النشر. حتى لو تم اختراق المفتاح الخاص، المهاجم يمكنه تشغيل سكريبت النشر ولا شيء آخر.

أضف المفتاح الخاص إلى أسرار GitHub كـ SSH_PRIVATE_KEY. هذا هو بيانات الاعتماد الثابتة الوحيدة التي أقبلها — مفاتيح SSH مع أوامر إجبارية لها نطاق تأثير محدود جداً.

سير عمل طلبات السحب: نشر المعاينة#

كل طلب سحب يستحق بيئة معاينة. يلتقط الأخطاء المرئية التي تفوتها اختبارات الوحدات، يتيح للمصممين المراجعة بدون تحميل الكود، ويجعل حياة ضمان الجودة أسهل بشكل كبير.

نشر معاينة عند فتح طلب السحب#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Find existing comment
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

حساب المنفذ (4000 + PR_NUM) حل عملي. طلب السحب #42 يحصل على المنفذ 4042. طالما ليس لديك أكثر من بضع مئات من طلبات السحب المفتوحة، لا تعارضات. إعداد Nginx باستخدام بطاقة شاملة يوجه pr-*.preview.akousa.net إلى المنفذ الصحيح.

التنظيف عند إغلاق طلب السحب#

بيئات المعاينة التي لا تُنظف تأكل المساحة والذاكرة. أضف وظيفة تنظيف:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

فحوصات الحالة المطلوبة#

في إعدادات مستودعك (Settings > Branches > Branch protection rules)، اطلب هذه الفحوصات قبل الدمج:

  • lint — لا أخطاء تدقيق
  • typecheck — لا أخطاء أنواع
  • test — جميع الاختبارات تنجح
  • build — المشروع يُبنى بنجاح

بدون هذا، سيدمج شخص ما طلب سحب بفحوصات فاشلة. ليس بشكل خبيث — سيرى "2 من 4 فحوصات نجحت" ويفترض أن الاثنين الآخرين لا يزالان يعملان. أحكم الإغلاق.

أيضاً فعّل "طلب تحديث الفروع قبل الدمج." هذا يفرض إعادة تشغيل CI بعد rebase على آخر main. يلتقط الحالة حيث يجتاز طلبا سحب CI بشكل فردي لكن يتعارضان عند دمجهما.

الإشعارات#

نشر لا يعرف عنه أحد هو نشر لا يثق به أحد. الإشعارات تغلق حلقة التغذية الراجعة.

Webhook لـ Slack#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

if: always() حاسم. بدونه، خطوة الإشعار تُتخطى عندما يفشل النشر — وهو بالضبط عندما تحتاجها أكثر.

واجهة برمجة تطبيقات نشر GitHub#

لتتبع نشر أغنى، استخدم واجهة GitHub Deployments API. هذا يعطيك تاريخ نشر في واجهة المستودع ويتيح شارات الحالة:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... خطوات النشر الفعلية ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

الآن تبويب Environments في GitHub يُظهر تاريخ نشر كاملاً: من نشر ماذا، متى، وهل نجح.

بريد إلكتروني عند الفشل فقط#

للنشر الحرج، أحفز أيضاً بريداً إلكترونياً عند الفشل. ليس عبر البريد المدمج في GitHub Actions (مزعج جداً)، بل عبر webhook موجه:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

هذا خط دفاعي الأخير. Slack رائع لكنه أيضاً مزعج — الناس يكتمون القنوات. بريد "DEPLOY FAILED" مع رابط للتشغيل يلفت الانتباه.

ملف سير العمل الكامل#

إليك كل شيء موصولاً معاً في ملف سير عمل واحد جاهز للإنتاج. هذا قريب جداً مما ينشر هذا الموقع فعلاً.

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: التدقيق وفحص الأنواع والاختبار بالتوازي
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # البناء: فقط بعد نجاح CI
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker: بناء ودفع الصورة (فرع main فقط)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # النشر: SSH إلى VPS والتحديث
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # سحب صورة جديدة
            docker pull "$IMAGE"
 
            # تشغيل حاوية جديدة على منفذ بديل
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # فحص الصحة
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # تبديل حركة المرور
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # فترة سماح للطلبات الجارية
            sleep 5
 
            # إيقاف الحاوية القديمة
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # إعادة التسمية وإعادة تعيين المنفذ
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # ملاحظة: لا نعيد تحميل Nginx هنا لأن اسم الحاوية تغير،
            # وليس المنفذ. النشر التالي سيستخدم المنفذ الصحيح.
 
            # تسجيل النشر
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # تنظيف الصور القديمة (أقدم من 7 أيام)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

متابعة التدفق#

عندما أدفع إلى main:

  1. التدقيق وفحص الأنواع والاختبار تنطلق في نفس الوقت. ثلاث مُشغّلات، ثلاث وظائف متوازية. إذا فشلت أي واحدة، يتوقف خط الأنابيب.
  2. البناء يعمل فقط إذا نجحت الثلاثة. يتحقق من أن التطبيق يُجمّع وينتج مخرجات عاملة.
  3. Docker يبني صورة الإنتاج ويدفعها إلى ghcr.io. متعددة المنصات، مع تخزين مؤقت للطبقات.
  4. النشر يدخل SSH إلى VPS، يسحب الصورة الجديدة، يبدأ حاوية جديدة، يفحص صحتها، يبدّل Nginx، وينظف.
  5. الإشعارات تُطلق بغض النظر عن النتيجة. Slack يحصل على الرسالة. نشر GitHub يُحدّث. إذا فشل، يخرج بريد تنبيه.

عندما أفتح طلب سحب:

  1. التدقيق وفحص الأنواع والاختبار تعمل. نفس بوابات الجودة.
  2. البناء يعمل للتحقق من أن المشروع يُجمّع.
  3. Docker والنشر يُتخطيان (شروط if تحصرهما في فرع main فقط).

عندما أحتاج نشراً طارئاً (تخطي الاختبارات):

  1. انقر "Run workflow" في تبويب Actions.
  2. اختر skip_tests: true.
  3. التدقيق وفحص الأنواع لا يزالان يعملان (لا يمكنك تخطيهما — لا أثق بنفسي بهذا القدر).
  4. الاختبارات تُتخطى، البناء يعمل، Docker يبني، النشر يُطلق.

هذا كان سير عملي لمدة سنتين. صمد أمام هجرات الخوادم، ترقيات إصدارات Node.js الكبرى، pnpm يحل محل npm، وإضافة 15 أداة لهذا الموقع. إجمالي الوقت من الدفع إلى الإنتاج: 3 دقائق و40 ثانية في المتوسط. أبطأ خطوة هي بناء Docker متعدد المنصات عند ~90 ثانية. كل شيء آخر مخزن مؤقتاً ليكون شبه فوري.

دروس من سنتين من التكرار#

سأختم بالأخطاء التي ارتكبتها حتى لا تضطر لذلك.

ثبّت إصدارات الإجراءات. uses: actions/checkout@v4 مقبول، لكن للإنتاج، فكر في uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (الـ SHA الكامل). إجراء مخترق يمكنه تسريب أسرارك. حادثة tj-actions/changed-files في 2025 أثبتت أن هذا ليس نظرياً.

لا تخزن كل شيء مؤقتاً. خزنت مرة node_modules مباشرة (وليس فقط مخزن pnpm) وقضيت ساعتين في تصحيح فشل بناء وهمي سببه ارتباطات أصلية قديمة. خزن مؤقتاً مخزن مدير الحزم، وليس الوحدات المثبتة.

عيّن المهلات. كل وظيفة يجب أن تحتوي على timeout-minutes. الافتراضي 360 دقيقة (6 ساعات). إذا علق نشرك لأن اتصال SSH انقطع، لا تريد اكتشاف ذلك بعد ست ساعات عندما تكون قد استهلكت دقائقك الشهرية.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

استخدم concurrency بحكمة. لطلبات السحب، cancel-in-progress: true دائماً صحيح — لا أحد يهتم بنتيجة CI لـ commit تم عمل force-push فوقه بالفعل. لنشر الإنتاج، عيّنه إلى false. لا تريد commit متابعة سريعة يلغي نشراً في منتصف الطرح.

اختبر ملف سير العمل. استخدم act (https://github.com/nektos/act) لتشغيل سير العمل محلياً. لن يلتقط كل شيء (الأسرار غير متاحة، وبيئة المُشغّل مختلفة)، لكنه يلتقط أخطاء بناء جملة YAML وأخطاء منطقية واضحة قبل الدفع.

راقب تكاليف CI. دقائق GitHub Actions مجانية للمستودعات العامة ورخيصة للخاصة، لكنها تتراكم. بناءات Docker متعددة المنصات هي 2x الدقائق (واحدة لكل منصة). استراتيجيات مصفوفات الاختبار تضاعف وقت التشغيل. تابع صفحة الفوترة.

أفضل خط أنابيب CI/CD هو الذي تثق به. الثقة تأتي من الموثوقية والمراقبة والتحسين التدريجي. ابدأ بخط أنابيب تدقيق-اختبار-بناء بسيط. أضف Docker عندما تحتاج قابلية التكرار. أضف نشر SSH عندما تحتاج الأتمتة. أضف الإشعارات عندما تحتاج الثقة. لا تبنِ خط الأنابيب الكامل من اليوم الأول — ستخطئ في التجريدات.

ابنِ خط الأنابيب الذي تحتاجه اليوم، ودعه ينمو مع مشروعك.

مقالات ذات صلة