DEV Community

myougaTheAxo
myougaTheAxo

Posted on

CI/CD Pipeline Design with Claude Code: GitHub Actions from Zero to Deploy

GitHub Actions workflows have a steep learning curve — YAML syntax, job dependencies, secret management, Blue-Green deployments. Claude Code eliminates that curve.

With a CLAUDE.md that defines your project's CI/CD rules, Claude Code generates project-specific workflows in seconds — not generic templates, but pipelines that match your actual constraints.


Step 1: Define CI/CD Rules in CLAUDE.md

# CI/CD Rules

## Branch Strategy
- Direct push to main is prohibited. Always use PRs
- All PRs require CI to pass (ci-required status check)
- Branch flow: feature/* → develop → main

## Pipeline Stages (strict order)
1. lint       — ESLint + Prettier check
2. test       — Jest (minimum 80% line coverage required)
3. build      — tsc + vite build
4. deploy     — main merge only, Blue-Green to production

## Secret Management
- All API keys stored in GitHub Secrets (no hardcoding)
- Environment variable names: SCREAMING_SNAKE_CASE
- .env.example is public-safe; .env must be in .gitignore

## Deploy Requirements
- Health check endpoint: GET /health → 200
- Rollback: auto-revert to previous version on deploy failure
- Notification: Slack alert on deploy success/failure
Enter fullscreen mode Exit fullscreen mode

Step 2: Generate the CI Workflow

Prompt:

Generate a GitHub Actions CI workflow for a Node.js app (npm workspace, TypeScript)
following CLAUDE.md rules.

Requirements:
- Node.js 20.x
- Stages: lint → test (with coverage report) → build
- Trigger: PR open and push
- Cache npm dependencies
- Post test results to GitHub Actions Summary
- Fail if line coverage drops below 80%
Enter fullscreen mode Exit fullscreen mode

Generated .github/workflows/ci.yml:

name: CI

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

jobs:
  lint:
    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 lint
      - run: npm run format:check

  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - name: Run tests with coverage
        run: npm test -- --coverage --coverageReporters=json-summary
      - name: Coverage gate (80%)
        run: |
          COVERAGE=$(node -e "
            const s = require('./coverage/coverage-summary.json');
            console.log(s.total.lines.pct);
          ")
          echo "Line coverage: ${COVERAGE}%"
          node -e "if (${COVERAGE} < 80) { console.error('Coverage below 80%'); process.exit(1); }"
          echo "## Test Coverage: ${COVERAGE}%" >> $GITHUB_STEP_SUMMARY

  build:
    needs: test
    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
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
Enter fullscreen mode Exit fullscreen mode

Step 3: Auto-Post Coverage to PRs

Prompt:

Add a job that posts test coverage diff as a PR comment on every PR open/update.
- Compare against base branch coverage
- Show a warning icon if coverage decreased
- Update the existing comment instead of creating a new one
- Use secrets.GITHUB_TOKEN
Enter fullscreen mode Exit fullscreen mode

Generated job:

  coverage-comment:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Get base branch coverage
        run: |
          git checkout ${{ github.base_ref }}
          npm ci --silent
          npm test -- --coverage --coverageReporters=json-summary 2>/dev/null || true
          BASE=$(node -e "
            try {
              const s = require('./coverage/coverage-summary.json');
              console.log(s.total.lines.pct);
            } catch { console.log('0'); }
          ")
          echo "BASE_COVERAGE=${BASE}" >> $GITHUB_ENV
      - name: Get PR coverage
        run: |
          git checkout ${{ github.head_ref }}
          npm ci --silent
          npm test -- --coverage --coverageReporters=json-summary
          PR=$(node -e "
            const s = require('./coverage/coverage-summary.json');
            console.log(s.total.lines.pct);
          ")
          echo "PR_COVERAGE=${PR}" >> $GITHUB_ENV
      - name: Post coverage comment
        uses: actions/github-script@v7
        with:
          script: |
            const base = parseFloat(process.env.BASE_COVERAGE) || 0;
            const pr = parseFloat(process.env.PR_COVERAGE) || 0;
            const diff = (pr - base).toFixed(1);
            const icon = pr < base ? '⚠️' : '✅';

            const body = `## ${icon} Test Coverage Report
            | | Coverage |
            |---|---|
            | Base (${context.payload.pull_request.base.ref}) | ${base}% |
            | This PR | ${pr}% |
            | Diff | ${diff > 0 ? '+' : ''}${diff}% |`;

            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const existing = comments.data.find(c => c.body.includes('Test Coverage Report'));
            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }
Enter fullscreen mode Exit fullscreen mode

Step 4: Production Deploy with Blue-Green

Prompt:

Generate a GitHub Actions deploy workflow triggered on main branch merge.
Use Blue-Green deployment with Docker containers.
- Health check new (Green) environment before switching traffic
- Auto-rollback if health check fails after 10 retries
- Send Slack notification on success or failure
Enter fullscreen mode Exit fullscreen mode

Generated .github/workflows/deploy.yml:

name: Deploy to Production

on:
  push:
    branches: [main]

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

      - name: Build Docker image
        run: |
          docker build -t myapp:${{ github.sha }} .
          echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/${{ github.repository }}/myapp:${{ github.sha }}

      - name: Deploy to Green environment
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        run: |
          echo "$DEPLOY_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
          ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no ubuntu@${DEPLOY_HOST} "
            docker pull ghcr.io/${{ github.repository }}/myapp:${{ github.sha }}
            docker stop myapp-green 2>/dev/null || true
            docker rm myapp-green 2>/dev/null || true
            docker run -d --name myapp-green \
              -p 3001:3000 \
              -e NODE_ENV=production \
              ghcr.io/${{ github.repository }}/myapp:${{ github.sha }}
          "

      - name: Health check Green
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          for i in {1..10}; do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://${DEPLOY_HOST}:3001/health)
            if [ "$STATUS" = "200" ]; then
              echo "Health check passed"
              exit 0
            fi
            echo "Attempt $i: status=$STATUS, retrying in 5s..."
            sleep 5
          done
          echo "Health check failed after 10 attempts"
          exit 1

      - name: Switch traffic to Green
        if: success()
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        run: |
          ssh -i /tmp/deploy_key ubuntu@${DEPLOY_HOST} "
            sed -i 's/3000/3001/' /etc/nginx/conf.d/app.conf
            nginx -s reload
            docker stop myapp-blue 2>/dev/null || true
            docker rename myapp-green myapp-blue
          "

      - name: Rollback on failure
        if: failure()
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        run: |
          ssh -i /tmp/deploy_key ubuntu@${DEPLOY_HOST} "
            docker stop myapp-green 2>/dev/null || true
            docker rm myapp-green 2>/dev/null || true
            echo 'Rolled back to Blue (previous version)'
          "

      - name: Notify Slack
        if: always()
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        run: |
          STATUS="${{ job.status }}"
          COLOR=$([ "$STATUS" = "success" ] && echo "good" || echo "danger")
          ICON=$([ "$STATUS" = "success" ] && echo ":rocket:" || echo ":x:")
          curl -s -X POST "$SLACK_WEBHOOK" \
            -H "Content-Type: application/json" \
            -d "{
              \"attachments\": [{
                \"color\": \"${COLOR}\",
                \"text\": \"${ICON} Deploy ${STATUS}: \`${{ github.sha }}\` by ${{ github.actor }}\"
              }]
            }"
Enter fullscreen mode Exit fullscreen mode

Why CLAUDE.md First?

The key insight: Claude Code generates project-specific workflows when it has context.

Without CLAUDE.md:

  • Generic Node.js template
  • No coverage gate
  • No rollback logic
  • No Slack notifications

With CLAUDE.md rules defined:

  • Coverage threshold from your rules (80%)
  • Branch strategy enforced in workflow triggers
  • Deploy pattern matches your infrastructure

The CLAUDE.md investment pays off every time you add a new workflow — the rules transfer automatically.


Cost vs Manual Setup

Task Manual Time With Claude Code
CI workflow (lint+test+build) 2-3 hours 5 minutes
PR coverage comments 1-2 hours 3 minutes
Blue-Green deploy workflow 4-8 hours 10 minutes
Total ~13 hours ~20 minutes

Code Review Pack (¥980) includes /code-review for CI/CD configuration review. 👉 https://prompt-works.jp

Myouga (@myougatheaxo) — Security-focused Claude Code engineer.

Top comments (0)