Skip to content

Merge pull request #676 from code-zero-to-one/cherry/feat-banner-to-main #164

Merge pull request #676 from code-zero-to-one/cherry/feat-banner-to-main

Merge pull request #676 from code-zero-to-one/cherry/feat-banner-to-main #164

Workflow file for this run

# 도커 허브에 immutable production image를 push 후 서버에 배포하고 release record를 남긴다.
name: Front Production Server (Main)
on:
push:
branches:
- main
paths-ignore:
- 'releases/**'
- 'ops/**'
workflow_dispatch:
inputs:
release_summary:
description: 'Release summary written to releases/prod-YYYYMMDD-HHmm.yaml'
required: false
default: 'Manual production frontend deployment'
release_intent:
description: 'major, minor, or patch (manual dispatch only)'
required: false
default: 'patch'
permissions:
contents: write
pull-requests: read
concurrency:
group: prod-release-record
cancel-in-progress: false
env:
FRONTEND_IMAGE_REPOSITORY: zerooneitkr/zeroone-frontend
jobs:
build-and-push-image:
if: ${{ !contains(github.event.head_commit.message || '', '[release-record]') }}
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.meta.outputs.release_id }}
deployed_at: ${{ steps.meta.outputs.deployed_at }}
frontend_image: ${{ steps.meta.outputs.frontend_image }}
frontend_commit: ${{ steps.meta.outputs.frontend_commit }}
frontend_version: ${{ steps.meta.outputs.frontend_version }}
release_summary: ${{ steps.meta.outputs.release_summary }}
source_pr_number: ${{ steps.meta.outputs.source_pr_number }}
release_type: ${{ steps.meta.outputs.release_type }}
frontend_deploy_id: ${{ steps.meta.outputs.frontend_deploy_id }}
backend_deploy_id: ${{ steps.meta.outputs.backend_deploy_id }}
paired_backend_deploy_id: ${{ steps.meta.outputs.paired_backend_deploy_id }}
backend_release_intent: ${{ steps.meta.outputs.backend_release_intent }}
bootstrap_mode: ${{ steps.meta.outputs.bootstrap_mode }}
backend_changed: ${{ steps.meta.outputs.backend_changed }}
backend_image: ${{ steps.meta.outputs.backend_image }}
backend_commit: ${{ steps.meta.outputs.backend_commit }}
backend_version: ${{ steps.meta.outputs.backend_version }}
db_changed: ${{ steps.meta.outputs.db_changed }}
db_migration_version: ${{ steps.meta.outputs.db_migration_version }}
db_migration_files: ${{ steps.meta.outputs.db_migration_files }}
rollback_frontend_image: ${{ steps.meta.outputs.rollback_frontend_image }}
rollback_backend_image: ${{ steps.meta.outputs.rollback_backend_image }}
db_rollback_note: ${{ steps.meta.outputs.db_rollback_note }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Resolve release intent and metadata
id: meta
env:
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_RELEASE_SUMMARY: "${{ github.event.inputs.release_summary || '' }}"
RELEASE_INTENT_BODY: "${{ github.event_name == 'workflow_dispatch' && format('release: {0}', github.event.inputs.release_intent || 'patch') || '' }}"
run: node scripts/release/resolve-prod-release-intent.mjs
- name: Create production env file
run: |
echo "NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_PROD_BASE_URL }}" > .env
echo "NEXT_PUBLIC_GTM_ID=${{ secrets.NEXT_PUBLIC_GTM_ID }}" >> .env
echo "NEXT_PUBLIC_KAKAO_CLIENT_ID=${{ secrets.NEXT_PUBLIC_PROD_KAKAO_CLIENT_ID }}" >> .env
echo "NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_PROD_GOOGLE_CLIENT_ID }}" >> .env
echo "NEXT_PUBLIC_STRAPI_URL=${{ secrets.NEXT_PUBLIC_PROD_STRAPI_URL }}" >> .env
echo "NEXT_PUBLIC_TOSS_CLIENT_KEY=${{ secrets.NEXT_PUBLIC_PROD_TOSS_CLIENT_KEY }}" >> .env
echo "NEXT_PUBLIC_CLARITY_PROJECT_ID=${{ secrets.NEXT_PUBLIC_CLARITY_PROJECT_ID }}" >> .env
echo "NEXT_PUBLIC_GOOGLE_SHEETS_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_SHEETS_ID }}" >> .env
echo "GOOGLE_SERVICE_ACCOUNT_EMAIL=${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}" >> .env
echo "GOOGLE_PRIVATE_KEY=${{ secrets.GOOGLE_PRIVATE_KEY }}" >> .env
- name: DockerHub login
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Docker image build and push
run: |
set -euo pipefail
docker build -f Dockerfile.prod \
-t "${{ steps.meta.outputs.frontend_image }}" \
-t "${FRONTEND_IMAGE_REPOSITORY}:prod" \
-t "${FRONTEND_IMAGE_REPOSITORY}:latest-prod" \
.
docker push "${{ steps.meta.outputs.frontend_image }}"
docker push "${FRONTEND_IMAGE_REPOSITORY}:prod"
docker push "${FRONTEND_IMAGE_REPOSITORY}:latest-prod"
deploy-image-to-server:
runs-on: ubuntu-latest
needs: build-and-push-image
env:
FRONTEND_IMAGE: zerooneitkr/zeroone-frontend:${{ needs.build-and-push-image.outputs.frontend_version }}-${{ needs.build-and-push-image.outputs.frontend_commit }}
RELEASE_ID: ${{ needs.build-and-push-image.outputs.release_id }}
DEPLOYED_AT: ${{ needs.build-and-push-image.outputs.deployed_at }}
FRONTEND_COMMIT: ${{ needs.build-and-push-image.outputs.frontend_commit }}
FRONTEND_VERSION: ${{ needs.build-and-push-image.outputs.frontend_version }}
RELEASE_SUMMARY: ${{ needs.build-and-push-image.outputs.release_summary }}
SOURCE_PR_NUMBER: ${{ needs.build-and-push-image.outputs.source_pr_number }}
RELEASE_TYPE: ${{ needs.build-and-push-image.outputs.release_type }}
FRONTEND_DEPLOY_ID: ${{ needs.build-and-push-image.outputs.frontend_deploy_id }}
RELEASE_BACKEND_DEPLOY_ID: ${{ needs.build-and-push-image.outputs.backend_deploy_id }}
RELEASE_PAIRED_BACKEND_DEPLOY_ID: ${{ needs.build-and-push-image.outputs.paired_backend_deploy_id }}
RELEASE_BACKEND_RELEASE_INTENT: ${{ needs.build-and-push-image.outputs.backend_release_intent }}
RELEASE_BOOTSTRAP_MODE: ${{ needs.build-and-push-image.outputs.bootstrap_mode }}
RELEASE_BACKEND_CHANGED: ${{ needs.build-and-push-image.outputs.backend_changed }}
RELEASE_BACKEND_IMAGE: ${{ needs.build-and-push-image.outputs.backend_image }}
RELEASE_BACKEND_COMMIT: ${{ needs.build-and-push-image.outputs.backend_commit }}
RELEASE_BACKEND_VERSION: ${{ needs.build-and-push-image.outputs.backend_version }}
RELEASE_DB_CHANGED: ${{ needs.build-and-push-image.outputs.db_changed }}
RELEASE_DB_MIGRATION_VERSION: ${{ needs.build-and-push-image.outputs.db_migration_version }}
RELEASE_DB_MIGRATION_FILES: ${{ needs.build-and-push-image.outputs.db_migration_files }}
RELEASE_ROLLBACK_FRONTEND_IMAGE: ${{ needs.build-and-push-image.outputs.rollback_frontend_image }}
RELEASE_ROLLBACK_BACKEND_IMAGE: ${{ needs.build-and-push-image.outputs.rollback_backend_image }}
RELEASE_DB_ROLLBACK_NOTE: ${{ needs.build-and-push-image.outputs.db_rollback_note }}
PROD_E2E_BASE_URL: ${{ vars.PROD_E2E_BASE_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install cloudflared
run: |
echo "Downloading cloudflared..."
curl -L --output /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
chmod +x /tmp/cloudflared
sudo mv /tmp/cloudflared /usr/local/bin/cloudflared
cloudflared --version || echo "cloudflared 설치 확인 실패"
- name: Install sshpass
run: |
echo "Installing sshpass..."
sudo apt-get update -qq
sudo apt-get install -y -qq sshpass
sshpass -V || echo "sshpass 설치 확인 실패"
- name: Setup SSH config
run: |
echo "Setting up SSH config..."
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -p 24 -H ssh.zeroone.it.kr >> ~/.ssh/known_hosts 2>&1 || echo "ssh-keyscan 실패 (계속 진행)"
cat > ~/.ssh/config << 'SSHCONFIG'
Host ssh.zeroone.it.kr
HostName ssh.zeroone.it.kr
User zero_one
Port 24
ProxyCommand cloudflared access ssh --hostname %h
StrictHostKeyChecking no
SSHCONFIG
chmod 600 ~/.ssh/config
- name: Read current backend metadata
run: |
set -euo pipefail
sshpass -p '${{ secrets.SSH_PASSWORD }}' ssh ssh.zeroone.it.kr bash << 'EOF' > /tmp/prod-backend-metadata.env
SUDO_PASSWORD='${{ secrets.SSH_PASSWORD }}'
inspect_label() {
container="$1"
label="$2"
echo "$SUDO_PASSWORD" | sudo -S docker inspect "$container" --format "{{ index .Config.Labels \"$label\" }}" 2>/dev/null || true
}
inspect_image() {
container="$1"
echo "$SUDO_PASSWORD" | sudo -S docker inspect "$container" --format '{{.Config.Image}}' 2>/dev/null || true
}
for container in backend-prod zeroone-backend backend app-prod; do
image="$(inspect_image "$container")"
if [ -n "$image" ] && [ "$image" != "<no value>" ]; then
echo "INSPECTED_BACKEND_IMAGE=$image"
revision="$(inspect_label "$container" org.opencontainers.image.revision)"
version="$(inspect_label "$container" org.opencontainers.image.version)"
[ -n "$revision" ] && [ "$revision" != "<no value>" ] && echo "INSPECTED_BACKEND_COMMIT=$revision"
[ -n "$version" ] && [ "$version" != "<no value>" ] && echo "INSPECTED_BACKEND_VERSION=$version"
exit 0
fi
done
EOF
cat /tmp/prod-backend-metadata.env
cat /tmp/prod-backend-metadata.env >> "$GITHUB_ENV"
- name: Resolve release record metadata
run: |
set -euo pipefail
BACKEND_IMAGE="${RELEASE_BACKEND_IMAGE:-${INSPECTED_BACKEND_IMAGE:-}}"
BACKEND_COMMIT="${RELEASE_BACKEND_COMMIT:-${INSPECTED_BACKEND_COMMIT:-unknown}}"
BACKEND_VERSION="${RELEASE_BACKEND_VERSION:-${INSPECTED_BACKEND_VERSION:-unknown}}"
if [ -z "$BACKEND_IMAGE" ]; then
echo "::error::Backend image metadata is required. Add backend_image/backend_commit/backend_version to the release intent body for the first recorded production release."
exit 1
fi
if [ -n "${INSPECTED_BACKEND_IMAGE:-}" ] && [ "$INSPECTED_BACKEND_IMAGE" != "$BACKEND_IMAGE" ]; then
echo "::error::Inspected backend image ($INSPECTED_BACKEND_IMAGE) differs from latest release metadata ($BACKEND_IMAGE). Record the backend deployment through backend-prod-deployed before frontend production deploy."
exit 1
fi
{
echo "BACKEND_IMAGE=$BACKEND_IMAGE"
echo "BACKEND_COMMIT=$BACKEND_COMMIT"
echo "BACKEND_VERSION=$BACKEND_VERSION"
echo "BACKEND_CHANGED=${RELEASE_BACKEND_CHANGED:-false}"
echo "BACKEND_DEPLOY_ID=${RELEASE_BACKEND_DEPLOY_ID:-}"
echo "PAIRED_BACKEND_DEPLOY_ID=${RELEASE_PAIRED_BACKEND_DEPLOY_ID:-}"
echo "BACKEND_RELEASE_INTENT=${RELEASE_BACKEND_RELEASE_INTENT:-}"
echo "BOOTSTRAP_MODE=${RELEASE_BOOTSTRAP_MODE:-}"
echo "DB_CHANGED=${RELEASE_DB_CHANGED:-false}"
echo "DB_MIGRATION_VERSION=${RELEASE_DB_MIGRATION_VERSION:-N/A}"
echo "DB_MIGRATION_FILES=${RELEASE_DB_MIGRATION_FILES:-}"
echo "ROLLBACK_FRONTEND_IMAGE=${RELEASE_ROLLBACK_FRONTEND_IMAGE:-}"
echo "ROLLBACK_BACKEND_IMAGE=${RELEASE_ROLLBACK_BACKEND_IMAGE:-}"
echo "DB_ROLLBACK_NOTE=${RELEASE_DB_ROLLBACK_NOTE:-DB rollback is not automated. Confirm app compatibility with the recorded DB state.}"
} >> "$GITHUB_ENV"
- name: Deploy frontend image to production server
run: |
set -euo pipefail
: "${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}"
sshpass -p '${{ secrets.SSH_PASSWORD }}' ssh ssh.zeroone.it.kr \
"FRONTEND_IMAGE='$FRONTEND_IMAGE' bash -s" << 'EOF'
set -euo pipefail
SUDO_PASSWORD='${{ secrets.SSH_PASSWORD }}'
: "${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}"
echo "사용하지 않는 이미지, 네트워크 정리 중 (실행 중 컨테이너 보존, 볼륨 제외)..."
echo "$SUDO_PASSWORD" | sudo -S docker image prune -f || true
echo "$SUDO_PASSWORD" | sudo -S docker network prune -f || true
cd ~/front/study-platform-client-prod || {
echo "디렉토리가 없습니다. 생성합니다..."
mkdir -p ~/front/study-platform-client-prod
cd ~/front/study-platform-client-prod
}
echo ".env 파일 생성 (런타임에 필요한 모든 환경변수)"
echo "NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_PROD_BASE_URL }}" > .env
echo "NEXT_PUBLIC_GTM_ID=${{ secrets.NEXT_PUBLIC_GTM_ID }}" >> .env
echo "NEXT_PUBLIC_KAKAO_CLIENT_ID=${{ secrets.NEXT_PUBLIC_PROD_KAKAO_CLIENT_ID }}" >> .env
echo "NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_PROD_GOOGLE_CLIENT_ID }}" >> .env
echo "NEXT_PUBLIC_STRAPI_URL=${{ secrets.NEXT_PUBLIC_PROD_STRAPI_URL }}" >> .env
echo "NEXT_PUBLIC_TOSS_CLIENT_KEY=${{ secrets.NEXT_PUBLIC_PROD_TOSS_CLIENT_KEY }}" >> .env
echo "NEXT_PUBLIC_CLARITY_PROJECT_ID=${{ secrets.NEXT_PUBLIC_CLARITY_PROJECT_ID }}" >> .env
echo "NEXT_PUBLIC_GOOGLE_SHEETS_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_SHEETS_ID }}" >> .env
echo "GOOGLE_SERVICE_ACCOUNT_EMAIL=${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }}" >> .env
echo "GOOGLE_PRIVATE_KEY=${{ secrets.GOOGLE_PRIVATE_KEY }}" >> .env
echo "docker-compose.prod.yml 파일 생성"
cat > docker-compose.prod.yml << COMPOSE
services:
frontend:
container_name: frontend-prod
image: ${FRONTEND_IMAGE}
ports:
- '13755:13755'
env_file:
- .env
restart: unless-stopped
COMPOSE
echo "도커 이미지 pull: $FRONTEND_IMAGE"
echo "$SUDO_PASSWORD" | sudo -S docker pull "$FRONTEND_IMAGE"
echo "기존 컨테이너 교체"
echo "$SUDO_PASSWORD" | sudo -S docker compose -f docker-compose.prod.yml down || true
echo "$SUDO_PASSWORD" | sudo -S docker stop frontend-prod || true
echo "$SUDO_PASSWORD" | sudo -S docker rm frontend-prod || true
echo "도커 컴포즈 재시작"
echo "$SUDO_PASSWORD" | sudo -S docker compose -f docker-compose.prod.yml up -d
echo "$SUDO_PASSWORD" | sudo -S docker system prune -f || true
echo "운영 서버 배포 완료"
EOF
- name: Production smoke/E2E check
run: |
set -euo pipefail
if [ -z "${PROD_E2E_BASE_URL:-}" ]; then
echo "::warning::PROD_E2E_BASE_URL is not set. Skipping production smoke/E2E check."
exit 0
fi
curl --fail --retry 5 --retry-delay 5 --max-time 20 "$PROD_E2E_BASE_URL" >/dev/null
- name: Generate and validate release record
run: |
set -euo pipefail
RECORD_PATH="$(node scripts/release/generate-prod-release-record.mjs)"
node scripts/release/validate-release-record.mjs "$RECORD_PATH"
echo "RECORD_PATH=$RECORD_PATH" >> "$GITHUB_ENV"
- name: Commit release record
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "$RECORD_PATH"
git commit -m "chore(release): record ${RELEASE_ID} [release-record]"
git pull --rebase origin main
git push origin HEAD:main