לדלג לתוכן

11.6 CI CD לפרונטאנד פתרון

פתרון - CI/CD לפרונטאנד

פתרון תרגיל 1 - Workflow בסיסי

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  ci:
    name: CI Pipeline
    runs-on: ubuntu-latest

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: ESLint
        run: npm run lint

      - name: TypeScript Check
        run: npx tsc --noEmit

      - name: Prettier Check
        run: npx prettier --check "src/**/*.{ts,tsx,css,json}"

      - name: Unit Tests
        run: npm test -- --ci --coverage

      - name: Build
        run: npm run build

הסבר: ה-concurrency מקבצת ריצות לפי workflow ו-branch. cancel-in-progress: true מבטל ריצה קודמת אם push חדש מגיע באותו branch, חוסך זמן ומשאבים.


פתרון תרגיל 2 - Pipeline מרובה שלבים

דיאגרמת סדר ריצה:

quality -----> test --------> deploy (main only)
    |                          ^
    +-------> build ------> e2e (PR only)
                              |
                              +---> deploy
# .github/workflows/pipeline.yml
name: Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_VERSION: '20'

jobs:
  quality:
    name: Code Quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit
      - run: npx prettier --check "src/**/*.{ts,tsx}"

  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --ci --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
      - uses: actions/upload-artifact@v4
        with:
          name: build
          path: .next/

  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: e2e-report
          path: playwright-report/

  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [test, build]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

פתרון תרגיל 3 - Preview Deployment עם Lighthouse

# .github/workflows/preview-lighthouse.yml
name: Preview & Lighthouse

on:
  pull_request:
    branches: [main]

jobs:
  deploy-preview:
    name: Deploy Preview
    runs-on: ubuntu-latest
    outputs:
      preview-url: ${{ steps.deploy.outputs.preview-url }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Vercel
        id: deploy
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}

  lighthouse:
    name: Lighthouse Audit
    runs-on: ubuntu-latest
    needs: deploy-preview

    steps:
      - uses: actions/checkout@v4

      - name: Run Lighthouse
        id: lighthouse
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: ${{ needs.deploy-preview.outputs.preview-url }}
          uploadArtifacts: true
          temporaryPublicStorage: true

      - name: Format Results
        id: format
        uses: actions/github-script@v7
        with:
          script: |
            const results = ${{ steps.lighthouse.outputs.manifest }};
            const summary = results[0].summary;

            const score = (value) => Math.round(value * 100);
            const emoji = (value) => value >= 0.9 ? 'V' : value >= 0.5 ? '~' : 'X';

            const performance = score(summary.performance);
            const accessibility = score(summary.accessibility);
            const bestPractices = score(summary['best-practices']);
            const seo = score(summary.seo);

            const comment = `## Lighthouse Results

            | Metric | Score | Status |
            |--------|-------|--------|
            | Performance | ${performance} | ${emoji(summary.performance)} |
            | Accessibility | ${accessibility} | ${emoji(summary.accessibility)} |
            | Best Practices | ${bestPractices} | ${emoji(summary['best-practices'])} |
            | SEO | ${seo} | ${emoji(summary.seo)} |

            Preview: ${{ needs.deploy-preview.outputs.preview-url }}`;

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: comment,
            });

            // כישלון אם לא עומד בתקנים
            if (performance < 80) {
              core.setFailed(`Performance score ${performance} is below threshold of 80`);
            }
            if (accessibility < 90) {
              core.setFailed(`Accessibility score ${accessibility} is below threshold of 90`);
            }

פתרון תרגיל 4 - ניהול משתני סביבה

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [develop, main]

jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/develop'
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
          NEXT_PUBLIC_GA_ID: ${{ vars.GA_ID }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          NEXT_PUBLIC_ENV: staging
      - name: Deploy
        run: echo "Deploying to staging..."
        env:
          DEPLOY_URL: ${{ vars.DEPLOY_URL }}

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
          NEXT_PUBLIC_GA_ID: ${{ vars.GA_ID }}
          NEXT_PUBLIC_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          NEXT_PUBLIC_ENV: production
      - name: Deploy
        run: echo "Deploying to production..."

רשימת הגדרות לפי סביבה:

Staging - Secrets:
- DATABASE_URL - כתובת מסד נתונים של staging

Staging - Variables:
- API_URL - https://api-staging.myapp.com
- GA_ID - G-STAGING123
- DEPLOY_URL - https://staging.myapp.com

Production - Secrets:
- DATABASE_URL - כתובת מסד נתונים של production
- SENTRY_AUTH_TOKEN - טוקן Sentry

Production - Variables:
- API_URL - https://api.myapp.com
- GA_ID - G-PROD456
- SENTRY_DSN - https://xxx@sentry.io/yyy
- DEPLOY_URL - https://myapp.com


פתרון תרגיל 5 - בדיקת PR אוטומטית

# .github/workflows/pr-checks.yml
name: PR Checks

on:
  pull_request:
    types: [opened, synchronize, reopened, edited]

jobs:
  validate-pr:
    name: Validate PR
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # בדיקת תיאור PR
      - name: Check PR Description
        uses: actions/github-script@v7
        with:
          script: |
            const pr = context.payload.pull_request;
            if (!pr.body || pr.body.trim().length < 20) {
              core.setFailed('PR must include a description of at least 20 characters');
            }

      # בדיקת גודל PR
      - name: Check PR Size
        uses: actions/github-script@v7
        with:
          script: |
            const { data: files } = await github.rest.pulls.listFiles({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number,
            });

            const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);

            if (totalChanges > 400) {
              core.warning(`PR has ${totalChanges} line changes. Consider splitting into smaller PRs.`);
            }

            // בדיקת קבצי .env
            const envFiles = files.filter(f =>
              f.filename.includes('.env') && !f.filename.endsWith('.example')
            );

            if (envFiles.length > 0) {
              core.setFailed(`PR contains .env files: ${envFiles.map(f => f.filename).join(', ')}`);
            }

      # Labels אוטומטיות
      - name: Auto Label
        uses: actions/github-script@v7
        with:
          script: |
            const { data: files } = await github.rest.pulls.listFiles({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number,
            });

            const labels = new Set();
            const filenames = files.map(f => f.filename);

            if (filenames.some(f => f.includes('test') || f.includes('spec'))) {
              labels.add('tests');
            }
            if (filenames.some(f => f.endsWith('.md') || f.includes('docs'))) {
              labels.add('documentation');
            }
            if (filenames.some(f => f.includes('.yml') || f.includes('.yaml') || f.includes('config'))) {
              labels.add('infrastructure');
            }

            const pr = context.payload.pull_request;
            const title = pr.title.toLowerCase();
            if (title.startsWith('fix') || title.includes('bug')) labels.add('bug');
            if (title.startsWith('feat') || title.includes('feature')) labels.add('feature');

            if (labels.size > 0) {
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                labels: Array.from(labels),
              });
            }

  bundle-size:
    name: Bundle Size Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build

      - name: Report Bundle Size
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const path = '.next/build-manifest.json';

            if (fs.existsSync(path)) {
              const manifest = JSON.parse(fs.readFileSync(path, 'utf-8'));
              const pages = Object.keys(manifest.pages);

              const comment = `## Bundle Size Report\n\nPages analyzed: ${pages.length}\n\nBuild completed successfully.`;

              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: comment,
              });
            }

פתרון תרגיל 6 - CI/CD מלא עם התראות

# .github/workflows/full-pipeline.yml
name: Full Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 9 * * 1' # כל יום שני ב-9:00

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_VERSION: '20'

jobs:
  quality:
    name: Code Quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit
      - run: npx prettier --check "src/**/*.{ts,tsx}"

  test:
    name: Tests
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --ci --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/lcov.info

      # הערה ב-PR עם coverage
      - name: Coverage Report
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            if (fs.existsSync('coverage/coverage-summary.json')) {
              const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf-8'));
              const total = summary.total;
              const comment = `## Test Coverage Report\n\n| Metric | Coverage |\n|--------|----------|\n| Statements | ${total.statements.pct}% |\n| Branches | ${total.branches.pct}% |\n| Functions | ${total.functions.pct}% |\n| Lines | ${total.lines.pct}% |`;

              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: comment,
              });
            }

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci
      - run: npm run build

  security:
    name: Security Audit
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
      - run: npm ci
      - run: npm audit --audit-level=high

  deploy-staging:
    name: Deploy Staging
    runs-on: ubuntu-latest
    needs: [test, build]
    if: github.ref == 'refs/heads/develop' && github.event_name == 'push'
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: echo "Deploying to staging..."

  deploy-production:
    name: Deploy Production
    runs-on: ubuntu-latest
    needs: [test, build]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: echo "Deploying to production..."

  # התראות
  notify-success:
    name: Notify Success
    runs-on: ubuntu-latest
    needs: [deploy-staging, deploy-production]
    if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success')
    steps:
      - name: Notify Discord
        env:
          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
        run: |
          curl -H "Content-Type: application/json" \
            -d "{\"content\": \"Deployment successful! Branch: ${{ github.ref_name }}, Commit: ${{ github.sha }}\"}" \
            $DISCORD_WEBHOOK

  notify-failure:
    name: Notify Failure
    runs-on: ubuntu-latest
    needs: [quality, test, build]
    if: failure()
    steps:
      - name: Notify Discord
        env:
          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
        run: |
          curl -H "Content-Type: application/json" \
            -d "{\"content\": \"CI FAILED! Branch: ${{ github.ref_name }}, Author: ${{ github.actor }}, Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
            $DISCORD_WEBHOOK

תשובות לשאלות

1. npm install מול npm ci:

npm install מתקין תלויות לפי package.json ועלול לעדכן את package-lock.json. npm ci (clean install) מתקין בדיוק לפי package-lock.json, מוחק node_modules לפני ההתקנה, ונכשל אם יש חוסר התאמה. ב-CI משתמשים ב-npm ci כי: (א) תוצאה זהה בכל ריצה, (ב) מהיר יותר, (ג) לא משנה קבצים ב-repository.

2. Concurrency:

concurrency מקבצת ריצות workflow לפי group. כש-cancel-in-progress: true, ריצה חדשה באותו group מבטלת ריצות קודמות. זה חשוב כי: אם מפתח עושה 3 pushes מהירים, אין טעם להריץ CI על השניים הראשונים - רק האחרון רלוונטי. חוסך זמן ומשאבים (דקות GitHub Actions).

3. Secrets מול Variables:

Secrets הם ערכים מוצפנים שלא גלויים בלוגים ולא ניתן לראות את ערכם אחרי הגדרה - מתאימים למפתחות API, סיסמאות, טוקנים. Variables הם ערכים גלויים שמופיעים בלוגים - מתאימים לכתובות URL, מזהי פרויקט, הגדרות שאינן רגישות.

4. בדיקות E2E ב-CI:

בדיקות יחידה בודקות פונקציות בודדות בבידוד, אך לא מוודאות שהמערכת כולה עובדת יחד. בדיקות E2E מדמות משתמש אמיתי - לחיצות, ניווט, מילוי טפסים - ותופסות באגים שבדיקות יחידה מפספסות: שגיאות אינטגרציה, בעיות CSS, ניתוב שבור. הרצה ב-CI מבטיחה שכל PR נבדק בצורה מקיפה.

5. Preview Deployment:

Preview Deployment הוא פריסה זמנית שנוצרת אוטומטית לכל PR. היתרון: הסוקר יכול לראות ולבדוק את השינויים באתר חי, לא רק בקוד. אפשר גם להריץ בדיקות אוטומטיות (Lighthouse, E2E) על ה-preview. זה מפחית באגים שמגיעים לפרודקשן כי יש שכבת בדיקה נוספת לפני המיזוג.