CI CD 파이프라인 구성 - 100-hours-a-week/KTB4-16th-Cloud GitHub Wiki

M3 통합본

1. CI

Frontend

name: Frontend CI main

on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - develop

env:
  ZONE: asia-northeast3-a

jobs:
  # ============================================================
  # develop PR: 머지 전 검증만 (배포 없음)
  # ============================================================
  pr-ci:
    if: github.event_name == 'pull_request'
    name: Frontend develop PR Check
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # PR 검증은 최신 커밋만 검사하므로 얕은 복사(shallow clone) 사용
          fetch-depth: 1

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      # Phase 1: 스타일 검사
      - name: Run Prettier
        run: npm run prettier

      # Phase 2: Lint 검사
      - name: Run ESLint
        run: npm run lint

      # Phase 3: TypeScript 타입 검사
      - name: Typecheck
        run: npm run typecheck

  # PR 검증 결과 알림
  notify-pr:
    name: Discord notification (Develop PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() && github.event_name == 'pull_request' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend develop PR 검증 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend PR CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]

  # ============================================================
  # main push: 운영 릴리즈 (기존 로직 그대로, ref_name 가드만 추가)
  # ============================================================
  main-ci:
    if: github.event_name == 'push' && github.ref_name == 'main'
    name: Frontend CI (main release)
    runs-on: ubuntu-latest
    concurrency:
      group: frontend-main-release
      cancel-in-progress: false

    # 워크플로우 권한 정의
    permissions:
      contents: write      # Git 태그, Release 생성
      id-token: write      # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      # 1. 코드 체크아웃
      - name: Checkout code
        uses: actions/checkout@v7
        with:
            fetch-depth: 0

      # 2. Node.js 
      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"

      # 3. 의존성 설치    
      - name: Install dependencies
        run: npm ci      

      # Phase 1: Prettier
      - name: Run Prettier 
        run: npm run prettier

      # Phase 2: Lint (ESlint)
      - name: Run ESlint
        run: npm run lint

      # Phase 3: TypeScript 타입 오류 검사
      - name: Typecheck
        run: npm run typecheck

      # Phase 4: Build
      - name: Build
        run: npm run build

      # Phase 5: Docker Build 준비
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      # Phase 6: OIDC를 이용한 서비스 계정 인증
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      # Phase 7: gcloud 명령어 사용 준비
      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3
        

      # Phase 8: Docker가 GCP 인증 정보를 사용하도록 연결
      - name: Configure Docker authentication
        run: |
            gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet

      # Phase 9: 버전 계산 -> image build, push -> Git 태그, Release 생성
      - name: Release frontend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
          # release.config.main.json의 prepareCmd(docker buildx build)가 이 값들을 참조
          VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
        run: |
          cp release.config.main.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release

  main-notify:
      name: Discord notification (main release)
      runs-on: ubuntu-latest
      needs: main-ci

      # CI가 실패해도 알림 작업 실행
      if: ${{ always() && github.event_name == 'push' && github.ref_name == 'main' }}
      permissions: {}

      env:
        DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

      steps:
        - name: Send CI result to Discord
          if: ${{ env.DISCORD_WEBHOOK != '' }}
          uses: Ilshidur/[email protected]
          with:
            args: "Frontend main CI 결과 알림"
          env:
            DISCORD_USERNAME: MURO_BOT
            DISCORD_EMBEDS: |
              [
                {
                  "title": "Frontend CI: ${{ needs.main-ci.result }}",
                  "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                  "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                  "color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
                  "fields": [
                    {
                      "name": "커밋",
                      "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                      "inline": true
                    },
                    {
                      "name": "푸시한 사람",
                      "value": ${{ toJSON(github.actor) }},
                      "inline": true
                    },
                    {
                      "name": "브랜치",
                      "value": ${{ toJSON(github.ref_name) }}
                    }
                  ]
                }
              ]

  # ============================================================
  # develop push: 개발서버 릴리즈 + 배포
  # ============================================================
  # 2. 개발 서버 빌드 (develop 브랜치 머지 시 실행)
  push-ci:
    if: github.event_name == 'push' && github.ref_name == 'develop'
    name: Frontend develop Release
    runs-on: ubuntu-latest
    concurrency:
      group: frontend-develop-release
      cancel-in-progress: false

    permissions:
      contents: write   # Git 태그 및 GitHub Release 생성 권한
      id-token: write   # GCP Workload Identity OIDC 인증 권한
      issues: write
      pull-requests: write

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # semantic-release가 커밋 로그와 태그를 추적해야 하므로 전체 히스토리 필요
          fetch-depth: 0

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      # Phase 1: 애플리케이션 빌드
      - name: Build
        run: npm run build

      # Phase 2: Docker Buildx 설정
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      # Phase 3: GCP 인증 (OIDC)
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      # Phase 4: gcloud CLI 준비
      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      # Phase 5: Artifact Registry Docker 인증
      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet

      # release.config.develop.json을 .releaserc.json으로 복사해서 쓴다 (main-ci와 같은 방식).
      - name: Release frontend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.develop.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release

  # 3. Develop VM에 프론트엔드 새 버전을 자동으로 배포한다.
  deploy:
    name: Deploy to Develop VM
    runs-on: ubuntu-latest
    needs: push-ci
    if: github.event_name == 'push' && github.ref_name == 'develop'
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      vm: ${{ steps.develop.outputs.vm }}

    steps:
      # WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - uses: google-github-actions/setup-gcloud@v3

      # 1. 현재 role=develop 라벨이 붙은 VM을 찾는다.
      - id: develop
        run: |
          VM=$(gcloud compute instances list \
            --filter="labels.role=develop" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          if [ -z "$VM" ]; then
            echo "role=develop 라벨을 가진 VM을 찾을 수 없습니다."
            exit 1
          fi
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
          
      # 3. 빈 env 파일 생성
      - run: touch frontend.env

      # 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 frontend 컨테이너만 교체한다.
      #    같은 VM 위의 backend/ai/nginx 컨테이너는 건드리지 않는다.
      - run: |
          gcloud compute scp frontend.env "${{ steps.develop.outputs.vm }}:~/app/frontend.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.develop.outputs.vm }}" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              set -e
              sudo gcloud auth configure-docker '${{ secrets.GCP_REGION }}-docker.pkg.dev' --quiet
              cd ~/app
              export IMAGE_TAG=${{ steps.image.outputs.tag }}
              export GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose pull frontend
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d frontend
            "

      # 5. 컨테이너가 healthy 상태가 될 때까지 대기
      - name: "Wait for frontend container to be healthy"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            for i in \$(seq 1 20); do
              status=\$(sudo docker inspect frontend --format='{{.State.Health.Status}}' 2>/dev/null || echo 'unhealthy')
              if [ \"\$status\" = \"healthy\" ]; then
                echo \"frontend healthy (시도 \$i/20)\"
                exit 0
              fi
              echo \"대기 중... (\$i/20) 현재 상태: \$status\"
              sleep 3
            done
            echo \"frontend가 60초 안에 healthy가 되지 않았습니다.\"
            exit 1
          "

      # 6. nginx는 컨테이너 DNS를 시작/리로드 시점에만 캐싱하므로, 방금 재생성된
      #    frontend의 새 IP를 반영하도록 reload한다. 개발서버는 VM이 하나뿐이라
      #    트래픽 전환 과정이 없어 이 단계가 빠져있었고, 그래서 이미지는 최신인데
      #    실제로는 화면에 반영이 안 되는 문제가 있었다(2026-10-01 실제 발생).
      - name: "Reload nginx to pick up new container IP"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            sudo docker exec nginx nginx -s reload
          "

  # push CI 및 배포 결과 알림
  notify-push:
    name: Discord notification
    runs-on: ubuntu-latest
    needs: [push-ci, deploy]
    if: ${{ always() && github.event_name == 'push' && github.ref_name == 'develop' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Frontend Develop push CI 및 개발 서버 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Frontend Develop 배포: ${{ needs.deploy.result }} (CI: ${{ needs.push-ci.result }})",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ (needs.push-ci.result == 'success' && needs.deploy.result == 'success') && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인]",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]
          

BE

name: Backend CI

# main push -> 프로덕션 릴리즈, develop push -> 개발서버(Mulo test 프로젝트) 릴리즈+배포,
# develop PR -> 검증(checkstyle+compile)만. 세 흐름이 한 파일에 공존하므로 각 job은
# event_name/ref_name으로 자기 몫만 골라 실행되게 가드를 건다.
on:
  push:
    branches:
      - main
      - develop
  pull_request:
    branches:
      - develop

env:
  ZONE: asia-northeast3-a

jobs:
  # ============================================================
  # develop PR: 머지 전 검증만 (배포 없음)
  # ============================================================
  pr-ci:
    if: github.event_name == 'pull_request'
    name: Backend CI pull_request
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 1

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      # PR 게이트웨이 검사 유지
      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      # 컴파일 에러 및 기본 테스트 검증
      - name: Run Compile
        run: ./gradlew bootJar

  develop-pr-notify:
    name: Discord notification (develop PR)
    runs-on: ubuntu-latest
    needs: pr-ci
    if: ${{ always() && github.event_name == 'pull_request' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "백엔드 Develop pull_request CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend CI: ${{ needs.pr-ci.result }}",
                "description": ${{ toJSON(github.event.pull_request.title) }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.pr-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "PR",
                    "value": "[#${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }})",
                    "inline": true
                  },
                  {
                    "name": "작성자",
                    "value": ${{ toJSON(github.event.pull_request.user.login) }},
                    "inline": true
                  },
                  {
                    "name": "작업 브랜치",
                    "value": ${{ toJSON(github.head_ref) }},
                    "inline": true
                  },
                  {
                    "name": "대상 브랜치",
                    "value": ${{ toJSON(github.base_ref) }},
                    "inline": true
                  }
                ]
              }
            ]

  # ============================================================
  # main push: 프로덕션 릴리즈 (기존 로직 그대로, ref_name 가드만 추가)
  # ============================================================
  main-ci:
    if: github.event_name == 'push' && github.ref_name == 'main'
    name: Backend CI (main release)
    runs-on: ubuntu-latest
    concurrency:
      group: backend-main-release
      cancel-in-progress: false

    permissions:
      contents: write      # Git 태그, Release 생성
      id-token: write      # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      - name: Run Checkstyle
        run: ./gradlew checkstyleMain

      - name: Run Compile
        run: ./gradlew bootJar

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet

      # release.config.main.json을 이 job 안에서만 .releaserc.json으로 복사해서 쓴다.
      # `semantic-release --extends <path>`로 직접 넘기면 플러그인 모듈 해석 기준 경로가
      # 달라져서 semantic-release 코어에 번들된 기본 플러그인(commit-analyzer 등)조차
      # 못 찾는 문제가 있었다(2026-09-30 실제 발생, main-ci에서 재현 확인). cosmiconfig가
      # 원래 쓰던 자동 탐색 방식 그대로 두고, 파일만 이 job 시작 시점에 맞바꿔 충돌을 피한다.
      - name: Release backend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.main.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release

  main-notify:
    name: Discord notification (main release)
    runs-on: ubuntu-latest
    needs: main-ci
    if: ${{ always() && github.event_name == 'push' && github.ref_name == 'main' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "Backend main CI 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend CI: ${{ needs.main-ci.result }}",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ needs.main-ci.result == 'success' && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

  # ============================================================
  # develop push: 개발서버(별도 GCP 프로젝트 "Mulo test") 릴리즈 + 배포
  # ============================================================
  push-ci:
    if: github.event_name == 'push' && github.ref_name == 'develop'
    name: Backend CI (develop release)
    runs-on: ubuntu-latest
    concurrency:
      group: backend-develop-release
      cancel-in-progress: false

    permissions:
      contents: write
      id-token: write
      issues: write
      pull-requests: write

    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v7
        with:
          # semantic-release가 Git 태그 및 히스토리를 분석해야 하므로 0 유지 필수
          fetch-depth: 0

      - name: Set up JDK 21
        uses: actions/setup-java@v6
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Set up Gradle
        uses: gradle/actions/setup-gradle@v6

      - name: Grant execute permission
        run: chmod +x gradlew

      - name: Run Compile
        run: ./gradlew bootJar

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3

      - name: Configure Docker authentication
        run: |
          gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet

      # release.config.develop.json을 .releaserc.json으로 복사해서 쓴다 (main-ci와 동일한 이유).
      - name: Release backend image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          cp release.config.develop.json .releaserc.json
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release

  # 개발서버(Mulo test 프로젝트) VM에 새 버전을 자동으로 배포한다.
  deploy:
    name: Deploy to Develop VM
    runs-on: ubuntu-latest
    needs: push-ci
    if: github.event_name == 'push' && github.ref_name == 'develop'
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      vm: ${{ steps.develop.outputs.vm }}

    steps:
      # WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
      - id: auth
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID_Develop }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER_Develop }}
          service_account: ${{ secrets.SERVICE_ACCOUNT_Develop }}

      - uses: google-github-actions/setup-gcloud@v3

      # 1. 현재 role=develop 라벨이 붙은 VM을 찾는다.
      - id: develop
        run: |
          VM=$(gcloud compute instances list \
            --filter="labels.role=develop" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
      #    CD는 이미지를 직접 빌드하지 않고, CI가 이미 push해둔 걸 그대로 가져다 쓴다.
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID_Develop }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      # 3. backend.env 파일을 GitHub Secrets 값으로 생성한다.
      #    이 파일은 여기서만 잠깐 만들어지고, 실제 값은 워크플로 로그에 자동으로 마스킹된다.
      - run: |
          cat > backend.env <<ENVEOF
          DB_URL=${{ secrets.DB_URL_Develop }}
          DB_USERNAME=${{ secrets.DB_USERNAME_Develop }}
          DB_PASSWORD=${{ secrets.DB_PASSWORD_Develop }}
          JWT_SECRET=${{ secrets.JWT_SECRET }}
          GCS_BUCKET_NAME=${{ secrets.GCS_BUCKET_NAME_Develop }}
          GCS_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
          KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }}
          KMA_SERVICE_KEY=${{ secrets.KMA_SERVICE_KEY }}
          SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
          SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
          AI_BASE_URL=${{ secrets.AI_BASE_URL }}
          AI_INTERNAL_TOKEN=${{ secrets.AI_INTERNAL_TOKEN }}
          MULO_SECURITY_ALLOWED_ORIGINS=${{ secrets.MULO_SECURITY_ALLOWED_ORIGINS_Develop }}
          REPORT_OPERATION_TOKEN=${{ secrets.REPORT_OPERATION_TOKEN }}
          # 컨테이너 JVM 기본 시간대가 UTC라, java.sql.Timestamp -> LocalDateTime 변환 시
          # (Timestamp가 상속하는 java.util.Date의 레거시 getter가 JVM 기본시간대를 씀)
          # DB에 정상 저장된 KST 값을 9시간 밀려서 읽는 문제가 있었다. JVM 기본시간대 자체를
          # 앱이 실제 쓰는 Asia/Seoul로 맞춰서 이 변환 단계의 시간대 불일치를 없앤다.
          JAVA_TOOL_OPTIONS=-Duser.timezone=Asia/Seoul
          ENVEOF

      # 4. env 파일을 develop VM으로 전송하고, 새 이미지를 pull해서 backend 컨테이너만 교체한다.
      #    같은 VM 위의 frontend/ai/nginx 컨테이너는 건드리지 않는다.
      - run: |
          gcloud compute scp backend.env "${{ steps.develop.outputs.vm }}:~/app/backend.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.develop.outputs.vm }}" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              set -e
              sudo gcloud auth configure-docker '${{ secrets.GCP_REGION }}-docker.pkg.dev' --quiet
              cd ~/app
              export IMAGE_TAG=${{ steps.image.outputs.tag }}
              export GCP_PROJECT_ID=${{ secrets.GCP_PROJECT_ID_Develop }}
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose pull backend
              sudo --preserve-env=IMAGE_TAG,GCP_PROJECT_ID docker compose up -d backend
            "

      # 5. 컨테이너가 healthy 상태가 될 때까지 대기 (비즈니스 로직 검증용 스모크 테스트는 아님).
      #    이게 없으면 승인이 컨테이너 기동 완료 전에 빨리 눌렸을 때, 아직 준비 안 된
      #    컨테이너로 바로 트래픽이 전환되어 502 다운타임이 생긴다. 최대 60초(3초 x 20회) 대기.
      - name: "Wait for backend container to be healthy"
        run: |
          gcloud compute ssh "${{ steps.develop.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            for i in \$(seq 1 20); do
              status=\$(sudo docker inspect backend --format='{{.State.Health.Status}}')
              if [ \"\$status\" = \"healthy\" ]; then
                echo \"backend healthy (시도 \$i/20)\"
                exit 0
              fi
              echo \"대기 중... (\$i/20) 현재 상태: \$status\"
              sleep 3
            done
            echo \"backend가 60초 안에 healthy가 되지 않았습니다.\"
            exit 1
          "

  develop-push-notify:
    name: Discord notification (develop deploy)
    runs-on: ubuntu-latest
    needs: [push-ci, deploy]
    if: ${{ always() && github.event_name == 'push' && github.ref_name == 'develop' }}
    permissions: {}

    env:
      DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

    steps:
      - name: Send CI result to Discord
        if: ${{ env.DISCORD_WEBHOOK != '' }}
        uses: Ilshidur/[email protected]
        with:
          args: "백엔드 Develop push CI 및 개발 서버 배포 결과 알림"
        env:
          DISCORD_USERNAME: MURO_BOT
          DISCORD_EMBEDS: |
            [
              {
                "title": "Backend Develop 배포: ${{ needs.deploy.result }} (CI: ${{ needs.push-ci.result }})",
                "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                "color": ${{ (needs.push-ci.result == 'success' && needs.deploy.result == 'success') && 3066993 || 15158332 }},
                "fields": [
                  {
                    "name": "커밋",
                    "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                    "inline": true
                  },
                  {
                    "name": "푸시한 사람",
                    "value": ${{ toJSON(github.actor) }},
                    "inline": true
                  },
                  {
                    "name": "브랜치",
                    "value": ${{ toJSON(github.ref_name) }},
                    "inline": true
                  }
                ]
              }
            ]

AI

name: AI CI main

on:
  push:
    branches:
      - main

# 버전 작업 동시에 실행되지 않게 
concurrency:
  group: ai-main-release
  cancel-in-progress: false

jobs:
  ci:
    name: AI CI
    runs-on: ubuntu-latest
    
    # 워크플로우 권한 정의
    permissions:
      contents: write      # Git 태그, Release 생성
      id-token: write      # GCP Workload Identity와 통신하기 위한 OIDC 토큰 발행 권한
      
    env:
      IMAGE_BASE: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}
    
    steps:
      # 1. 코드 체크아웃
      - name: Checkout code
        uses: actions/checkout@v7
        with:
            fetch-depth: 0
            
      # 2. Ruff 설치 + 린트 검사  
      - name: Ruff 실행
        uses: astral-sh/ruff-action@v3
        with:
            args: "check"

      
      # 3. Ruff로 포맷 검사
      - name: Run Ruff Formatter Check
        run: ruff format --check .
        

      # 4. Python 환경 설정
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"

      # 5. 애플리케이션 의존성 설치
      - name: Install dependencies
        run: python -m pip install -r requirements.txt

      # 6. 컴파일
      - name: Check Python syntax
        run: python -m compileall .
      
      # Phase 1: semantic-release 실행 환경 구성
      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: "24"
          package-manager-cache: false

      # Phase 2: Docker Build 준비
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      # Phase 3: OIDC를 이용한 서비스 계정 인증
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      # Phase 4: gcloud 명령어 사용 준비
      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v3
        

      # Phase 5: Docker가 GCP 인증 정보를 사용하도록 연결
      - name: Configure Docker authentication
        run: |
            gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet

      # Phase 6: 버전 계산 -> image build, push -> Git 태그, Release 생성
      - name: Release AI image
        env:
          GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        run: |
          npx --yes \
            --package=semantic-release@25 \
            --package=@semantic-release/exec@7 \
            semantic-release
            
  notify:
      name: Discord notification
      runs-on: ubuntu-latest
      needs: ci

      # CI가 실패해도 알림 작업 실행
      if: ${{ always() }}
      permissions: {}

      env:
        DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}

      steps:
        - name: Send CI result to Discord
          if: ${{ env.DISCORD_WEBHOOK != '' }}
          uses: Ilshidur/[email protected]
          with:
            args: "AI main CI 결과 알림"
          env:
            DISCORD_USERNAME: MURO_BOT
            DISCORD_EMBEDS: |
              [
                {
                  "title": "AI CI: ${{ needs.ci.result }}",
                  "description": ${{ toJSON(github.event.head_commit.message || '커밋 메시지 없음') }},
                  "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
                  "color": ${{ needs.ci.result == 'success' && 3066993 || 15158332 }},
                  "fields": [
                    {
                      "name": "커밋",
                      "value": "[커밋 확인](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})",
                      "inline": true
                    },
                    {
                      "name": "푸시한 사람",
                      "value": ${{ toJSON(github.actor) }},
                      "inline": true
                    },
                    {
                      "name": "브랜치",
                      "value": ${{ toJSON(github.ref_name) }},
                      "inline": true
                    }
                  ]
                }
              ]

https://github.com/vivekratnavel/omniboard/blob/master/.releaserc.json

https://docs.astral.sh/ruff/linter/#ruff-check

https://inpa.tistory.com/entry/GitHub-%F0%9F%8F%9B%EF%B8%8F-%EA%B9%83%ED%97%99-%ED%86%A0%ED%81%B0-%EB%B0%9B%EA%B8%B0

https://twentytwentyone.tistory.com/534

https://prettier.io/docs/cli#--check

https://weaklion1.tistory.com/37

https://dilution0216.tistory.com/185

https://semantic-release.org/recipes/ci-configurations/github-actions

2. CD

Frontend CD

name: Frontend CD

# CI(Frontend CI main)가 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 CI 없이 재배포만 다시 시도하고 싶을 때 수동으로 쓰기 위한 것.
on:
  workflow_run:
    workflows: ["Frontend CI main"]
    types: [completed]
    # main push로 제한한다.
    branches: [main]
  workflow_dispatch:

env:
  ZONE: asia-northeast3-a

jobs:
  # ============================================================
  # 1단계 job: Standby VM에 새 버전을 자동으로 배포한다.
  # 스모크 테스트는 CI 쪽 테스트 코드로 대체하기로 해서 여기선 하지 않는다.
  # 이 job이 실패하면 switch-traffic job은 아예 시작되지 않고 승인 요청도 안 뜬다.
  # ============================================================
  deploy:
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      standby_vm: ${{ steps.standby.outputs.vm }}   # switch-traffic job에서도 이 값을 써야 함
    steps:
      # WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v2

      # 1. 현재 role=standby 라벨이 붙은 VM을 찾는다.
      #    배포는 항상 Standby(트래픽 안 받는 쪽)에만 한다. Active는 여기서 절대 안 건드린다.
      - id: standby
        run: |
          VM=$(gcloud compute instances list \
            --filter="labels.role=standby" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
      #    CD는 이미지를 직접 빌드하지 않고, CI가 이미 push해둔 걸 그대로 가져다 쓴다.
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      # 3. frontend.env 생성.
      #    Vite 빌드 산출물은 런타임 env가 아니라 CI 빌드 타임에 값이 굳어지는 구조라
      #    (VITE_* 값은 이미 CI에서 빌드된 정적 파일에 포함됨), 컨테이너 실행에는 별도
      #    런타임 환경변수가 필요 없다. 다만 docker-compose.yml이 env_file로 이 파일을
      #    참조하므로, 파일 자체가 없으면 docker compose가 실패한다 — 매 배포마다 빈
      #    파일을 새로 만들어 VM으로 보낸다 (VM에 수동으로 만들어둔 파일에 의존하지 않기 위함).
      - run: touch frontend.env

      # 4. env 파일을 Standby VM으로 전송하고, 새 이미지를 pull해서 frontend 컨테이너만 교체한다.
      #    같은 VM 위의 backend/ai/nginx 컨테이너는 건드리지 않는다.
      - run: |
          gcloud compute scp frontend.env "${{ steps.standby.outputs.vm }}:~/app/frontend.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            cd ~/app &&
            export IMAGE_TAG=${{ steps.image.outputs.tag }} &&
            docker compose pull frontend &&
            docker compose up -d frontend
          "

      # 5. 컨테이너가 healthy 상태가 될 때까지 대기 (Backend CD와 동일한 패턴).
      #    이게 없으면 승인이 컨테이너 기동 완료 전에 빨리 눌렸을 때 502 다운타임이 생긴다.
      - name: "Wait for frontend container to be healthy"
        run: |
          gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            for i in \$(seq 1 20); do
              status=\$(sudo docker inspect frontend --format='{{.State.Health.Status}}')
              if [ \"\$status\" = \"healthy\" ]; then
                echo \"frontend healthy (시도 \$i/20)\"
                exit 0
              fi
              echo \"대기 중... (\$i/20) 현재 상태: \$status\"
              sleep 3
            done
            echo \"frontend가 60초 안에 healthy가 되지 않았습니다.\"
            exit 1
          "

  # ============================================================
  # 2단계 job: 실제 트래픽 전환 (Blue-Green Switch).
  # environment: production 이 붙어있어서, GitHub Settings에서 지정한 승인자가
  # Actions 탭에서 "Approve and deploy"를 눌러야만 이 job이 실행된다.
  # deploy가 실패하면 이 job은 아예 시작되지 않고 승인 요청도 안 뜬다.
  # ============================================================
  switch-traffic:
    needs: deploy
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v2

      # 6. 현재 role=active인 VM(지금까지 트래픽을 받던 VM)을 찾는다.
      # 7. 고정 IP를 Active VM에서 떼어내고 검증이 끝난 Standby VM에 붙인다.
      #    이 순간부터 실제 사용자 트래픽이 새 버전으로 넘어간다.
      # 8. 라벨을 서로 바꾼다 — 방금 전환된 VM이 새 active, 기존 active는 새 standby가 되어
      #    다음 배포 때 그쪽이 배포 대상이 된다.
      - run: |
          ACTIVE_VM=$(gcloud compute instances list \
            --filter="labels.role=active" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          STANDBY_VM="${{ needs.deploy.outputs.standby_vm }}"

          # ACTIVE와 STANDBY가 같으면 이미 전환됐거나 오래된 승인 요청이다 — 라벨/IP를 망가뜨리지 않도록 중단한다.
          if [ "$ACTIVE_VM" = "$STANDBY_VM" ]; then
            echo "ACTIVE_VM과 STANDBY_VM이 동일합니다 ($ACTIVE_VM). 이미 전환되었거나 오래된 승인 요청일 수 있어 중단합니다."
            exit 1
          fi
          
          # 세 CD(backend/frontend/ai)가 서로 독립적으로 이 라벨/IP를 바꾸다 보니, 이
          # CD가 스위치해도 다른 두 서비스는 이 VM에 예전에 마지막으로 배포됐던 버전이 남아있을 수도 있음.
          # active가 되기 전에 다른 서비스들도 최신 태그인지 확인해 동기화한다.
          for SERVICE in backend ai; do
            LATEST=$(gcloud artifacts docker images list \
              "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/$SERVICE/$SERVICE" \
              --include-tags --sort-by="~UPDATE_TIME" --limit=1 --format="value(TAGS)" | cut -d',' -f1)

            gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              cd ~/app &&
              CURRENT=\$(docker inspect $SERVICE --format='{{.Config.Image}}' | rev | cut -d: -f1 | rev) &&
              if [ \"\$CURRENT\" != \"$LATEST\" ]; then
                echo \"$SERVICE 동기화: \$CURRENT -> $LATEST\" &&
                export IMAGE_TAG=$LATEST &&
                docker compose pull $SERVICE &&
                docker compose up -d $SERVICE
              else
                echo \"$SERVICE 이미 최신 (\$CURRENT)\"
              fi
            "
          done

          gcloud compute instances delete-access-config "$ACTIVE_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT"

          # Cloud NAT 미도입 상태라 standby가 자체 임시 외부 IP를 갖고 있을 수 있다.
          # 고정 IP를 붙이기 전에 기존 access-config를 먼저 제거한다 (없으면 무시하고 진행).
          gcloud compute instances delete-access-config "$STANDBY_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT" || true

          gcloud compute instances add-access-config "$STANDBY_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT" \
            --address="${{ secrets.MURO_PROD_IP }}"

          gcloud compute instances add-labels "$STANDBY_VM" --zone="${{ env.ZONE }}" --labels=role=active
          gcloud compute instances add-labels "$ACTIVE_VM" --zone="${{ env.ZONE }}" --labels=role=standby

          # 임시 외부 IP를 다시 붙여서 인터넷 접속을 유지시켜준다.
          gcloud compute instances add-access-config "$ACTIVE_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT"

          # 9. nginx는 backend/frontend를 도커 서비스명(예: server frontend:3000)으로 프록시하는데,
          #    시작/리로드 시점에 한 번만 DNS를 해석해 캐싱한다. 방금 재배포로 frontend 컨테이너의
          #    IP가 바뀌었으므로, 지금 active가 된 이 VM의 nginx를 reload해 새 IP를 반영한다
          #    (2026-09-28 backend에서 이 문제로 전체 502가 났던 사고의 재발 방지).
          gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            sudo docker exec nginx nginx -s reload
          "

Backend CD

name: Backend CD

# CI(Backend CI)가 성공적으로 끝나면 자동으로 실행된다.
# workflow_dispatch는 CI 없이 재배포만 다시 시도하고 싶을 때 수동으로 쓰기 위한 것.
# 2026-10-01: CI.yaml이 develop 병합 과정에서 "main CI with backend" -> "Backend CI"로
# 이름이 바뀌었는데 여기를 안 맞춰서, 그 사이 Backend CD가 자동으로 전혀 안 돌았다.
on:
  workflow_run:
    workflows: ["Backend CI"]
    types: [completed]
    # CI 트리거를 develop 등 다른 브랜치로 넓혀도 CD가 따라 돌지 않도록 방어적으로 명시한다.
    branches: [main]
  workflow_dispatch:

env:
  ZONE: asia-northeast3-a

jobs:
  # ============================================================
  # 1단계 job: Standby VM에 새 버전을 자동으로 배포한다.
  # 스모크 테스트는 CI 쪽 테스트 코드로 대체하기로 해서 여기선 하지 않는다.
  # 이 job이 실패하면 switch-traffic job은 아예 시작되지 않고 승인 요청도 안 뜬다.
  # ============================================================
  deploy:
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # GCP WIF 인증용 OIDC 토큰 발급 권한
    outputs:
      standby_vm: ${{ steps.standby.outputs.vm }}   # switch-traffic job에서도 이 값을 써야 함
    steps:
      # WIF로 GCP 서비스 계정 인증 (키 파일 없이 임시 토큰 발급)
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v2

      # 1. 현재 role=standby 라벨이 붙은 VM을 찾는다.
      #    배포는 항상 Standby(트래픽 안 받는 쪽)에만 한다. Active는 여기서 절대 안 건드린다.
      - id: standby
        run: |
          VM=$(gcloud compute instances list \
            --filter="labels.role=standby" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI(semantic-release)가 방금 push한 이미지 중 가장 최근 태그를 조회한다.
      #    CD는 이미지를 직접 빌드하지 않고, CI가 이미 push해둔 걸 그대로 가져다 쓴다.
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      # 3. backend.env 파일을 GitHub Secrets 값으로 생성한다.
      #    이 파일은 여기서만 잠깐 만들어지고, 실제 값은 워크플로 로그에 자동으로 마스킹된다.
      - run: |
          cat > backend.env <<ENVEOF
          DB_URL=${{ secrets.DB_URL }}
          DB_USERNAME=${{ secrets.DB_USERNAME }}
          DB_PASSWORD=${{ secrets.DB_PASSWORD }}
          JWT_SECRET=${{ secrets.JWT_SECRET }}
          SENTRY_DSN=${{ secrets.SENTRY_DSN }}
          GCS_BUCKET_NAME=${{ secrets.GCS_BUCKET_NAME }}
          GCS_PROJECT_ID=${{ secrets.GCP_PROJECT_ID }}
          KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }}
          KMA_SERVICE_KEY=${{ secrets.KMA_SERVICE_KEY }}
          SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
          SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
          AI_BASE_URL=${{ secrets.AI_BASE_URL }}
          AI_INTERNAL_TOKEN=${{ secrets.AI_INTERNAL_TOKEN }}
          MULO_SECURITY_ALLOWED_ORIGINS=${{ secrets.MULO_SECURITY_ALLOWED_ORIGINS }}
          REPORT_OPERATION_TOKEN=${{ secrets.REPORT_OPERATION_TOKEN }}
          # 컨테이너 JVM 기본 시간대가 UTC라, java.sql.Timestamp -> LocalDateTime 변환 시
          # (Timestamp가 상속하는 java.util.Date의 레거시 getter가 JVM 기본시간대를 씀)
          # DB에 정상 저장된 KST 값을 9시간 밀려서 읽는 문제가 있었다. JVM 기본시간대 자체를
          # 앱이 실제 쓰는 Asia/Seoul로 맞춰서 이 변환 단계의 시간대 불일치를 없앤다.
          JAVA_TOOL_OPTIONS=-Duser.timezone=Asia/Seoul
          ENVEOF

      # 4. env 파일을 Standby VM으로 전송하고, 새 이미지를 pull해서 backend 컨테이너만 교체한다.
      #    같은 VM 위의 frontend/ai/nginx 컨테이너는 건드리지 않는다.
      - run: |
          gcloud compute scp backend.env "${{ steps.standby.outputs.vm }}:~/app/backend.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            cd ~/app &&
            export IMAGE_TAG=${{ steps.image.outputs.tag }} &&
            docker compose pull backend &&
            docker compose up -d backend
          "

      # 5. 컨테이너가 healthy 상태가 될 때까지 대기 (비즈니스 로직 검증용 스모크 테스트는 아님).
      #    이게 없으면 승인이 컨테이너 기동 완료 전에 빨리 눌렸을 때, 아직 준비 안 된
      #    컨테이너로 바로 트래픽이 전환되어 502 다운타임이 생긴다. 최대 60초(3초 x 20회) 대기.
      - name: "Wait for backend container to be healthy"
        run: |
          gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            for i in \$(seq 1 20); do
              status=\$(sudo docker inspect backend --format='{{.State.Health.Status}}')
              if [ \"\$status\" = \"healthy\" ]; then
                echo \"backend healthy (시도 \$i/20)\"
                exit 0
              fi
              echo \"대기 중... (\$i/20) 현재 상태: \$status\"
              sleep 3
            done
            echo \"backend가 60초 안에 healthy가 되지 않았습니다.\"
            exit 1
          "

  # ============================================================
  # 2단계 job: 실제 트래픽 전환 (Blue-Green Switch).
  # environment: production 이 붙어있어서, GitHub Settings에서 지정한 승인자가
  # Actions 탭에서 "Approve and deploy"를 눌러야만 이 job이 실행된다.
  # deploy가 실패하면 이 job은 아예 시작되지 않고 승인 요청도 안 뜬다.
  # ============================================================
  switch-traffic:
    needs: deploy
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v2

      # 6. 현재 role=active인 VM(지금까지 트래픽을 받던 VM)을 찾는다.
      # 7. 고정 IP를 Active VM에서 떼어내고 검증이 끝난 Standby VM에 붙인다.
      #    이 순간부터 실제 사용자 트래픽이 새 버전으로 넘어간다.
      # 8. 라벨을 서로 바꾼다 — 방금 전환된 VM이 새 active, 기존 active는 새 standby가 되어
      #    다음 배포 때 그쪽이 배포 대상이 된다.
      - run: |
          ACTIVE_VM=$(gcloud compute instances list \
            --filter="labels.role=active" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          STANDBY_VM="${{ needs.deploy.outputs.standby_vm }}"

          # ACTIVE와 STANDBY가 같으면 이미 전환됐거나 오래된 승인 요청이다 — 라벨/IP를 망가뜨리지 않도록 중단한다.
          if [ "$ACTIVE_VM" = "$STANDBY_VM" ]; then
            echo "ACTIVE_VM과 STANDBY_VM이 동일합니다 ($ACTIVE_VM). 이미 전환되었거나 오래된 승인 요청일 수 있어 중단합니다."
            exit 1
          fi

          # 세 CD(backend/frontend/ai)가 서로 독립적으로 이 라벨/IP를 바꾸다 보니, 이
          # CD가 스위치해도 다른 두 서비스는 이 VM에 예전에 마지막으로 배포됐던 버전이 남아있을 수도 있음.
          # active가 되기 전에 다른 서비스들도 최신 태그인지 확인해 동기화한다.
          for SERVICE in frontend ai; do
            LATEST=$(gcloud artifacts docker images list \
              "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/$SERVICE/$SERVICE" \
              --include-tags --sort-by="~UPDATE_TIME" --limit=1 --format="value(TAGS)" | cut -d',' -f1)

            gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              cd ~/app &&
              CURRENT=\$(docker inspect $SERVICE --format='{{.Config.Image}}' | rev | cut -d: -f1 | rev) &&
              if [ \"\$CURRENT\" != \"$LATEST\" ]; then
                echo \"$SERVICE 동기화: \$CURRENT -> $LATEST\" &&
                export IMAGE_TAG=$LATEST &&
                docker compose pull $SERVICE &&
                docker compose up -d $SERVICE
              else
                echo \"$SERVICE 이미 최신 (\$CURRENT)\"
              fi
            "
          done

          gcloud compute instances delete-access-config "$ACTIVE_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT"

          # Cloud NAT 미도입 상태라 standby가 자체 임시 외부 IP를 갖고 있을 수 있다.
          # 고정 IP를 붙이기 전에 기존 access-config를 먼저 제거한다 (없으면 무시하고 진행).
          gcloud compute instances delete-access-config "$STANDBY_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT" || true

          gcloud compute instances add-access-config "$STANDBY_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT" \
            --address="${{ secrets.MURO_PROD_IP }}"

          gcloud compute instances add-labels "$STANDBY_VM" --zone="${{ env.ZONE }}" --labels=role=active
          gcloud compute instances add-labels "$ACTIVE_VM" --zone="${{ env.ZONE }}" --labels=role=standby

          # 방금 standby가 된 VM(옛 active)은 고정 IP를 뗀 채로 남는다.
          # Cloud NAT이 없는 상태라 이대로 두면 다음 배포 때 이미지 pull이 안 되므로,
          # 임시(ephemeral) 외부 IP를 다시 붙여서 인터넷 접속을 유지시켜준다.
          gcloud compute instances add-access-config "$ACTIVE_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT"

AI CD

name: AI CD

on:
  workflow_run:
    workflows: ["AI CI main"]
    types: [completed]
  workflow_dispatch:

env:
  ZONE: asia-northeast3-a

jobs:
  # 스모크 테스트는 CI 쪽 테스트 코드로 대체하기로 해서 여기선 배포만 한다.
  deploy:
    if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    outputs:
      standby_vm: ${{ steps.standby.outputs.vm }}
    steps:
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v2

      # 1. 현재 Standby VM 판별
      - id: standby
        run: |
          VM=$(gcloud compute instances list \
            --filter="labels.role=standby" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          echo "vm=$VM" >> "$GITHUB_OUTPUT"

      # 2. CI가 방금 push한 최신 이미지 태그 조회
      - id: image
        run: |
          TAG=$(gcloud artifacts docker images list \
            "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.PROJECT_REPOSITORY }}/${{ secrets.IMAGE_NAME }}" \
            --include-tags --sort-by="~UPDATE_TIME" --limit=1 \
            --format="value(TAGS)" | cut -d',' -f1)
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"

      # 3. ai.env 생성 (GitHub Secrets 값으로)
      - run: |
          cat > ai.env <<ENVEOF
          APP_ENV=${{ secrets.APP_ENV }}
          INTERNAL_TOKEN=${{ secrets.INTERNAL_TOKEN }}
          DATABASE_URL=${{ secrets.DATABASE_URL }}
          AI_CLIENT_MODE=${{ secrets.AI_CLIENT_MODE }}
          ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
          LLM_MODEL=${{ secrets.LLM_MODEL }}
          OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
          TEXT_EMBEDDING_MODEL=${{ secrets.TEXT_EMBEDDING_MODEL }}
          TEXT_EMBEDDING_DIM=${{ secrets.TEXT_EMBEDDING_DIM }}
          REPLICATE_API_TOKEN=${{ secrets.REPLICATE_API_TOKEN }}
          CLIP_MODEL=${{ secrets.CLIP_MODEL }}
          CLIP_EMBEDDING_DIM=${{ secrets.CLIP_EMBEDDING_DIM }}
          SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
          SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}
          YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }}
          BACKEND_MYSQL_URL=${{ secrets.BACKEND_MYSQL_URL }}
          BACKEND_INTERNAL_URL=${{ secrets.BACKEND_INTERNAL_URL }}
          RUN_WORKER=false
          ENVEOF

      # 4. Standby VM에 env 전송 + ai 컨테이너만 새 이미지로 교체
      - run: |
          gcloud compute scp ai.env "${{ steps.standby.outputs.vm }}:~/app/ai.env" \
            --zone="${{ env.ZONE }}" --tunnel-through-iap

          gcloud compute ssh "${{ steps.standby.outputs.vm }}" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            cd ~/app &&
            export IMAGE_TAG=${{ steps.image.outputs.tag }} &&
            docker compose pull ai &&
            docker compose up -d ai
          "

  switch-traffic:
    needs: deploy
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: google-github-actions/setup-gcloud@v2

      # 6~8. 현재 Active VM 판별 → 고정 IP를 Standby로 이전 → 라벨 교체
      - run: |
          ACTIVE_VM=$(gcloud compute instances list \
            --filter="labels.role=active" \
            --zones="${{ env.ZONE }}" \
            --format="value(name)")
          STANDBY_VM="${{ needs.deploy.outputs.standby_vm }}"

          # ACTIVE와 STANDBY가 같으면 이미 전환됐거나 오래된 승인 요청이다 — 라벨/IP를 망가뜨리지 않도록 중단한다.
          if [ "$ACTIVE_VM" = "$STANDBY_VM" ]; then
            echo "ACTIVE_VM과 STANDBY_VM이 동일합니다 ($ACTIVE_VM). 이미 전환되었거나 오래된 승인 요청일 수 있어 중단합니다."
            exit 1
          fi

          # 세 CD(backend/frontend/ai)가 서로 독립적으로 이 라벨/IP를 바꾸다 보니, 이
          # CD가 스위치해도 다른 두 서비스는 이 VM에 예전에 마지막으로 배포됐던 버전
          # 그대로 남아있을 수 있다(2026-09-30 backend/frontend 양쪽에서 실제 발생).
          # active가 되기 전에 다른 서비스들도 최신 태그인지 확인해 동기화한다.
          for SERVICE in backend frontend; do
            LATEST=$(gcloud artifacts docker images list \
              "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/$SERVICE/$SERVICE" \
              --include-tags --sort-by="~UPDATE_TIME" --limit=1 --format="value(TAGS)" | cut -d',' -f1)

            gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
              cd ~/app &&
              CURRENT=\$(docker inspect $SERVICE --format='{{.Config.Image}}' | rev | cut -d: -f1 | rev) &&
              if [ \"\$CURRENT\" != \"$LATEST\" ]; then
                echo \"$SERVICE 동기화: \$CURRENT -> $LATEST\" &&
                export IMAGE_TAG=$LATEST &&
                docker compose pull $SERVICE &&
                docker compose up -d $SERVICE
              else
                echo \"$SERVICE 이미 최신 (\$CURRENT)\"
              fi
            "
          done

          # 새 운영 VM 워커 켜기 — 아직 트래픽을 안 받는 상태라 재시작해도 서비스 영향 없음.
          # IMAGE_TAG는 deploy job의 SSH 세션에서만 export됐고 여긴 새 세션이라 비어있으므로,
          # 재pull 없이 그 VM이 지금 실제로 돌리고 있는 이미지 태그를 그대로 읽어서 재사용한다.
          gcloud compute ssh "$STANDBY_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            cd ~/app &&
            export IMAGE_TAG=\$(docker inspect ai --format='{{.Config.Image}}' | cut -d: -f2) &&
            sed -i 's/^RUN_WORKER=.*/RUN_WORKER=true/' ai.env &&
            docker compose up -d --force-recreate ai
          "

          gcloud compute instances delete-access-config "$ACTIVE_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT"

          # Cloud NAT 미도입 상태라 standby가 자체 임시 외부 IP를 갖고 있을 수 있다.
          # 고정 IP를 붙이기 전에 기존 access-config를 먼저 제거한다 (없으면 무시하고 진행).
          gcloud compute instances delete-access-config "$STANDBY_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT" || true

          gcloud compute instances add-access-config "$STANDBY_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT" \
            --address="${{ secrets.MURO_PROD_IP }}"

          gcloud compute instances add-labels "$STANDBY_VM" --zone="${{ env.ZONE }}" --labels=role=active
          gcloud compute instances add-labels "$ACTIVE_VM" --zone="${{ env.ZONE }}" --labels=role=standby

          # 방금 standby가 된 VM(옛 active)은 고정 IP를 뗀 채로 남는다.
          # Cloud NAT이 없는 상태라 이대로 두면 다음 배포 때 이미지 pull이 안 되므로,
          # 임시(ephemeral) 외부 IP를 다시 붙여서 인터넷 접속을 유지시켜준다.
          gcloud compute instances add-access-config "$ACTIVE_VM" \
            --zone="${{ env.ZONE }}" --access-config-name="External NAT"

          # 옛 운영 VM 워커 끄기 — 이미 트래픽이 빠진 상태라 재시작해도 서비스 영향 없음.
          # 여기도 새 세션이라 IMAGE_TAG가 비어있으므로, 이 VM이 지금 돌리고 있는(옛) 이미지
          # 태그를 그대로 읽어서 재사용한다 — 새로 배포된 태그로 실수로 덮어쓰지 않기 위함이다.
          gcloud compute ssh "$ACTIVE_VM" --zone="${{ env.ZONE }}" --tunnel-through-iap --command="
            cd ~/app &&
            export IMAGE_TAG=\$(docker inspect ai --format='{{.Config.Image}}' | cut -d: -f2) &&
            (grep -q '^RUN_WORKER=' ai.env && sed -i 's/^RUN_WORKER=.*/RUN_WORKER=false/' ai.env || echo 'RUN_WORKER=false' >> ai.env) &&
            docker compose up -d --force-recreate ai
          "

3. GCP SA 및 IAM 권한 생성 (CI/CD)

핵심 개념

  1. GCP 서비스 계정(SA)에 Artifact Registry에 이미지 push할 권한을 준다.
  2. GCP한테 "내 GitHub 레포에서 오는 요청이면 저 서비스 계정을 잠깐 빌려줘도 된다"고 등록해둔다 (이게 WIF Pool + Provider + 바인딩).
  3. GitHub Actions는 실행될 때마다 "나 진짜 이 레포에서 실행 중이야"라는 일회용 신분증(OIDC 토큰)을 만들고, GCP는 그걸 확인해서 2번 등록된 요청이면 임시 토큰을 내어준다. 그 토큰으로 push한다.
  • 키 파일을 저장 안 해도 되는 이유는 "영구 비밀번호" 대신 "매번 새로 발급되는 1회용 출입증"을 쓰기 때문
PROJECT_ID="project-7b071b48-8637-467a-80d"
SA_NAME="github-actions-ci"
GITHUB_ORG="100-hours-a-week"

1) 서비스 계정 생성 + Artifact Registry 권한

gcloud iam service-accounts create"github-actions-ci" \
  --project="project-7b071b48-8637-467a-80d" \
  --display-name="GitHub Actions CI"

gcloud projects add-iam-policy-binding"project-7b071b48-8637-467a-80d" \
  --member="serviceAccount:"github-actions-ci"@"project-7b071b48-8637-467a-80d".iam.gserviceaccount.com" \
  --role="roles/artifactregistry.writer"

2) Workload Identity Pool 생성

gcloudiamworkload-identity-poolscreate"github-pool"\
--project="project-7b071b48-8637-467a-80d"\
--location="global"\
--display-name="GitHub Actions Pool"

3) Provider 생성

gcloud iam workload-identity-pools providers create-oidc "github-provider" \

--project="project-7b071b48-8637-467a-80d" \

--location="global" \

--workload-identity-pool="github-pool" \

--display-name="GitHub Provider" \

--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \

--attribute-condition="assertion.repository_owner=='100-hours-a-week'" \

--issuer-uri="https://token.actions.githubusercontent.com"

4) 서비스 계정 바인딩 — 레포 3개

for REPOin"KTB4-16th-FE""KTB4-16th-BE""KTB4-16th-AI";do
gcloudiamservice-accountsadd-iam-policy-binding\
""github-actions-ci"@"project-7b071b48-8637-467a-80d".iam.gserviceaccount.com"\
--project="project-7b071b48-8637-467a-80d"\
--role="roles/iam.workloadIdentityUser"\
--member="principalSet://iam.googleapis.com/projects/953840519216/locations/global/workloadIdentityPools/github-pool/attribute.repository/100-hours-a-week/KTB4-16th-FE"
--member="principalSet://iam.googleapis.com/projects/953840519216/locations/global/workloadIdentityPools/github-pool/attribute.repository/100-hours-a-week/KTB4-16th-BE"
--member="principalSet://iam.googleapis.com/projects/953840519216/locations/global/workloadIdentityPools/github-pool/attribute.repository/100-hours-a-week/KTB4-16th-AI"
done

5) Provider 전체 경로 확인 (워크플로우에 넣을 값)

gcloud iam workload-identity-pools providers describe "github-provider" \
  --project="project-7b071b48-8637-467a-80d" \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --format="value(name)"
  1. CD 를 위한 권한 부여
  • Compute Engine(VM)에 SSH로 로그인할 때, 가장 높은 관리자(root/sudo) 권한
  • IAP 터널 우회 접속 권한 부여
gcloud projects add-iam-policy-binding "project-7b071b48-8637-467a-80d" \
  --member="serviceAccount:github-actions-ci@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com" \
  --role="roles/compute.osAdminLogin"

gcloud projects add-iam-policy-binding "project-7b071b48-8637-467a-80d" \
  --member="serviceAccount:github-actions-ci@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com" \
  --role="roles/iap.tunnelResourceAccessor"

최종 값들

GCP_PROJECT_ID = project-7b071b48-8637-467a-80d
WORKLOAD_IDENTITY_PROVIDER = projects/953840519216/locations/global/workloadIdentityPools/github-pool/providers/github-provider
SERVICE_ACCOUNT = github-actions-ci@project-7b071b48-8637-467a-80d.iam.gserviceaccount.com
ARTIFACT_REGISTRY_HOST = asia-northeast3-docker.pkg.dev   
⚠️ **GitHub.com Fallback** ⚠️