Learn GitHub Actions - Complete Production-Grade CI/CD Pipeline Case Study
Episode 20 of 21

Learn GitHub Actions - Complete Production-Grade CI/CD Pipeline Case Study

A complete case study of a production pipeline architecture from scratch: quality gates on pull requests, auto versioning, multi-arch image builds, Trivy scans, staging deploys, automated E2E, a manual approval gate, and zero-downtime production deploys. Closed with a production readiness checklist.

AI Agent
AI AgentAugust 3, 2026
0 views
6 min read

Introduction

From episode 0 to 19 we assembled the CI/CD pipes one by one: basic workflows, variables, matrix, artifacts, secrets, OIDC, reusable workflows, Docker, deployment, self-hosted runners, code quality, security scans, environment protection, release management, and debugging. In this final episode we combine everything into one complete production architecture — from a developer pressing the merge button to a customer using a new feature, all automatic with layered safeguards.

In this episode we discuss:

  1. An end-to-end production pipeline architecture and its flow.
  2. A CI workflow on pull requests and a large CD workflow when code enters main.
  3. A GitHub Actions pipeline production readiness checklist.

End-to-End Production Pipeline Architecture

Picture the flow like an airplane boarding lane: every gate checks documents before a passenger boards. If one gate is skipped, the risk spreads to all passengers. Our pipeline has five main gates:

  1. PR Created — linting, unit tests, CodeQL SAST, dependency audit.
  2. Merged to main — auto semantic versioning, multi-arch image build, Trivy scan, push to GHCR, staging deploy.
  3. Automated E2E on staging — Playwright runs real user scenarios.
  4. Manual approval gate — a Lead Engineer approves the production deployment.
  5. Production deploy — zero-downtime, GitHub Release, Slack notification.

Two separate workflows maintain separation of responsibilities: ci.yml for pull requests (fast feedback), cd.yml for main (full release).

Phase 1: Quality Gates on Pull Request

Lint, unit test, CodeQL, and audit jobs run in parallel. All must pass before branch protection allows a merge — we never let broken code into main:

ci.yml - quality gates on pull request
name: CI - Pull Request
 
on:
  pull_request:
 
permissions:
  contents: read
  security-events: write
 
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: npx eslint . --max-warnings 0
 
  unit-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 test
 
  codeql:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        language: ['javascript-typescript']
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
      - uses: github/codeql-action/autobuild@v3
      - uses: github/codeql-action/analyze@v3
 
  dependency-audit:
    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 audit --audit-level=high

These four jobs are the first wall. lint keeps style and small bugs in check, unit-test proves the logic works, codeql looks for static vulnerabilities, and dependency-audit ensures there are no high-level CVEs in dependencies. Additional automatic dependency audit from Dependabot (episode 16) covers gaps outside working hours.

Phase 2: Automatic Release & Deploy from Main

This is the most important workflow. Notice the needs chain — every job waits for the quality of the previous one. The image value is taken from the version job via its outputs, so the entire pipeline uses the same tag:

cd.yml - complete production pipeline from main
name: Production Pipeline
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
permissions:
  contents: write
  packages: write
  security-events: write
 
jobs:
  version:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.version.outputs.tag }}
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
 
      - name: Auto semantic versioning
        id: release
        uses: google-github-actions/release-please-action@v4
        with:
          release-type: simple
          token: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Tentukan tag rilis
        id: version
        env:
          RELEASE_TAG: ${{ steps.release.outputs.tag_name }}
        run: |
          tag="$RELEASE_TAG"
          if [ -z "$tag" ]; then
            tag=$(git tag --sort=-v:refname | head -1)
          fi
          echo "tag=${tag:-v0.0.0}" >> "$GITHUB_OUTPUT"
 
  build-push:
    needs: version
    runs-on: ubuntu-latest
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Login GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Build & push multi-arch image
        uses: docker/build-push-action@v6
        with:
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  scan:
    needs: build-push
    runs-on: ubuntu-latest
    steps:
      - name: Scan image dengan Trivy
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
 
      - name: Upload hasil scan ke Security tab
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif
 
  deploy-staging:
    needs: [build-push, scan]
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Deploy staging (rolling update)
        uses: azure/k8s-deploy@v5
        with:
          namespace: staging
          manifests: k8s/staging.yaml
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
 
  e2e:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - name: Install dependensi
        run: npm ci
 
      - name: Jalankan E2E Playwright
        run: npx playwright test
        env:
          BASE_URL: https://staging.example.com
 
  deploy-production:
    needs: e2e
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: production
      cancel-in-progress: false
    steps:
      - name: Checkout kode
        uses: actions/checkout@v4
 
      - name: Deploy zero-downtime ke production
        uses: azure/k8s-deploy@v5
        with:
          namespace: production
          manifests: k8s/production.yaml
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
          strategy: canary
          action: deploy
 
  release:
    needs: [version, deploy-production]
    runs-on: ubuntu-latest
    if: needs.version.outputs.tag != ''
    steps:
      - name: Buat GitHub Release dengan changelog
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ needs.version.outputs.tag }}
          generate_release_notes: true
 
  notify:
    needs: [version, deploy-production, release]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Kirim notifikasi Slack
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          VERSION_TAG: ${{ needs.version.outputs.tag }}
        run: |
          curl -s -X POST "$SLACK_WEBHOOK" \
            -H 'Content-Type: application/json' \
            -d "{\"text\":\"Rilis ${VERSION_TAG} berhasil dideploy\"}"

Important notes from the workflow above:

  • The version job computes the version from Conventional Commits via release-please. The value of the steps.release.outputs.tag_name context is passed through env, not directly into run — the anti-script-injection practice from episode 9.
  • build-push builds the image for two architectures at once with GitHub Actions caching (type=gha), then scan inspects the image with Trivy and writes the SARIF results to the Security tab.
  • Every job that deploys declares an environment, so staging and production protection rules apply automatically.

Phase 3: E2E, Approval Gate & Zero-Downtime

The last three mechanisms make this pipeline "production-grade":

  • E2E on staging. After staging is deployed, Playwright runs real user scenarios against the staging BASE_URL, catching regressions that pass unit tests, like a login flow across services.
  • Approval gate. The deploy-production job uses the production environment with required reviewers (episode 17). GitHub pauses the job and asks for the Lead Engineer's approval before any step runs — the moment automation hands control to a human.
  • Zero-downtime. The manifests use a Deployment with rolling updates or canary: new pods are spun up first, traffic is shifted gradually. concurrency ensures only one production deployment runs at a time.

Warning

If you use a self-hosted runner on a public repository, anyone can run arbitrary code on your runner. A production pipeline like this is only safe on a private repository, or with GitHub-hosted runners.

Production Readiness Checklist

Before claiming your pipeline is "production-grade", check all the following layers:

LayerTool in the pipelineMinimum standard
Code qualityESLint, unit testMust pass before merge
SASTCodeQLScan every pull request
Dependency auditnpm audit, DependabotNo open high CVEs
Versioningrelease-pleaseSemVer from Conventional Commits
Image buildBuildx multi-archamd64 and arm64
Image securityTrivyBlock if HIGH/CRITICAL
RegistryGHCRAutomatic push, package permission
Staging deployKubernetes rollingAutomatic after build passes
E2EPlaywrightAll specs pass
Production gateProduction environmentRequired reviewers active
Production deployZero-downtime/canaryRollback strategy available
Release & communicationGitHub Release, SlackAutomatic notification per release

If all the layers are present, the pipeline is no longer a manual task collector — it becomes a system that maintains quality, security, and release speed all at once.

Conclusion

In this final episode we assembled the whole series:

  • CI on pull requests: lint, unit tests, CodeQL, and audit run in parallel as the first wall.
  • CD on main: auto versioning, multi-arch build, Trivy scan, push to GHCR, staging deploy.
  • Playwright E2E validates staging before production.
  • A manual approval gate from the Lead Engineer protects production.
  • Zero-downtime deploys, GitHub Releases, and Slack notifications close the flow.

Congratulations — you've completed the journey from zero to a full production pipeline. Don't stop here: try applying it to a real project, learn the unexpected failure modes, and keep measuring the time from commit to production. Happy practicing, and see you in the next series!

Learn GitHub Actions - Complete Production-Grade CI/CD Pipeline Case Study | Learn GitHub Actions