CI/CD یعنی خودکار کردن مسیر کد از commit تا سرور. CI (یکپارچه‌سازی مداوم) یعنی هر بار که کسی کد را push می‌کند، یک سیستم خودکار پروژه را می‌سازد و تست‌ها را اجرا می‌کند تا خرابی همان لحظه پیدا شود، نه یک هفته بعد. CD (تحویل یا استقرار مداوم) یعنی کدی که از CI سالم بیرون آمده، با یک قدم مشخص و تکرارپذیر، یا کاملاً خودکار، روی سرور می‌رود.

GitHub Actions ساده‌ترین راه برای شروع است، چون داخل خود GitHub است و چیزی برای نصب ندارد: یک فایل YAML در پوشه‌ی .github/workflows می‌گذارید و GitHub روی هر push یا pull request آن را اجرا می‌کند. در این مقاله یک pipeline واقعی برای پروژه‌ی Laravel می‌سازیم، نسخه‌ی Python را هم می‌بینیم و بعد سراغ secrets، محافظت از شاخه و استقرار می‌رویم.

مفاهیم پایه، بدون اصطلاح‌بازی

pipeline یک خط تولید است: کد از یک طرف وارد می‌شود و از چند ایستگاه می‌گذرد. اگر در هر ایستگاهی مشکلی پیدا شود، خط متوقف می‌شود و شما خبردار می‌شوید.

در GitHub Actions این خط از چند جزء ساخته می‌شود:

اصطلاح معنی
workflow یک فایل YAML که کل فرایند را تعریف می‌کند
event رویدادی که workflow را راه می‌اندازد؛ مثل push، pull_request یا ساخت تگ
job مجموعه‌ای از قدم‌ها که روی یک ماشین اجرا می‌شوند؛ jobها به‌طور پیش‌فرض موازی‌اند
step یک دستور شل یا یک action آماده
action قطعه‌ی قابل استفاده‌ی مجدد، مثل actions/checkout
runner ماشینی که job رویش اجرا می‌شود؛ مثلاً ubuntu-latest

ارزش CI در این است که «روی سیستم من کار می‌کرد» دیگر بهانه نیست. تست‌ها روی یک ماشین تمیز و یکسان اجرا می‌شوند، هر بار.

اولین workflow برای Laravel

فرض می‌کنیم پروژه Laravel 13 است و تست‌ها با php artisan test اجرا می‌شوند. این فایل را بسازید:

.github/workflows/tests.yml
name: tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    env:
      DB_CONNECTION: sqlite
      DB_DATABASE: ":memory:"

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
          extensions: mbstring, pdo_sqlite, bcmath, intl
          coverage: none

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
          restore-keys: composer-${{ runner.os }}-

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist --no-progress

      - name: Prepare environment
        run: |
          cp .env.example .env
          php artisan key:generate

      - name: Run tests
        run: php artisan test

چند نکته درباره‌ی این فایل:

  • on: workflow روی هر push به main و روی هر pull request اجرا می‌شود. یعنی پیش از merge می‌دانید کد سالم است یا نه.
  • setup-php: نسخه‌ی PHP و افزونه‌ها را دقیقاً مشخص می‌کنیم. بهتر است همان نسخه‌ای باشد که روی سرور production دارید.
  • کش composer: کلید کش از روی هش composer.lock ساخته می‌شود. تا وقتی وابستگی‌ها عوض نشده‌اند، بسته‌ها از کش می‌آیند و نصب خیلی سریع‌تر است. به همین دلیل composer.lock باید در مخزن commit شده باشد.
  • SQLite در حافظه: برای بیشتر تست‌ها کافی و سریع است و سرویس جداگانه‌ای نمی‌خواهد. متغیرهای env در سطح job، مقدارهای .env را بازنویسی می‌کنند.

اگر صفحه‌های شما از @vite استفاده می‌کنند و تست‌های Feature آن صفحه‌ها را رندر می‌کنند، یا در تست‌ها $this->withoutVite() را صدا بزنید، یا پیش از تست‌ها قدم‌های npm ci و npm run build را اضافه کنید؛ وگرنه تست‌ها به خاطر نبودن manifest شکست می‌خورند.

وقتی تست به MySQL واقعی نیاز دارد

اگر از قابلیت‌هایی استفاده می‌کنید که در SQLite رفتار متفاوتی دارند، می‌توانید یک سرویس MySQL کنار job بالا بیاورید:

yaml
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: testing
          MYSQL_ROOT_PASSWORD: password
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h 127.0.0.1"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

و متغیرهای job را به DB_CONNECTION: mysql، DB_HOST: 127.0.0.1، DB_DATABASE: testing، DB_USERNAME: root و DB_PASSWORD: password تغییر دهید. این رمز فقط مال یک کانتینر موقت است که بعد از job از بین می‌رود، پس نوشتنش در فایل اشکالی ندارد. افزونه‌ی pdo_mysql را هم به setup-php اضافه کنید.

همین ایده برای Python

برای پروژه‌ی Python با pytest، ساختار تقریباً یکی است و setup-python کش pip را خودش مدیریت می‌کند:

.github/workflows/tests.yml
name: tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - run: pip install -r requirements.txt

      - run: pytest

مقایسه‌ی کلی این دو زبان برای بک‌اند را در PHP یا Python نوشته‌ایم.

secrets: رمزها جایشان در فایل نیست

هر چیزی که محرمانه است، مثل کلید API یا کلید SSH استقرار، نباید در مخزن باشد. در GitHub از مسیر Settings → Secrets and variables → Actions یک secret بسازید و در workflow این‌طور بخوانید:

yaml
      - name: Call external service
        run: ./scripts/notify.sh
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}

چند قاعده:

  • GitHub مقدار secretها را در لاگ با *** می‌پوشاند، ولی اگر مقدار را تغییر دهید (مثلاً base64 کنید) و چاپ کنید، این پوشاندن کار نمی‌کند. secret را هیچ‌وقت echo نکنید.
  • workflowهایی که از pull requestهای یک fork اجرا می‌شوند به secretها دسترسی ندارند؛ این رفتار عمدی و درست است.
  • برای استقرار، از Environments استفاده کنید: secretهای production را فقط به environment‌ای به نام production بدهید و برایش تأیید دستی بگذارید.

محافظت از شاخه‌ی اصلی

CI وقتی واقعاً ارزش دارد که نشود از رویش رد شد. در Settings → Branches (یا Rulesets) برای شاخه‌ی main قانون بگذارید:

  • Require a pull request before merging: کسی مستقیم روی main push نکند.
  • Require status checks to pass: job به نام test را به عنوان check اجباری انتخاب کنید. تا تست‌ها سبز نشوند، دکمه‌ی merge فعال نمی‌شود.
  • Require branches to be up to date: شاخه پیش از merge با آخرین main تست شده باشد.

اگر با شاخه و pull request هنوز راحت نیستید، دستورهای ضروری git را ببینید.

CD چه شکلی می‌تواند باشد

ساده‌ترین الگوی استقرار که هم امن است و هم قابل کنترل: استقرار با تگ. هر وقت تصمیم گرفتید نسخه‌ای منتشر شود، یک تگ مثل v1.4.0 می‌سازید و push می‌کنید؛ workflow استقرار فقط روی تگ‌ها اجرا می‌شود.

.github/workflows/deploy.yml
name: deploy

on:
  push:
    tags: ["v*"]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        run: ./scripts/deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}

محتوای deploy.sh به زیرساخت شما بستگی دارد، ولی برای یک پروژه‌ی Laravel روی VPS معمولاً این کارها را انجام می‌دهد: با کلید SSH اختصاصی استقرار به سرور وصل می‌شود (کلید SSH)، کد تگ‌خورده را می‌گیرد، composer install --no-dev --optimize-autoloader را اجرا می‌کند، php artisan migrate --force و کش‌های config و route را می‌سازد و در آخر workerهای صف را با php artisan queue:restart تازه می‌کند.

چند توصیه برای CD:

  • اول CI، بعد CD. استقرار خودکار بدون تست خودکار یعنی رساندن سریع‌تر باگ به کاربر.
  • کلید استقرار جدا بسازید، با کمترین دسترسی لازم، نه کلید شخصی خودتان.
  • مسیر برگشت داشته باشید: اگر نسخه‌ی جدید خراب بود، استقرار تگ قبلی باید یک دستور باشد. پیش از migrationهای خطرناک هم پشتیبان بگیرید (قاعده‌ی ۳-۲-۱).
  • اگر برنامه با Docker اجرا می‌شود، CD می‌تواند به جای کپی کد، یک image بسازد و سرور آن را بکشد؛ مقدماتش در Docker Compose آمده است.

اشکال‌های رایج

  • تست‌ها روی سیستم خودتان سبزند ولی در CI قرمز: معمولاً به خاطر وابستگی به فایل .env محلی، ترتیب اجرای تست‌ها یا افزونه‌ی PHP که روی runner نصب نیست.
  • workflow اصلاً اجرا نمی‌شود: مسیر فایل باید دقیقاً .github/workflows/ باشد و YAML تورفتگی درستی داشته باشد.
  • کش هیچ‌وقت استفاده نمی‌شود: composer.lock در مخزن نیست یا در هر اجرا تغییر می‌کند.
  • نسخه‌ی شناور action: @v4 به‌روزرسانی‌های جزئی را خودکار می‌گیرد. اگر امنیت زنجیره‌ی تأمین برایتان مهم است، action را به هش commit مشخص سنجاق کنید.

جمع‌بندی

CI یعنی هر push به‌طور خودکار تست شود؛ CD یعنی کد سالم با یک قدم تکرارپذیر به سرور برسد. با یک فایل tests.yml شروع کنید، تست‌ها را روی هر pull request اجرا کنید و با قوانین شاخه merge کد قرمز را ناممکن کنید. وقتی به این خط اعتماد پیدا کردید، استقرار با تگ و secretهای محیط production قدم طبیعی بعدی است. مستندات کامل در docs.github.com/actions است.