From 3a559397456f852d3a3d9e1436a2bb73a38c452e Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Sun, 17 May 2026 19:19:49 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20=EA=B8=B0=EB=A1=9D=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: OmX --- .github/workflows/deploy-prod.yml | 247 +++++++++++++++--- .github/workflows/release-record-check.yml | 26 ++ AGENTS.md | 5 + CLAUDE.md | 1 + ops/deploy-checklist.md | 28 ++ ops/rollback.md | 27 ++ ops/version-management.md | 140 ++++++++++ package.json | 3 +- releases/.gitkeep | 0 .../release/generate-prod-release-record.mjs | 157 +++++++++++ scripts/release/validate-release-record.mjs | 124 +++++++++ 11 files changed, 722 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/release-record-check.yml create mode 100644 ops/deploy-checklist.md create mode 100644 ops/rollback.md create mode 100644 ops/version-management.md create mode 100644 releases/.gitkeep create mode 100644 scripts/release/generate-prod-release-record.mjs create mode 100644 scripts/release/validate-release-record.mjs diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 81b4f59a9..871b37e1e 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -1,21 +1,88 @@ -# 도커 허브에 이미지 push 후 서버에 배포하는 방식 (25.11.1 배포) 돌아라 +# 도커 허브에 immutable production image를 push 후 서버에 배포하고 release record를 남긴다. name: Front Production Server (Main) on: push: branches: - - main # main 브랜치에 push가 발생하면 실행 + - 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' + +permissions: + contents: write + +concurrency: + group: prod-frontend-deploy + cancel-in-progress: false + +env: + FRONTEND_IMAGE_REPOSITORY: zerooneitkr/zeroone-frontend jobs: - build-and-push-image: # 도커 이미지 빌드 및 도커 허브 push + 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 }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: ENV 파일 생성 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Compute release metadata + id: meta + run: | + set -euo pipefail + SHORT_SHA="${GITHUB_SHA::7}" + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + FRONTEND_VERSION="v${PACKAGE_VERSION#v}" + RELEASE_ID="prod-$(TZ=Asia/Seoul date +%Y%m%d-%H%M)" + DEPLOYED_AT="$(TZ=Asia/Seoul date +%Y-%m-%dT%H:%M:%S%:z)" + FRONTEND_IMAGE="${FRONTEND_IMAGE_REPOSITORY}:${FRONTEND_VERSION}-${SHORT_SHA}" + RELEASE_SUMMARY="${{ github.event.inputs.release_summary || 'Production frontend deployment' }}" + + echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT" + echo "deployed_at=${DEPLOYED_AT}" >> "$GITHUB_OUTPUT" + echo "frontend_image=${FRONTEND_IMAGE}" >> "$GITHUB_OUTPUT" + echo "frontend_commit=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "frontend_version=${FRONTEND_VERSION}" >> "$GITHUB_OUTPUT" + echo "release_summary=${RELEASE_SUMMARY}" >> "$GITHUB_OUTPUT" + + - name: Preflight rollback metadata + env: + ROLLBACK_FRONTEND_IMAGE: ${{ vars.PROD_ROLLBACK_FRONTEND_IMAGE }} + ROLLBACK_BACKEND_IMAGE: ${{ vars.PROD_ROLLBACK_BACKEND_IMAGE }} + run: | + set -euo pipefail + shopt -s nullglob + existing=(releases/prod-*.yaml) + if [ ${#existing[@]} -eq 0 ]; then + if [ -z "${ROLLBACK_FRONTEND_IMAGE:-}" ] || [ -z "${ROLLBACK_BACKEND_IMAGE:-}" ]; then + echo "::error::No previous release record exists. Set PROD_ROLLBACK_FRONTEND_IMAGE and PROD_ROLLBACK_BACKEND_IMAGE repository variables to fixed image tags for the first recorded production deployment." + exit 1 + fi + fi + + - 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 @@ -23,29 +90,62 @@ jobs: 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 - - # 이 부분 부터 점검 필요 - 프로덕션 환경에서도 같은걸 씀? (GTM도 프로덕션 데브 같이쓰는게 맞음? dev 엔 현재 빠진듯?) - echo "NEXT_PUBLIC_CLARITY_PROJECT_ID=${{ secrets.NEXT_PUBLIC_CLARITY_PROJECT_ID }}" >> .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 로그인 + - name: DockerHub login uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - - name: Docker 이미지 빌드 및 push + - name: Docker image build and push run: | - docker build -f Dockerfile.prod -t zerooneitkr/frontend:prod . - docker push zerooneitkr/frontend:prod + 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: # 도커 허브 pull 후 서버에 배포 + deploy-image-to-server: runs-on: ubuntu-latest needs: build-and-push-image + env: + FRONTEND_IMAGE: ${{ needs.build-and-push-image.outputs.frontend_image }} + 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 }} + PROD_BACKEND_IMAGE: ${{ vars.PROD_BACKEND_IMAGE }} + PROD_BACKEND_COMMIT: ${{ vars.PROD_BACKEND_COMMIT }} + PROD_BACKEND_VERSION: ${{ vars.PROD_BACKEND_VERSION }} + PROD_DB_CHANGED: ${{ vars.PROD_DB_CHANGED }} + PROD_DB_MIGRATION_VERSION: ${{ vars.PROD_DB_MIGRATION_VERSION }} + PROD_DB_MIGRATION_FILES: ${{ vars.PROD_DB_MIGRATION_FILES }} + PROD_ROLLBACK_FRONTEND_IMAGE: ${{ vars.PROD_ROLLBACK_FRONTEND_IMAGE }} + PROD_ROLLBACK_BACKEND_IMAGE: ${{ vars.PROD_ROLLBACK_BACKEND_IMAGE }} + PROD_DB_ROLLBACK_NOTE: ${{ vars.PROD_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..." @@ -66,11 +166,7 @@ jobs: echo "Setting up SSH config..." mkdir -p ~/.ssh chmod 700 ~/.ssh - - # known_hosts 설정 (실패해도 계속 진행) ssh-keyscan -p 24 -H ssh.zeroone.it.kr >> ~/.ssh/known_hosts 2>&1 || echo "ssh-keyscan 실패 (계속 진행)" - - # SSH config 생성 cat > ~/.ssh/config << 'SSHCONFIG' Host ssh.zeroone.it.kr HostName ssh.zeroone.it.kr @@ -80,28 +176,81 @@ jobs: StrictHostKeyChecking no SSHCONFIG chmod 600 ~/.ssh/config - echo "SSH config 생성 완료" - cat ~/.ssh/config - - name: 서버 배포 + - 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 }}' - sshpass -p '${{ secrets.SSH_PASSWORD }}' ssh ssh.zeroone.it.kr bash << EOF + 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" != "" ]; 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" != "" ] && echo "INSPECTED_BACKEND_COMMIT=$revision" + [ -n "$version" ] && [ "$version" != "" ] && 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 metadata + run: | + set -euo pipefail + BACKEND_IMAGE="${PROD_BACKEND_IMAGE:-${INSPECTED_BACKEND_IMAGE:-}}" + BACKEND_COMMIT="${PROD_BACKEND_COMMIT:-${INSPECTED_BACKEND_COMMIT:-unknown}}" + BACKEND_VERSION="${PROD_BACKEND_VERSION:-${INSPECTED_BACKEND_VERSION:-unknown}}" + + if [ -z "$BACKEND_IMAGE" ]; then + echo "::error::Backend image metadata is required. Set PROD_BACKEND_IMAGE or ensure the backend production container can be inspected." + exit 1 + fi + + { + echo "BACKEND_IMAGE=$BACKEND_IMAGE" + echo "BACKEND_COMMIT=$BACKEND_COMMIT" + echo "BACKEND_VERSION=$BACKEND_VERSION" + echo "DB_CHANGED=${PROD_DB_CHANGED:-false}" + echo "DB_MIGRATION_VERSION=${PROD_DB_MIGRATION_VERSION:-none}" + echo "DB_MIGRATION_FILES=${PROD_DB_MIGRATION_FILES:-}" + echo "ROLLBACK_FRONTEND_IMAGE=${PROD_ROLLBACK_FRONTEND_IMAGE:-}" + echo "ROLLBACK_BACKEND_IMAGE=${PROD_ROLLBACK_BACKEND_IMAGE:-}" + echo "DB_ROLLBACK_NOTE=${PROD_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 + sshpass -p '${{ secrets.SSH_PASSWORD }}' ssh ssh.zeroone.it.kr bash << 'EOF' set -e - + SUDO_PASSWORD='${{ secrets.SSH_PASSWORD }}' + FRONTEND_IMAGE='${{ needs.build-and-push-image.outputs.frontend_image }}' + echo "사용하지 않는 컨테이너, 이미지, 네트워크 정리 중 (볼륨제외)..." echo "$SUDO_PASSWORD" | sudo -S docker stop frontend-prod || true echo "$SUDO_PASSWORD" | sudo -S docker rm frontend-prod || true echo "$SUDO_PASSWORD" | sudo -S docker system prune -a -f - - # 여기다 도커 컴포즈 파일 갖다놓았기 때문임 + cd ~/front/study-platform-client-prod || { echo "디렉토리가 없습니다. 생성합니다..." mkdir -p ~/front/study-platform-client-prod cd ~/front/study-platform-client-prod } - echo ".env 파일 생성 (런타임에 필요한 모든 환경변수. 디버깅용)" + 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 @@ -114,26 +263,54 @@ jobs: echo "GOOGLE_PRIVATE_KEY=${{ secrets.GOOGLE_PRIVATE_KEY }}" >> .env echo "docker-compose.prod.yml 파일 생성" - echo "services:" > docker-compose.prod.yml - echo " frontend:" >> docker-compose.prod.yml - echo " container_name: frontend-prod" >> docker-compose.prod.yml - echo " image: zerooneitkr/frontend:prod" >> docker-compose.prod.yml - echo " ports:" >> docker-compose.prod.yml - echo " - '13755:13755'" >> docker-compose.prod.yml - echo " env_file:" >> docker-compose.prod.yml - echo " - .env" >> docker-compose.prod.yml - echo " restart: unless-stopped" >> 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 "기존 컨테이너 완전 제거" 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 "도커 이미지 pull" - echo "$SUDO_PASSWORD" | sudo -S docker pull zerooneitkr/frontend:prod + 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 up -d 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 diff --git a/.github/workflows/release-record-check.yml b/.github/workflows/release-record-check.yml new file mode 100644 index 000000000..f523a2584 --- /dev/null +++ b/.github/workflows/release-record-check.yml @@ -0,0 +1,26 @@ +name: Release Record Check + +on: + pull_request: + paths: + - 'releases/**/*.yaml' + - 'scripts/release/**' + - 'ops/**' + - '.github/workflows/deploy-prod.yml' + - '.github/workflows/release-record-check.yml' + workflow_dispatch: + +jobs: + validate-release-records: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Validate release records + run: node scripts/release/validate-release-record.mjs releases diff --git a/AGENTS.md b/AGENTS.md index f91cee56c..cd687db78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,8 @@ yarn typecheck # tsc --noEmit - `@radix-ui/*` — headless UI primitives (Dialog, DropdownMenu, Avatar, etc.) + +### Production Version Management +- Main-branch production deployments must follow `ops/version-management.md` (ZERO-ONE Version Management Rule - Frontend Repository). +- `releases/` is the source of truth for successful production FE/BE/DB/rollback combinations. +- Develop/test-server deployment keeps the existing flow and does not create release records. diff --git a/CLAUDE.md b/CLAUDE.md index 67c389bde..ca2709268 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ yarn typecheck # No type errors - No hardcoded colors/spacing. Use only `@theme inline` tokens from `global.css`. @.claude/rules/no-img-no-eslint-disable.md +@.claude/rules/version-management-frontend.md --- diff --git a/ops/deploy-checklist.md b/ops/deploy-checklist.md new file mode 100644 index 000000000..eee08910a --- /dev/null +++ b/ops/deploy-checklist.md @@ -0,0 +1,28 @@ +# Production Deploy Checklist + +This checklist applies to `main` production deployments only. `develop` test-server deployment keeps the existing lightweight pointer-tag flow. + +## Before production deploy + +- Confirm PR CI is green before merging to `main`. +- Confirm frontend version source (`package.json.version`) is intentional. +- Confirm the production backend image/API metadata is available to the workflow: + - preferred: current production backend container labels/inspect output + - fallback: GitHub variables/secrets documented in `ops/version-management.md` +- Confirm DB migration metadata is known when backend/database changed. +- Confirm rollback targets are fixed image tags, not `prod` or `latest-prod`. + +## Deployment order + +1. DB migration +2. Backend +3. Backend health check +4. Frontend +5. E2E/smoke check + +## After production deploy + +- Confirm frontend container is running with the fixed frontend image tag. +- Confirm backend health/API compatibility. +- Confirm `releases/prod-YYYYMMDD-HHmm.yaml` was committed to `main`. +- Confirm the release record includes frontend, backend, database, rollback, deploy order, deployed time, actor, and `status: success`. diff --git a/ops/rollback.md b/ops/rollback.md new file mode 100644 index 000000000..fa0c31efd --- /dev/null +++ b/ops/rollback.md @@ -0,0 +1,27 @@ +# Production Rollback Guide + +Rollback is based on fixed image tags recorded in `releases/`, never on pointer tags. + +## Required inputs + +1. Current failed `release_id`. +2. Latest known-good release record under `releases/`. +3. `rollback.app_rollback_target.frontend` fixed image tag. +4. `rollback.app_rollback_target.backend` fixed image tag. +5. `rollback.db_rollback_note` and DB compatibility confirmation. + +## Rules + +- Do not rollback to `prod` or `latest-prod`. +- Rollback frontend/backend as a compatible combination unless the incident analysis proves only one side changed safely. +- Do not run destructive DB rollback automatically. +- If DB migration compatibility is uncertain, stop and verify before changing app images. + +## Minimal rollback flow + +1. Open the latest successful release YAML before the failed release. +2. Copy fixed image tags from `rollback.app_rollback_target`. +3. Deploy the fixed frontend/backend images. +4. Run backend health check. +5. Run frontend smoke/E2E check. +6. Record the rollback outcome in a follow-up release/incident note. diff --git a/ops/version-management.md b/ops/version-management.md new file mode 100644 index 000000000..4a89cff2c --- /dev/null +++ b/ops/version-management.md @@ -0,0 +1,140 @@ +# ZERO-ONE Version Management Rule - Frontend Repository + +This document is the frontend repository source of truth for ZERO-ONE production release records. It is a version-management rule, not a frontend coding-style rule. + +## Responsibility + +- `study-platform-client` owns the final production release record because it is the user-facing application and the running product depends on a compatible frontend/backend/database combination. +- `releases/` is the final release-record location for production combinations. +- A production release record must include frontend image, backend image, database migration state, rollback target, release id, deploy order, deployment time, deploy actor, and status. +- Backend deployments must provide the backend image tag, commit, version, and DB migration information that belongs in this repository's release record. Backend-specific compatibility rules live in the backend repository, not here. + +## Release ID + +Release IDs include date/time. Image tags do not. + +```txt +prod-YYYYMMDD-HHmm +``` + +Example: + +```txt +prod-20260517-2100 +``` + +## Image tag policy + +Image tags must be immutable and must not contain dates. + +Canonical service image formats: + +```txt +zeroone-frontend:v{MAJOR}.{MINOR}.{PATCH}-{shortCommit} +zeroone-backend:v{MAJOR}.{MINOR}.{PATCH}-{shortCommit} +``` + +Hotfix formats: + +```txt +zeroone-frontend:v1.0.1-hotfix.1-f1a2b3c +zeroone-backend:v1.0.1-hotfix.1-b7c8d9e +``` + +A registry namespace may prefix the image name in workflow/deployment records, for example `zerooneitkr/zeroone-frontend:v1.0.0-f1a2b3c`, but the tag itself must still follow the immutable format above. + +`prod` and `latest-prod` are deployment convenience pointers only. They must never be used as rollback targets. + +## Production deploy order + +Production deployment order is: + +1. `db_migration` +2. `backend` +3. `backend_health_check` +4. `frontend` +5. `e2e_check` + +For a frontend-only production deployment, the workflow must still record the currently deployed backend image/API and database migration state before writing the release record. + +## Release record schema + +Production release records live at: + +```txt +releases/prod-YYYYMMDD-HHmm.yaml +``` + +Required shape: + +```yaml +release_id: prod-20260517-2100 +env: prod +service_version: v1.0.0 + +summary: Production frontend deployment + +components: + frontend: + repo: study-platform-client + image: zerooneitkr/zeroone-frontend:v1.0.0-f1a2b3c + commit: f1a2b3c + version: v1.0.0 + changed: true + + backend: + repo: study-platform-mvp + image: zerooneitkr/zeroone-backend:v1.0.0-b7c8d9e + commit: b7c8d9e + version: v1.0.0 + changed: false + +database: + changed: false + migration_version: V12 + migration_files: [] + +rollback: + app_rollback_target: + frontend: zerooneitkr/zeroone-frontend:v0.6.3-a1b2c3d + backend: zerooneitkr/zeroone-backend:v0.6.3-e4f5g6h + db_rollback_note: DB rollback is not automated. Confirm app compatibility with the recorded DB state. + +deploy_order: + - db_migration + - backend + - backend_health_check + - frontend + - e2e_check + +deployed_at: 2026-05-17T21:00:00+09:00 +deployed_by: github-actions +status: success +``` + +## Incident and rollback rule + +Incident analysis starts by identifying: + +- current `release_id` +- frontend image +- backend image +- DB migration version/files +- rollback targets from the latest successful release record + +Rollback decisions must use the fixed image tags in `rollback.app_rollback_target`, not `prod` or `latest-prod`. + +## GitHub Actions metadata inputs + +The production workflow can inspect the running backend container, but the first recorded release and any backend image without OCI labels may need repository variables: + +- `PROD_BACKEND_IMAGE` - fixed backend image tag, e.g. `zerooneitkr/zeroone-backend:v1.0.0-b7c8d9e` +- `PROD_BACKEND_COMMIT` - backend short commit +- `PROD_BACKEND_VERSION` - backend service version, e.g. `v1.0.0` +- `PROD_DB_CHANGED` - `true` or `false` +- `PROD_DB_MIGRATION_VERSION` - latest DB migration version or `none` +- `PROD_DB_MIGRATION_FILES` - comma-separated migration filenames +- `PROD_ROLLBACK_FRONTEND_IMAGE` - required for the first recorded release when no previous release YAML exists +- `PROD_ROLLBACK_BACKEND_IMAGE` - required for the first recorded release when no previous release YAML exists +- `PROD_DB_ROLLBACK_NOTE` - optional DB rollback/compatibility note +- `PROD_E2E_BASE_URL` - optional production smoke-check URL diff --git a/package.json b/package.json index e4976419e..a98be7ffc 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "e2e": "playwright test", "e2e:ui": "playwright test --ui", "e2e:headed": "playwright test --headed", - "e2e:save-auth": "playwright codegen https://test.zeroone.it.kr --save-storage=e2e/fixtures/auth.json" + "e2e:save-auth": "playwright codegen https://test.zeroone.it.kr --save-storage=e2e/fixtures/auth.json", + "release:validate": "node scripts/release/validate-release-record.mjs releases" }, "dependencies": { "@hookform/resolvers": "^5.2.1", diff --git a/releases/.gitkeep b/releases/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/release/generate-prod-release-record.mjs b/scripts/release/generate-prod-release-record.mjs new file mode 100644 index 000000000..b484d9f95 --- /dev/null +++ b/scripts/release/generate-prod-release-record.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +const RELEASES_DIR = 'releases'; +const DEPLOY_ORDER = [ + 'db_migration', + 'backend', + 'backend_health_check', + 'frontend', + 'e2e_check', +]; + +const getEnv = (name, fallback = '') => process.env[name]?.trim() || fallback; +const required = (name) => { + const value = getEnv(name); + if (!value) { + throw new Error(`${name} is required`); + } + return value; +}; + +const quote = (value) => { + const s = String(value ?? ''); + if (s === '') return '""'; + if (/^[A-Za-z0-9_./:@+-]+$/.test(s)) return s; + return JSON.stringify(s); +}; + +const parseField = (content, path) => { + const lines = content.split(/\r?\n/); + const stack = []; + for (const raw of lines) { + const match = raw.match(/^(\s*)([A-Za-z0-9_]+):\s*(.*)$/); + if (!match) continue; + const indent = match[1].length; + const key = match[2]; + const value = match[3].trim(); + while (stack.length && stack[stack.length - 1].indent >= indent) + stack.pop(); + stack.push({ indent, key }); + if (stack.map((item) => item.key).join('.') === path) { + return value.replace(/^['"]|['"]$/g, ''); + } + } + return ''; +}; + +const latestRelease = () => { + if (!existsSync(RELEASES_DIR)) return null; + const files = readdirSync(RELEASES_DIR) + .filter((file) => /^prod-\d{8}-\d{4}\.yaml$/.test(file)) + .sort(); + if (files.length === 0) return null; + const file = files[files.length - 1]; + const content = readFileSync(join(RELEASES_DIR, file), 'utf8'); + return { file, content }; +}; + +const releaseId = required('RELEASE_ID'); +const frontendImage = required('FRONTEND_IMAGE'); +const frontendCommit = required('FRONTEND_COMMIT'); +const frontendVersion = required('FRONTEND_VERSION'); +const backendImage = required('BACKEND_IMAGE'); +const backendCommit = required('BACKEND_COMMIT'); +const backendVersion = required('BACKEND_VERSION'); + +const previous = latestRelease(); +const rollbackFrontend = + getEnv('ROLLBACK_FRONTEND_IMAGE') || + (previous ? parseField(previous.content, 'components.frontend.image') : ''); +const rollbackBackend = + getEnv('ROLLBACK_BACKEND_IMAGE') || + (previous ? parseField(previous.content, 'components.backend.image') : ''); + +if (!rollbackFrontend) { + throw new Error( + 'ROLLBACK_FRONTEND_IMAGE is required when there is no previous release record', + ); +} +if (!rollbackBackend) { + throw new Error( + 'ROLLBACK_BACKEND_IMAGE is required when there is no previous release record', + ); +} + +const dbChanged = /^true$/i.test(getEnv('DB_CHANGED', 'false')); +const migrationVersion = getEnv('DB_MIGRATION_VERSION', 'none'); +const migrationFiles = getEnv('DB_MIGRATION_FILES') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +const deployedAt = required('DEPLOYED_AT'); +const deployedBy = getEnv('DEPLOYED_BY', 'github-actions'); +const summary = getEnv('RELEASE_SUMMARY', 'Production frontend deployment'); +const dbRollbackNote = getEnv( + 'DB_ROLLBACK_NOTE', + 'DB rollback is not automated. Confirm app compatibility with the recorded DB state.', +); +const backendChanged = /^true$/i.test(getEnv('BACKEND_CHANGED', 'false')); + +mkdirSync(RELEASES_DIR, { recursive: true }); + +const migrationBlock = migrationFiles.length + ? migrationFiles.map((file) => ` - ${quote(file)}`).join('\n') + : ' []'; + +const yaml = `release_id: ${quote(releaseId)} +env: prod +service_version: ${quote(frontendVersion)} + +summary: ${quote(summary)} + +components: + frontend: + repo: study-platform-client + image: ${quote(frontendImage)} + commit: ${quote(frontendCommit)} + version: ${quote(frontendVersion)} + changed: true + + backend: + repo: study-platform-mvp + image: ${quote(backendImage)} + commit: ${quote(backendCommit)} + version: ${quote(backendVersion)} + changed: ${backendChanged ? 'true' : 'false'} + +database: + changed: ${dbChanged ? 'true' : 'false'} + migration_version: ${quote(migrationVersion)} + migration_files: +${migrationBlock} + +rollback: + app_rollback_target: + frontend: ${quote(rollbackFrontend)} + backend: ${quote(rollbackBackend)} + db_rollback_note: ${quote(dbRollbackNote)} + +deploy_order: +${DEPLOY_ORDER.map((item) => ` - ${item}`).join('\n')} + +deployed_at: ${quote(deployedAt)} +deployed_by: ${quote(deployedBy)} +status: success +`; + +const outFile = join(RELEASES_DIR, `${releaseId}.yaml`); +writeFileSync(outFile, yaml); +process.stdout.write(`${outFile}\n`); diff --git a/scripts/release/validate-release-record.mjs b/scripts/release/validate-release-record.mjs new file mode 100644 index 000000000..63433e7fb --- /dev/null +++ b/scripts/release/validate-release-record.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const RELEASE_ID = /^prod-\d{8}-\d{4}$/; +const DATE_IN_TAG = /:.*\d{8}|:.*\d{4}-\d{2}-\d{2}/; +const POINTER_TAG = /:(prod|latest-prod)$/; +const FRONTEND_IMAGE = + /(?:^|\/)zeroone-frontend:v\d+\.\d+\.\d+(?:-hotfix\.\d+)?-[0-9A-Za-z]{7,}$/; +const BACKEND_IMAGE = + /(?:^|\/)zeroone-backend:v\d+\.\d+\.\d+(?:-hotfix\.\d+)?-[0-9A-Za-z]{7,}$/; + +const args = process.argv.slice(2); +const targets = args.length ? args : ['releases']; + +const listYamlFiles = (target) => { + if (!existsSync(target)) return []; + if (statSync(target).isFile()) return [target]; + return readdirSync(target) + .filter((file) => file.endsWith('.yaml')) + .map((file) => join(target, file)); +}; + +const parseScalar = (content, path) => { + const lines = content.split(/\r?\n/); + const stack = []; + for (const raw of lines) { + const match = raw.match(/^(\s*)([A-Za-z0-9_]+):\s*(.*)$/); + if (!match) continue; + const indent = match[1].length; + const key = match[2]; + const value = match[3].trim(); + while (stack.length && stack[stack.length - 1].indent >= indent) + stack.pop(); + stack.push({ indent, key }); + if (stack.map((item) => item.key).join('.') === path) { + return value.replace(/^['"]|['"]$/g, ''); + } + } + return ''; +}; + +const fail = (file, message) => { + throw new Error(`${file}: ${message}`); +}; + +const validateImage = (file, label, value, regex) => { + if (!value) fail(file, `${label} is required`); + if (POINTER_TAG.test(value)) + fail(file, `${label} must not use prod/latest-prod pointer tag`); + if (DATE_IN_TAG.test(value)) + fail(file, `${label} tag must not contain a date`); + if (!regex.test(value)) + fail(file, `${label} has invalid immutable tag format: ${value}`); +}; + +const validateFile = (file) => { + const content = readFileSync(file, 'utf8'); + const releaseId = parseScalar(content, 'release_id'); + const expectedName = `${releaseId}.yaml`; + + if (!RELEASE_ID.test(releaseId)) + fail(file, `invalid release_id: ${releaseId}`); + if (!file.endsWith(expectedName)) { + fail(file, `filename must match release_id (${expectedName})`); + } + if (parseScalar(content, 'env') !== 'prod') fail(file, 'env must be prod'); + if (parseScalar(content, 'status') !== 'success') + fail(file, 'status must be success'); + + validateImage( + file, + 'components.frontend.image', + parseScalar(content, 'components.frontend.image'), + FRONTEND_IMAGE, + ); + validateImage( + file, + 'components.backend.image', + parseScalar(content, 'components.backend.image'), + BACKEND_IMAGE, + ); + validateImage( + file, + 'rollback.app_rollback_target.frontend', + parseScalar(content, 'rollback.app_rollback_target.frontend'), + FRONTEND_IMAGE, + ); + validateImage( + file, + 'rollback.app_rollback_target.backend', + parseScalar(content, 'rollback.app_rollback_target.backend'), + BACKEND_IMAGE, + ); + + const dbChanged = parseScalar(content, 'database.changed'); + const migrationVersion = parseScalar(content, 'database.migration_version'); + if ( + dbChanged === 'true' && + (!migrationVersion || migrationVersion === 'none') + ) { + fail(file, 'database.changed=true requires database.migration_version'); + } + + for (const requiredPath of [ + 'service_version', + 'components.frontend.commit', + 'components.frontend.version', + 'components.backend.commit', + 'components.backend.version', + 'rollback.db_rollback_note', + 'deployed_at', + 'deployed_by', + ]) { + if (!parseScalar(content, requiredPath)) + fail(file, `${requiredPath} is required`); + } +}; + +const files = targets + .flatMap(listYamlFiles) + .filter((file) => /prod-\d{8}-\d{4}\.yaml$/.test(file)); +for (const file of files) validateFile(file); +process.stdout.write(`Validated ${files.length} release record(s).\n`); From fde1f69226c212b0906beeca8769acff86922e26 Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Sun, 17 May 2026 20:11:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EA=B8=B0=EB=A1=9D=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: OmX --- .claude/rules/version-management-frontend.md | 5 + .../zeroone-version-management/SKILL.md | 10 + .../zeroone-version-management/SKILL.md | 10 + .github/workflows/deploy-prod.yml | 103 +++-- .../workflows/record-backend-prod-release.yml | 50 +++ AGENTS.md | 1 + .../zeroone-version-management.md | 58 +++ ops/backend-release-dispatch.md | 95 +++++ ops/deploy-checklist.md | 10 +- ops/release-intent.md | 83 ++++ ops/version-management.md | 74 +++- .../generate-backend-prod-release-record.mjs | 326 +++++++++++++++ .../release/generate-prod-release-record.mjs | 3 + .../release/resolve-prod-release-intent.mjs | 382 ++++++++++++++++++ scripts/release/validate-release-record.mjs | 44 +- 15 files changed, 1174 insertions(+), 80 deletions(-) create mode 100644 .claude/rules/version-management-frontend.md create mode 100644 .claude/skills/zeroone-version-management/SKILL.md create mode 100644 .codex/skills/zeroone-version-management/SKILL.md create mode 100644 .github/workflows/record-backend-prod-release.yml create mode 100644 ops/agent-skills/zeroone-version-management.md create mode 100644 ops/backend-release-dispatch.md create mode 100644 ops/release-intent.md create mode 100644 scripts/release/generate-backend-prod-release-record.mjs create mode 100644 scripts/release/resolve-prod-release-intent.mjs diff --git a/.claude/rules/version-management-frontend.md b/.claude/rules/version-management-frontend.md new file mode 100644 index 000000000..da1b779e8 --- /dev/null +++ b/.claude/rules/version-management-frontend.md @@ -0,0 +1,5 @@ +# ZERO-ONE Version Management Rule - Frontend Repository + +Source of truth for this repository: `ops/version-management.md`. + +This rule is a version-management rule, not a frontend coding-style rule. Do not duplicate the full rule body here; keep the durable policy in `ops/version-management.md` so Claude/Codex/project agents share the same repository-level source of truth. diff --git a/.claude/skills/zeroone-version-management/SKILL.md b/.claude/skills/zeroone-version-management/SKILL.md new file mode 100644 index 000000000..172ad6148 --- /dev/null +++ b/.claude/skills/zeroone-version-management/SKILL.md @@ -0,0 +1,10 @@ +--- +name: zeroone-version-management +description: Use when working on ZERO-ONE production release records, PR release intent labels/body, main-branch production deploy workflow, rollback metadata, or releases/prod-*.yaml in study-platform-client. +--- + +# ZERO-ONE Version Management Wrapper for Claude + +Source of truth: `ops/agent-skills/zeroone-version-management.md`. + +Immediately read that file and follow it. Do not duplicate or reinterpret the rule in this wrapper. diff --git a/.codex/skills/zeroone-version-management/SKILL.md b/.codex/skills/zeroone-version-management/SKILL.md new file mode 100644 index 000000000..fe96a1108 --- /dev/null +++ b/.codex/skills/zeroone-version-management/SKILL.md @@ -0,0 +1,10 @@ +--- +name: zeroone-version-management +description: Use when working on ZERO-ONE production release records, PR release intent labels/body, main-branch production deploy workflow, rollback metadata, or releases/prod-*.yaml in study-platform-client. +--- + +# ZERO-ONE Version Management Wrapper for Codex + +Source of truth: `ops/agent-skills/zeroone-version-management.md`. + +Immediately read that file and follow it. Do not duplicate or reinterpret the rule in this wrapper. diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 871b37e1e..103319be0 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -15,12 +15,17 @@ on: 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-frontend-deploy + group: prod-release-record cancel-in-progress: false env: @@ -37,6 +42,18 @@ jobs: 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 }} + 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 @@ -48,39 +65,13 @@ jobs: node-version: '20' cache: 'yarn' - - name: Compute release metadata + - name: Resolve release intent and metadata id: meta - run: | - set -euo pipefail - SHORT_SHA="${GITHUB_SHA::7}" - PACKAGE_VERSION="$(node -p "require('./package.json').version")" - FRONTEND_VERSION="v${PACKAGE_VERSION#v}" - RELEASE_ID="prod-$(TZ=Asia/Seoul date +%Y%m%d-%H%M)" - DEPLOYED_AT="$(TZ=Asia/Seoul date +%Y-%m-%dT%H:%M:%S%:z)" - FRONTEND_IMAGE="${FRONTEND_IMAGE_REPOSITORY}:${FRONTEND_VERSION}-${SHORT_SHA}" - RELEASE_SUMMARY="${{ github.event.inputs.release_summary || 'Production frontend deployment' }}" - - echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT" - echo "deployed_at=${DEPLOYED_AT}" >> "$GITHUB_OUTPUT" - echo "frontend_image=${FRONTEND_IMAGE}" >> "$GITHUB_OUTPUT" - echo "frontend_commit=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "frontend_version=${FRONTEND_VERSION}" >> "$GITHUB_OUTPUT" - echo "release_summary=${RELEASE_SUMMARY}" >> "$GITHUB_OUTPUT" - - - name: Preflight rollback metadata env: - ROLLBACK_FRONTEND_IMAGE: ${{ vars.PROD_ROLLBACK_FRONTEND_IMAGE }} - ROLLBACK_BACKEND_IMAGE: ${{ vars.PROD_ROLLBACK_BACKEND_IMAGE }} - run: | - set -euo pipefail - shopt -s nullglob - existing=(releases/prod-*.yaml) - if [ ${#existing[@]} -eq 0 ]; then - if [ -z "${ROLLBACK_FRONTEND_IMAGE:-}" ] || [ -z "${ROLLBACK_BACKEND_IMAGE:-}" ]; then - echo "::error::No previous release record exists. Set PROD_ROLLBACK_FRONTEND_IMAGE and PROD_ROLLBACK_BACKEND_IMAGE repository variables to fixed image tags for the first recorded production deployment." - exit 1 - fi - fi + 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: | @@ -123,15 +114,18 @@ jobs: 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 }} - PROD_BACKEND_IMAGE: ${{ vars.PROD_BACKEND_IMAGE }} - PROD_BACKEND_COMMIT: ${{ vars.PROD_BACKEND_COMMIT }} - PROD_BACKEND_VERSION: ${{ vars.PROD_BACKEND_VERSION }} - PROD_DB_CHANGED: ${{ vars.PROD_DB_CHANGED }} - PROD_DB_MIGRATION_VERSION: ${{ vars.PROD_DB_MIGRATION_VERSION }} - PROD_DB_MIGRATION_FILES: ${{ vars.PROD_DB_MIGRATION_FILES }} - PROD_ROLLBACK_FRONTEND_IMAGE: ${{ vars.PROD_ROLLBACK_FRONTEND_IMAGE }} - PROD_ROLLBACK_BACKEND_IMAGE: ${{ vars.PROD_ROLLBACK_BACKEND_IMAGE }} - PROD_DB_ROLLBACK_NOTE: ${{ vars.PROD_DB_ROLLBACK_NOTE }} + SOURCE_PR_NUMBER: ${{ needs.build-and-push-image.outputs.source_pr_number }} + RELEASE_TYPE: ${{ needs.build-and-push-image.outputs.release_type }} + 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: @@ -207,15 +201,19 @@ jobs: cat /tmp/prod-backend-metadata.env cat /tmp/prod-backend-metadata.env >> "$GITHUB_ENV" - - name: Resolve release metadata + - name: Resolve release record metadata run: | set -euo pipefail - BACKEND_IMAGE="${PROD_BACKEND_IMAGE:-${INSPECTED_BACKEND_IMAGE:-}}" - BACKEND_COMMIT="${PROD_BACKEND_COMMIT:-${INSPECTED_BACKEND_COMMIT:-unknown}}" - BACKEND_VERSION="${PROD_BACKEND_VERSION:-${INSPECTED_BACKEND_VERSION:-unknown}}" + 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. Set PROD_BACKEND_IMAGE or ensure the backend production container can be inspected." + 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 @@ -223,12 +221,13 @@ jobs: echo "BACKEND_IMAGE=$BACKEND_IMAGE" echo "BACKEND_COMMIT=$BACKEND_COMMIT" echo "BACKEND_VERSION=$BACKEND_VERSION" - echo "DB_CHANGED=${PROD_DB_CHANGED:-false}" - echo "DB_MIGRATION_VERSION=${PROD_DB_MIGRATION_VERSION:-none}" - echo "DB_MIGRATION_FILES=${PROD_DB_MIGRATION_FILES:-}" - echo "ROLLBACK_FRONTEND_IMAGE=${PROD_ROLLBACK_FRONTEND_IMAGE:-}" - echo "ROLLBACK_BACKEND_IMAGE=${PROD_ROLLBACK_BACKEND_IMAGE:-}" - echo "DB_ROLLBACK_NOTE=${PROD_DB_ROLLBACK_NOTE:-DB rollback is not automated. Confirm app compatibility with the recorded DB state.}" + echo "BACKEND_CHANGED=${RELEASE_BACKEND_CHANGED:-false}" + echo "DB_CHANGED=${RELEASE_DB_CHANGED:-false}" + echo "DB_MIGRATION_VERSION=${RELEASE_DB_MIGRATION_VERSION:-none}" + 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 diff --git a/.github/workflows/record-backend-prod-release.yml b/.github/workflows/record-backend-prod-release.yml new file mode 100644 index 000000000..229ba141e --- /dev/null +++ b/.github/workflows/record-backend-prod-release.yml @@ -0,0 +1,50 @@ +# 백엔드 prod 배포 fact(repository_dispatch)를 받아 프론트 레포 releases/에 최종 atomic release record를 남긴다. + +name: Record Backend Production Release + +on: + repository_dispatch: + types: + - backend-prod-deployed + +permissions: + contents: write + +concurrency: + group: prod-release-record + cancel-in-progress: false + +jobs: + record-backend-release: + runs-on: ubuntu-latest + env: + BACKEND_RELEASE_PAYLOAD_JSON: ${{ toJSON(github.event.client_payload) }} + + 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: Generate backend release record + run: | + set -euo pipefail + RECORD_PATH="$(node scripts/release/generate-backend-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 backend prod release [release-record]" + git pull --rebase origin main + git push origin HEAD:main diff --git a/AGENTS.md b/AGENTS.md index cd687db78..969da8ab2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,5 +85,6 @@ yarn typecheck # tsc --noEmit ### Production Version Management - Main-branch production deployments must follow `ops/version-management.md` (ZERO-ONE Version Management Rule - Frontend Repository). +- Agent skill SSOT for this workflow is `ops/agent-skills/zeroone-version-management.md`; Codex/Claude skill files are thin wrappers only. - `releases/` is the source of truth for successful production FE/BE/DB/rollback combinations. - Develop/test-server deployment keeps the existing flow and does not create release records. diff --git a/ops/agent-skills/zeroone-version-management.md b/ops/agent-skills/zeroone-version-management.md new file mode 100644 index 000000000..f01a48396 --- /dev/null +++ b/ops/agent-skills/zeroone-version-management.md @@ -0,0 +1,58 @@ +# ZERO-ONE Version Management Agent Skill SSOT + +Use this skill when working on production release records, release intent labels/body, `main` production deployment workflow, rollback metadata, or `releases/prod-*.yaml` in `study-platform-client`. + +This file is the shared skill source of truth. Codex and Claude wrappers must stay thin and point here instead of duplicating the workflow. + +## Required reading order + +1. `ops/version-management.md` - repository rule and release-record policy. +2. `ops/release-intent.md` - human usage for PR labels/body and bootstrap. +3. `ops/deploy-checklist.md` or `ops/rollback.md` only when deploying or rolling back. +4. Relevant scripts/workflows only after the docs above: + - `.github/workflows/deploy-prod.yml` + - `.github/workflows/release-record-check.yml` + - `scripts/release/resolve-prod-release-intent.mjs` + - `scripts/release/generate-prod-release-record.mjs` + - `scripts/release/generate-backend-prod-release-record.mjs` + - `scripts/release/validate-release-record.mjs` + - `ops/backend-release-dispatch.md` + +## Non-negotiable rules + +- This skill applies to `main` production releases only. Do not change `develop` deployment behavior unless the user explicitly asks. +- `releases/` is the frontend repository source of truth for successful production FE/BE/DB/rollback combinations. +- Frontend repo owns only the frontend version-management rule. Do not add the backend repository rule here. +- Production version metadata comes from PR intent or backend dispatch payload, not per-release repository variables. +- Exactly one release intent is allowed: `release:major`, `release:minor`, or `release:patch`. +- If multiple `release:*` labels are present, or label intent conflicts with body `release`, fail instead of guessing. +- First recorded frontend production release requires explicit bootstrap approval metadata in the PR body: `bootstrap: approved`, `base_version` or `version`, backend image/commit/version, and rollback frontend/backend fixed image tags. +- `prod` and `latest-prod` are pointer tags only. They are never valid rollback targets and must fail if used as inherited or supplied backend/rollback images. +- Image dates belong in `release_id`, not image tags. + +## Implementation workflow for agents + +1. Inspect current branch and changed files. +2. Read the docs in the required reading order. +3. For workflow/script changes, add deterministic checks for: + - first-release bootstrap without accidental defaulting, + - duplicate release label failure, + - pointer-tag rejection, + - docs/examples matching supported script keys, + - backend dispatch schema validation, + - duplicate `metadata.backend_deploy_id` rejection. +4. Validate with targeted commands first: + - `node --check scripts/release/resolve-prod-release-intent.mjs` + - local resolver smoke cases for bootstrap, duplicate labels, pointer tags, and normal latest-release inheritance. + - `node scripts/release/validate-release-record.mjs releases` +5. Then run repository checks required by the project for the changed scope. + +## Human handoff format + +Report: + +- changed files, +- what happens on `main` merge, +- what the PR author must put in labels/body, +- verification commands and results, +- any remaining manual setup such as optional `PROD_E2E_BASE_URL`. diff --git a/ops/backend-release-dispatch.md b/ops/backend-release-dispatch.md new file mode 100644 index 000000000..c1c6bb021 --- /dev/null +++ b/ops/backend-release-dispatch.md @@ -0,0 +1,95 @@ +# Backend Production Release Dispatch Contract + +This is the frontend repository contract for backend production deployments. Backend automation is the producer of backend deploy facts. The frontend repository is the final release-record writer. + +## Trigger + +Backend production deploy success must call the frontend repository with `repository_dispatch` or an equivalent API trigger. + +```json +{ + "event_type": "backend-prod-deployed", + "client_payload": {} +} +``` + +The frontend workflow that receives this event is: + +```txt +.github/workflows/record-backend-prod-release.yml +``` + +## Required payload + +```json +{ + "release_id": "prod-20260517-2100", + "env": "prod", + "summary": "backend patch release", + "backend": { + "repo": "study-platform-mvp", + "image": "zeroone-backend:v1.4.3-b7c8d9e", + "commit": "b7c8d9e", + "version": "v1.4.3", + "changed": true + }, + "database": { + "changed": true, + "migration_version": "V45", + "migration_files": [ + "src/main/resources/db/migration/V45__create_course_refund.sql" + ] + }, + "rollback": { + "backend": "zeroone-backend:v1.4.2-a1b2c3d" + }, + "metadata": { + "release_intent": "patch", + "bootstrap_mode": false, + "previous_deploy_image": "zeroone-backend:v1.4.2-a1b2c3d", + "pull_request_number": 1234, + "pull_request_labels": ["release:patch", "db:backup-confirmed"], + "backend_deploy_id": "backend-prod-123" + } +} +``` + +## Required fields + +- `release_id` - `prod-YYYYMMDD-HHmm` +- `env` - must be `prod` +- `backend.image` - fixed immutable backend image tag +- `backend.commit` - backend short commit +- `backend.version` - `vMAJOR.MINOR.PATCH` +- `backend.changed` - must be `true` +- `database.changed` - boolean +- `database.migration_version` - migration version or `none` +- `database.migration_files` - array +- `rollback.backend` - fixed immutable backend rollback image tag +- `metadata.release_intent` - `patch`, `minor`, or `major` +- `metadata.bootstrap_mode` - boolean +- `metadata.backend_deploy_id` - unique backend deployment id + +## Optional fields + +- `summary` +- `metadata.previous_deploy_image` +- `metadata.pull_request_number` +- `metadata.pull_request_labels` + +## Frontend behavior + +When the dispatch arrives, the frontend repository workflow: + +1. validates the payload strictly, +2. reads the latest `releases/prod-*.yaml` to identify current frontend production state, +3. creates a new release record with `frontend.changed=false` and `backend.changed=true`, +4. writes `metadata.backend_deploy_id`, +5. fails if the same `backend_deploy_id` is already recorded, +6. fails if image tags use `prod`, `latest-prod`, dates, or non-canonical versions. + +The workflow does not deploy frontend code. It only records the new atomic production combination `FE(current) + BE(new)`. + +## Failure principle + +Do not guess. If the payload is missing, invalid, duplicated, or the frontend repository has no previous release record to identify current frontend production state, the workflow must fail instead of writing a wrong release record. diff --git a/ops/deploy-checklist.md b/ops/deploy-checklist.md index eee08910a..e86dc093b 100644 --- a/ops/deploy-checklist.md +++ b/ops/deploy-checklist.md @@ -5,12 +5,10 @@ This checklist applies to `main` production deployments only. `develop` test-ser ## Before production deploy - Confirm PR CI is green before merging to `main`. -- Confirm frontend version source (`package.json.version`) is intentional. -- Confirm the production backend image/API metadata is available to the workflow: - - preferred: current production backend container labels/inspect output - - fallback: GitHub variables/secrets documented in `ops/version-management.md` -- Confirm DB migration metadata is known when backend/database changed. -- Confirm rollback targets are fixed image tags, not `prod` or `latest-prod`. +- Confirm the PR has exactly one release intent: `release:patch`, `release:minor`, `release:major`, or a `release: ...` line in the PR body. +- Confirm `ops/release-intent.md` bootstrap approval fields are present if this is the first recorded frontend production release. +- For frontend-only deploys, confirm latest `releases/` backend image matches current production backend; if not, record the backend dispatch first. +- Confirm rollback targets and inherited/supplied backend images are fixed image tags, not `prod` or `latest-prod`. ## Deployment order diff --git a/ops/release-intent.md b/ops/release-intent.md new file mode 100644 index 000000000..36210fa65 --- /dev/null +++ b/ops/release-intent.md @@ -0,0 +1,83 @@ +# Production Release Intent Usage + +Production release intent is how a PR tells the production automation what semantic version bump to apply. This removes the need to set release-version environment variables for every deployment. + +This applies only when code is deployed from `main`. `develop` test-server deployment keeps its existing flow. + +## Allowed labels + +Use exactly one release label: + +- `release:patch` - bug fix or small compatible change +- `release:minor` - compatible feature/change +- `release:major` - breaking product/API contract change + +`hotfix` labels are not used. Emergency fixes still use `release:patch` unless they are minor/major by compatibility impact. + +## Frontend PR + +For a normal frontend-only production deployment, the PR only needs one release label, for example: + +```txt +release:patch +``` + +On `main` deploy, the frontend workflow reads the latest `releases/prod-*.yaml`, bumps the frontend version, builds a fixed frontend image, verifies the currently inspected backend image does not differ from the latest release record, and writes a new release record with: + +```yaml +components: + frontend: + changed: true + backend: + changed: false +``` + +If the inspected backend image differs from the latest release record, the frontend deploy fails. The backend deployment must be recorded first through `backend-prod-deployed` dispatch. + +## Backend PR + +Backend PRs also use exactly one release label, but backend metadata is not copied into a frontend PR body. After backend prod deploy succeeds, backend automation sends the `backend-prod-deployed` payload documented in `ops/backend-release-dispatch.md`. + +The frontend repository then records `FE(current) + BE(new)` with: + +```yaml +components: + frontend: + changed: false + backend: + changed: true +``` + +## First recorded frontend release bootstrap + +The first frontend release record has no previous YAML to inherit backend and rollback metadata from. For that one frontend PR only, include `bootstrap: approved` and the full production combination in the PR body: + +```md +## Release Intent +release: patch +summary: First recorded production release +bootstrap: approved +base_version: v1.0.0 +backend_image: zerooneitkr/zeroone-backend:v1.0.0-b7c8d9e +backend_commit: b7c8d9e +backend_version: v1.0.0 +rollback_frontend_image: zerooneitkr/zeroone-frontend:v0.6.3-a1b2c3d +rollback_backend_image: zerooneitkr/zeroone-backend:v0.6.3-e4f5g6h +db_changed: false +db_migration_version: V12 +db_migration_files: +db_rollback_note: DB rollback is not automated. Confirm app compatibility with the recorded DB state. +``` + +Bootstrap is an explicit exception path. Without `bootstrap: approved`, first-record generation fails. + +## PR body release fallback + +If labels are not available, a frontend PR can use: + +```md +release: patch +summary: QnA image key fix production release +``` + +If a label and body `release` disagree, the workflow fails. If more than one `release:*` label exists, the workflow fails. diff --git a/ops/version-management.md b/ops/version-management.md index 4a89cff2c..15ea93147 100644 --- a/ops/version-management.md +++ b/ops/version-management.md @@ -34,12 +34,6 @@ zeroone-frontend:v{MAJOR}.{MINOR}.{PATCH}-{shortCommit} zeroone-backend:v{MAJOR}.{MINOR}.{PATCH}-{shortCommit} ``` -Hotfix formats: - -```txt -zeroone-frontend:v1.0.1-hotfix.1-f1a2b3c -zeroone-backend:v1.0.1-hotfix.1-b7c8d9e -``` A registry namespace may prefix the image name in workflow/deployment records, for example `zerooneitkr/zeroone-frontend:v1.0.0-f1a2b3c`, but the tag itself must still follow the immutable format above. @@ -124,17 +118,61 @@ Incident analysis starts by identifying: Rollback decisions must use the fixed image tags in `rollback.app_rollback_target`, not `prod` or `latest-prod`. -## GitHub Actions metadata inputs +## Main-branch release intent + +Production versioning is derived from PR intent, not from per-release environment variables. + +Allowed release intents are exactly: + +- `release:patch` +- `release:minor` +- `release:major` + +`hotfix` labels and `-hotfix.N` image tags are not used. + +## Frontend-only production release + +When a frontend PR is merged to `main`, `.github/workflows/deploy-prod.yml` builds and deploys the frontend image, then records the final production combination. + +The workflow uses the latest `releases/prod-*.yaml` as the backend/DB source of truth. It records: + +```yaml +components: + frontend: + changed: true + backend: + changed: false +``` + +If the production backend container can be inspected and its image differs from the latest release record, the frontend workflow fails. This prevents writing a release record with stale backend metadata. + +## Backend-only production release + +When backend production deploy succeeds, backend automation must trigger the frontend repository with `repository_dispatch` event type `backend-prod-deployed`. + +The frontend workflow `.github/workflows/record-backend-prod-release.yml` receives the backend deploy fact, validates it, reads the current frontend production state from the latest release record, and records: + +```yaml +components: + frontend: + changed: false + backend: + changed: true +``` + +The backend dispatch payload contract is documented in `ops/backend-release-dispatch.md`. + +## Duplicate and failure rules + +- `metadata.backend_deploy_id` is required for backend dispatch records. +- The same `metadata.backend_deploy_id` must not be recorded twice. +- `prod` and `latest-prod` are pointer tags only and are rejected as backend, frontend, or rollback image values. +- If payload/schema/current-state validation fails, the workflow must fail rather than guess. + +## Bootstrap rule + +The first recorded frontend release has no previous state to inherit from. It must be explicitly approved with `bootstrap: approved` in the frontend PR body and must include fixed frontend/backend rollback image tags plus current backend metadata. -The production workflow can inspect the running backend container, but the first recorded release and any backend image without OCI labels may need repository variables: +Backend dispatch records require an existing release record so the frontend repository can identify the current frontend production state. -- `PROD_BACKEND_IMAGE` - fixed backend image tag, e.g. `zerooneitkr/zeroone-backend:v1.0.0-b7c8d9e` -- `PROD_BACKEND_COMMIT` - backend short commit -- `PROD_BACKEND_VERSION` - backend service version, e.g. `v1.0.0` -- `PROD_DB_CHANGED` - `true` or `false` -- `PROD_DB_MIGRATION_VERSION` - latest DB migration version or `none` -- `PROD_DB_MIGRATION_FILES` - comma-separated migration filenames -- `PROD_ROLLBACK_FRONTEND_IMAGE` - required for the first recorded release when no previous release YAML exists -- `PROD_ROLLBACK_BACKEND_IMAGE` - required for the first recorded release when no previous release YAML exists -- `PROD_DB_ROLLBACK_NOTE` - optional DB rollback/compatibility note -- `PROD_E2E_BASE_URL` - optional production smoke-check URL +`PROD_E2E_BASE_URL` is the only optional repository variable used by this rule; it controls the production smoke/E2E URL. It is not version metadata. diff --git a/scripts/release/generate-backend-prod-release-record.mjs b/scripts/release/generate-backend-prod-release-record.mjs new file mode 100644 index 000000000..0578b5fed --- /dev/null +++ b/scripts/release/generate-backend-prod-release-record.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +const RELEASES_DIR = 'releases'; +const DEPLOY_ORDER = [ + 'db_migration', + 'backend', + 'backend_health_check', + 'frontend', + 'e2e_check', +]; +const RELEASE_ID = /^prod-\d{8}-\d{4}$/; +const VERSION = /^v\d+\.\d+\.\d+$/; +const DATE_IN_TAG = /:.*\d{8}|:.*\d{4}-\d{2}-\d{2}/; +const POINTER_TAG = /:(prod|latest-prod)$/; +const FRONTEND_IMAGE = + /(?:^|\/)zeroone-frontend:v\d+\.\d+\.\d+-[0-9A-Za-z]{7,}$/; +const BACKEND_IMAGE = /(?:^|\/)zeroone-backend:v\d+\.\d+\.\d+-[0-9A-Za-z]{7,}$/; +const RELEASE_INTENTS = new Set(['patch', 'minor', 'major']); + +const getEnv = (name, fallback = '') => process.env[name]?.trim() || fallback; + +const quote = (value) => { + const s = String(value ?? ''); + if (s === '') return '""'; + if (/^[A-Za-z0-9_./:@+-]+$/.test(s)) return s; + return JSON.stringify(s); +}; + +const parseField = (content, path) => { + const lines = content.split(/\r?\n/); + const stack = []; + for (const raw of lines) { + const match = raw.match(/^(\s*)([A-Za-z0-9_]+):\s*(.*)$/); + if (!match) continue; + const indent = match[1].length; + const key = match[2]; + const value = match[3].trim(); + while (stack.length && stack[stack.length - 1].indent >= indent) + stack.pop(); + stack.push({ indent, key }); + if (stack.map((item) => item.key).join('.') === path) { + return value.replace(/^["']|["']$/g, ''); + } + } + return ''; +}; + +const latestRelease = () => { + if (!existsSync(RELEASES_DIR)) return null; + const files = readdirSync(RELEASES_DIR) + .filter((file) => /^prod-\d{8}-\d{4}\.yaml$/.test(file)) + .sort(); + if (files.length === 0) return null; + const file = files[files.length - 1]; + const content = readFileSync(join(RELEASES_DIR, file), 'utf8'); + return { file, content }; +}; + +const allReleaseFiles = () => { + if (!existsSync(RELEASES_DIR)) return []; + return readdirSync(RELEASES_DIR) + .filter((file) => /^prod-\d{8}-\d{4}\.yaml$/.test(file)) + .map((file) => join(RELEASES_DIR, file)); +}; + +const readPayload = () => { + const raw = getEnv('BACKEND_RELEASE_PAYLOAD_JSON'); + if (!raw) throw new Error('BACKEND_RELEASE_PAYLOAD_JSON is required'); + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid backend release payload JSON: ${error.message}`); + } +}; + +const fail = (message) => { + throw new Error(message); +}; + +const requireString = (value, path) => { + if (typeof value !== 'string' || value.trim() === '') { + fail(`${path} is required`); + } + return value.trim(); +}; + +const requireBoolean = (value, path) => { + if (typeof value !== 'boolean') fail(`${path} must be boolean`); + return value; +}; + +const requireArray = (value, path) => { + if (!Array.isArray(value)) fail(`${path} must be an array`); + return value; +}; + +const validateImage = ({ label, value, regex }) => { + if (!value) fail(`${label} is required`); + if (POINTER_TAG.test(value)) + fail(`${label} must not use prod/latest-prod pointer tag: ${value}`); + if (DATE_IN_TAG.test(value)) + fail(`${label} tag must not contain a date: ${value}`); + if (!regex.test(value)) + fail(`${label} has invalid immutable tag format: ${value}`); +}; + +const payload = readPayload(); +const releaseId = requireString(payload.release_id, 'release_id'); +const env = requireString(payload.env, 'env'); +const backend = payload.backend ?? {}; +const database = payload.database ?? {}; +const rollback = payload.rollback ?? {}; +const metadata = payload.metadata ?? {}; +const backendDeployId = requireString( + metadata.backend_deploy_id, + 'metadata.backend_deploy_id', +); +const releaseIntent = requireString( + metadata.release_intent, + 'metadata.release_intent', +); +const bootstrapMode = requireBoolean( + metadata.bootstrap_mode, + 'metadata.bootstrap_mode', +); + +if (!RELEASE_ID.test(releaseId)) fail(`invalid release_id: ${releaseId}`); +if (env !== 'prod') fail('env must be prod'); +if (!RELEASE_INTENTS.has(releaseIntent)) { + fail('metadata.release_intent must be patch, minor, or major'); +} + +const backendRepo = + backend.repo === undefined + ? 'study-platform-mvp' + : requireString(backend.repo, 'backend.repo'); +const backendImage = requireString(backend.image, 'backend.image'); +const backendCommit = requireString(backend.commit, 'backend.commit'); +const backendVersion = requireString(backend.version, 'backend.version'); +const backendChanged = requireBoolean(backend.changed, 'backend.changed'); +const dbChanged = requireBoolean(database.changed, 'database.changed'); +const migrationVersion = requireString( + database.migration_version, + 'database.migration_version', +); +const migrationFiles = requireArray( + database.migration_files, + 'database.migration_files', +).map((file, index) => + requireString(file, `database.migration_files[${index}]`), +); +const rollbackBackend = requireString(rollback.backend, 'rollback.backend'); + +if (!backendChanged) + fail('backend.changed must be true for backend-prod-deployed'); +if (!VERSION.test(backendVersion)) + fail(`backend.version must be vMAJOR.MINOR.PATCH: ${backendVersion}`); +if (dbChanged && migrationVersion === 'none') { + fail('database.changed=true requires database.migration_version'); +} +validateImage({ + label: 'backend.image', + value: backendImage, + regex: BACKEND_IMAGE, +}); +validateImage({ + label: 'rollback.backend', + value: rollbackBackend, + regex: BACKEND_IMAGE, +}); + +const previous = latestRelease(); +if (!previous) { + fail( + 'Backend dispatch release record requires an existing frontend release record to identify current frontend prod state.', + ); +} + +const frontendImage = parseField(previous.content, 'components.frontend.image'); +const frontendCommit = parseField( + previous.content, + 'components.frontend.commit', +); +const frontendVersion = parseField( + previous.content, + 'components.frontend.version', +); +if (!frontendImage || !frontendCommit || !frontendVersion) { + fail(`Latest release record ${previous.file} is missing frontend prod state`); +} +validateImage({ + label: 'latest components.frontend.image', + value: frontendImage, + regex: FRONTEND_IMAGE, +}); +if (!VERSION.test(frontendVersion)) { + fail( + `latest components.frontend.version must be vMAJOR.MINOR.PATCH: ${frontendVersion}`, + ); +} + +for (const file of allReleaseFiles()) { + const content = readFileSync(file, 'utf8'); + if (parseField(content, 'metadata.backend_deploy_id') === backendDeployId) { + fail(`metadata.backend_deploy_id already recorded in ${file}`); + } +} + +const deployedAt = + getEnv('DEPLOYED_AT') || + new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + .format(new Date()) + .replace(' ', 'T') + .concat('+09:00'); +const deployedBy = getEnv('DEPLOYED_BY', 'automation'); +const summary = + typeof payload.summary === 'string' && payload.summary.trim() + ? payload.summary.trim() + : `backend ${releaseIntent} release`; +const dbRollbackNote = getEnv( + 'DB_ROLLBACK_NOTE', + dbChanged + ? 'Verify compatibility before app rollback if DB changed.' + : 'DB unchanged. Use fixed app image rollback targets if needed.', +); +const pullRequestLabels = Array.isArray(metadata.pull_request_labels) + ? metadata.pull_request_labels.map((label, index) => + requireString(label, `metadata.pull_request_labels[${index}]`), + ) + : []; + +mkdirSync(RELEASES_DIR, { recursive: true }); + +const migrationBlock = migrationFiles.length + ? migrationFiles.map((file) => ` - ${quote(file)}`).join('\n') + : ' []'; +const pullRequestLabelsBlock = pullRequestLabels.length + ? pullRequestLabels.map((label) => ` - ${quote(label)}`).join('\n') + : ' []'; +const previousDeployImage = + typeof metadata.previous_deploy_image === 'string' + ? metadata.previous_deploy_image.trim() + : ''; +if (previousDeployImage) { + validateImage({ + label: 'metadata.previous_deploy_image', + value: previousDeployImage, + regex: BACKEND_IMAGE, + }); +} +const pullRequestNumber = + typeof metadata.pull_request_number === 'number' + ? String(metadata.pull_request_number) + : ''; + +const yaml = `release_id: ${quote(releaseId)} +env: prod +service_version: ${quote(backendVersion)} + +summary: ${quote(summary)} + +components: + frontend: + repo: study-platform-client + image: ${quote(frontendImage)} + commit: ${quote(frontendCommit)} + version: ${quote(frontendVersion)} + changed: false + + backend: + repo: ${quote(backendRepo)} + image: ${quote(backendImage)} + commit: ${quote(backendCommit)} + version: ${quote(backendVersion)} + changed: true + +database: + changed: ${dbChanged ? 'true' : 'false'} + migration_version: ${quote(migrationVersion)} + migration_files: +${migrationBlock} + +rollback: + app_rollback_target: + frontend: ${quote(frontendImage)} + backend: ${quote(rollbackBackend)} + db_rollback_note: ${quote(dbRollbackNote)} + +deploy_order: +${DEPLOY_ORDER.map((item) => ` - ${item}`).join('\n')} + +deployed_at: ${quote(deployedAt)} +deployed_by: ${quote(deployedBy)} +status: success + +metadata: + backend_deploy_id: ${quote(backendDeployId)} + release_intent: ${quote(releaseIntent)} + bootstrap_mode: ${bootstrapMode ? 'true' : 'false'} + previous_deploy_image: ${quote(previousDeployImage)} + pull_request_number: ${quote(pullRequestNumber)} + pull_request_labels: +${pullRequestLabelsBlock} +`; + +const outFile = join(RELEASES_DIR, `${releaseId}.yaml`); +if (existsSync(outFile)) fail(`Release record already exists: ${outFile}`); +writeFileSync(outFile, yaml); +process.stdout.write(`${outFile}\n`); diff --git a/scripts/release/generate-prod-release-record.mjs b/scripts/release/generate-prod-release-record.mjs index b484d9f95..16a489e75 100644 --- a/scripts/release/generate-prod-release-record.mjs +++ b/scripts/release/generate-prod-release-record.mjs @@ -153,5 +153,8 @@ status: success `; const outFile = join(RELEASES_DIR, `${releaseId}.yaml`); +if (existsSync(outFile)) { + throw new Error(`Release record already exists: ${outFile}`); +} writeFileSync(outFile, yaml); process.stdout.write(`${outFile}\n`); diff --git a/scripts/release/resolve-prod-release-intent.mjs b/scripts/release/resolve-prod-release-intent.mjs new file mode 100644 index 000000000..52ed0214a --- /dev/null +++ b/scripts/release/resolve-prod-release-intent.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node +import { appendFileSync, existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const RELEASES_DIR = 'releases'; +const DATE_IN_TAG = /:.*\d{8}|:.*\d{4}-\d{2}-\d{2}/; +const POINTER_TAG = /:(prod|latest-prod)$/; +const FRONTEND_IMAGE = + /(?:^|\/)zeroone-frontend:v\d+\.\d+\.\d+-[0-9A-Za-z]{7,}$/; +const BACKEND_IMAGE = /(?:^|\/)zeroone-backend:v\d+\.\d+\.\d+-[0-9A-Za-z]{7,}$/; + +const getEnv = (name, fallback = '') => process.env[name]?.trim() || fallback; +const required = (name) => { + const value = getEnv(name); + if (!value) throw new Error(`${name} is required`); + return value; +}; + +const parseField = (content, path) => { + const lines = content.split(/\r?\n/); + const stack = []; + for (const raw of lines) { + const match = raw.match(/^(\s*)([A-Za-z0-9_]+):\s*(.*)$/); + if (!match) continue; + const indent = match[1].length; + const key = match[2]; + const value = match[3].trim(); + while (stack.length && stack[stack.length - 1].indent >= indent) + stack.pop(); + stack.push({ indent, key }); + if (stack.map((item) => item.key).join('.') === path) { + return value.replace(/^["']|["']$/g, ''); + } + } + return ''; +}; + +const latestRelease = () => { + if (!existsSync(RELEASES_DIR)) return null; + const files = readdirSync(RELEASES_DIR) + .filter((file) => /^prod-\d{8}-\d{4}\.yaml$/.test(file)) + .sort(); + if (files.length === 0) return null; + const file = files[files.length - 1]; + const content = readFileSync(join(RELEASES_DIR, file), 'utf8'); + return { file, content }; +}; + +const normalizeVersion = (value) => { + const version = String(value || '').trim(); + if (!version) return ''; + return version.startsWith('v') ? version : `v${version}`; +}; + +const parseSemver = (value) => { + const match = normalizeVersion(value).match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (!match) throw new Error(`Invalid service version: ${value}`); + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +}; + +const bumpVersion = ({ + current, + releaseType, + explicitVersion, + firstRelease, +}) => { + if (explicitVersion) return normalizeVersion(explicitVersion); + const base = parseSemver(current); + if (firstRelease) return normalizeVersion(current); + + if (releaseType === 'major') return `v${base.major + 1}.0.0`; + if (releaseType === 'minor') return `v${base.major}.${base.minor + 1}.0`; + if (releaseType === 'patch') + return `v${base.major}.${base.minor}.${base.patch + 1}`; + + throw new Error( + `Unsupported release type: ${releaseType}. Use major, minor, or patch`, + ); +}; + +const parseKeyValues = (body) => { + const values = new Map(); + for (const raw of String(body || '').split(/\r?\n/)) { + const match = raw.match(/^\s*[-*]?\s*([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$/); + if (!match) continue; + values.set(match[1].toLowerCase().replaceAll('-', '_'), match[2].trim()); + } + return values; +}; + +const parseReleaseTypeFromLabels = (labels) => { + const matches = labels + .map((label) => label.match(/^release:(major|minor|patch)$/i)) + .filter(Boolean) + .map((match) => match[1].toLowerCase()); + if (matches.length > 1) { + throw new Error( + `Exactly one release intent label is allowed, but found ${matches.length}: ${matches.join(', ')}`, + ); + } + return matches[0] || ''; +}; + +const validateImage = ({ label, value, regex }) => { + if (!value) throw new Error(`${label} is required`); + if (POINTER_TAG.test(value)) { + throw new Error( + `${label} must not use prod/latest-prod pointer tag: ${value}`, + ); + } + if (DATE_IN_TAG.test(value)) { + throw new Error(`${label} tag must not contain a date: ${value}`); + } + if (!regex.test(value)) { + throw new Error(`${label} has invalid immutable tag format: ${value}`); + } +}; + +const fetchAssociatedPullRequest = async () => { + const token = getEnv('GITHUB_TOKEN'); + const repository = getEnv('GITHUB_REPOSITORY'); + const sha = getEnv('GITHUB_SHA'); + if (!token || !repository || !sha) return null; + + const response = await fetch( + `https://api.github.com/repos/${repository}/commits/${sha}/pulls`, + { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'zeroone-prod-release-intent', + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch associated pull request for ${sha}: ${response.status} ${response.statusText}`, + ); + } + + const pulls = await response.json(); + return pulls[0] ?? null; +}; + +const getIntent = async () => { + const suppliedBody = getEnv('RELEASE_INTENT_BODY'); + const suppliedLabels = getEnv('RELEASE_INTENT_LABELS') + .split(',') + .map((label) => label.trim()) + .filter(Boolean); + + if (suppliedBody || suppliedLabels.length > 0) { + return { + body: suppliedBody, + labels: suppliedLabels, + number: getEnv('RELEASE_INTENT_PR_NUMBER'), + title: getEnv('RELEASE_INTENT_TITLE', 'Production release'), + source: 'env', + }; + } + + const pull = await fetchAssociatedPullRequest(); + if (pull) { + return { + body: pull.body || '', + labels: (pull.labels || []).map((label) => label.name).filter(Boolean), + number: String(pull.number), + title: pull.title || 'Production release', + source: 'pull_request', + }; + } + + return { + body: '', + labels: [], + number: '', + title: getEnv('WORKFLOW_RELEASE_SUMMARY', 'Manual production deployment'), + source: 'none', + }; +}; + +const writeOutput = (outputs) => { + const outputPath = getEnv('GITHUB_OUTPUT'); + const lines = Object.entries(outputs).map( + ([key, value]) => `${key}=${value ?? ''}`, + ); + if (outputPath) appendFileSync(outputPath, `${lines.join('\n')}\n`); + process.stdout.write(`${lines.join('\n')}\n`); +}; + +const previous = latestRelease(); +const intent = await getIntent(); +const values = parseKeyValues(intent.body); +const labelReleaseType = parseReleaseTypeFromLabels(intent.labels); +const bodyReleaseType = ( + values.get('release') || + values.get('release_type') || + '' +).toLowerCase(); +const explicitVersion = normalizeVersion( + values.get('version') || values.get('service_version') || '', +); +if ( + labelReleaseType && + bodyReleaseType && + labelReleaseType !== bodyReleaseType +) { + throw new Error( + `Release intent label (${labelReleaseType}) conflicts with PR body release (${bodyReleaseType}). Keep exactly one source of truth.`, + ); +} +const releaseType = labelReleaseType || bodyReleaseType; +const workflowSummary = getEnv('WORKFLOW_RELEASE_SUMMARY'); + +if (!explicitVersion && !releaseType) { + if (getEnv('GITHUB_EVENT_NAME') !== 'workflow_dispatch') { + throw new Error( + 'Production release intent is required. Add a PR label release:major, release:minor, or release:patch, or put release: patch in the PR body.', + ); + } +} + +const latestVersion = previous + ? parseField(previous.content, 'service_version') || + parseField(previous.content, 'components.frontend.version') + : ''; +const firstRelease = !previous; +const bootstrapApproved = values.get('bootstrap') === 'approved'; +if (!firstRelease && values.get('bootstrap')) { + throw new Error( + 'bootstrap is only allowed for the first recorded production release', + ); +} +if ( + !firstRelease && + (/^true$/i.test(values.get('backend_changed') || '') || + /^true$/i.test(values.get('db_changed') || '') || + values.get('backend_image') || + values.get('backend_commit') || + values.get('backend_version')) +) { + throw new Error( + 'Backend/DB changes must be recorded through backend-prod-deployed repository_dispatch, not the frontend deployment PR body.', + ); +} +if (firstRelease && !bootstrapApproved) { + throw new Error( + 'No previous release record exists. Add bootstrap: approved and the required bootstrap approval metadata to the PR body.', + ); +} +if (firstRelease && !values.get('base_version') && !explicitVersion) { + throw new Error( + 'First recorded release requires base_version or explicit version in the PR body.', + ); +} +const baseVersion = normalizeVersion( + values.get('base_version') || latestVersion, +); +const frontendVersion = bumpVersion({ + current: baseVersion, + releaseType: releaseType || 'patch', + explicitVersion, + firstRelease, +}); +const shortSha = required('GITHUB_SHA').slice(0, 7); +const frontendImageRepository = required('FRONTEND_IMAGE_REPOSITORY'); +const frontendImage = `${frontendImageRepository}:${frontendVersion}-${shortSha}`; +const releaseId = `prod-${new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}) + .format(new Date()) + .replace(/[-: ]/g, '') + .replace(/(\d{8})(\d{4})/, '$1-$2')}`; +const deployedAt = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}) + .format(new Date()) + .replace(' ', 'T') + .concat('+09:00'); + +const previousBackendImage = previous + ? parseField(previous.content, 'components.backend.image') + : ''; +const previousBackendCommit = previous + ? parseField(previous.content, 'components.backend.commit') + : ''; +const previousBackendVersion = previous + ? parseField(previous.content, 'components.backend.version') + : ''; +const backendImage = values.get('backend_image') || previousBackendImage; +const backendCommit = values.get('backend_commit') || previousBackendCommit; +const backendVersion = normalizeVersion( + values.get('backend_version') || previousBackendVersion, +); +const rollbackFrontendImage = + values.get('rollback_frontend_image') || + (previous ? parseField(previous.content, 'components.frontend.image') : ''); +const rollbackBackendImage = + values.get('rollback_backend_image') || + (previous ? parseField(previous.content, 'components.backend.image') : ''); + +if (!backendImage || !backendCommit || !backendVersion) { + throw new Error( + 'Backend image/commit/version are required in the PR body for the first recorded release. Use backend_image, backend_commit, and backend_version.', + ); +} +if (!rollbackFrontendImage || !rollbackBackendImage) { + throw new Error( + 'Rollback frontend/backend images are required in the PR body for the first recorded release. Use rollback_frontend_image and rollback_backend_image.', + ); +} + +validateImage({ + label: 'frontend_image', + value: frontendImage, + regex: FRONTEND_IMAGE, +}); +validateImage({ + label: 'backend_image', + value: backendImage, + regex: BACKEND_IMAGE, +}); +validateImage({ + label: 'rollback_frontend_image', + value: rollbackFrontendImage, + regex: FRONTEND_IMAGE, +}); +validateImage({ + label: 'rollback_backend_image', + value: rollbackBackendImage, + regex: BACKEND_IMAGE, +}); + +writeOutput({ + release_id: releaseId, + deployed_at: deployedAt, + frontend_image: frontendImage, + frontend_commit: shortSha, + frontend_version: frontendVersion, + release_summary: values.get('summary') || workflowSummary || intent.title, + source_pr_number: intent.number, + source_intent: intent.source, + release_type: releaseType || 'patch', + backend_changed: 'false', + backend_image: backendImage, + backend_commit: backendCommit, + backend_version: backendVersion, + db_changed: values.get('db_changed') || 'false', + db_migration_version: + values.get('db_migration_version') || + (previous + ? parseField(previous.content, 'database.migration_version') + : 'none'), + db_migration_files: values.get('db_migration_files') || '', + rollback_frontend_image: rollbackFrontendImage, + rollback_backend_image: rollbackBackendImage, + db_rollback_note: + values.get('db_rollback_note') || + (previous + ? parseField(previous.content, 'rollback.db_rollback_note') + : '') || + 'DB rollback is not automated. Confirm app compatibility with the recorded DB state.', +}); diff --git a/scripts/release/validate-release-record.mjs b/scripts/release/validate-release-record.mjs index 63433e7fb..110580acf 100644 --- a/scripts/release/validate-release-record.mjs +++ b/scripts/release/validate-release-record.mjs @@ -6,9 +6,10 @@ const RELEASE_ID = /^prod-\d{8}-\d{4}$/; const DATE_IN_TAG = /:.*\d{8}|:.*\d{4}-\d{2}-\d{2}/; const POINTER_TAG = /:(prod|latest-prod)$/; const FRONTEND_IMAGE = - /(?:^|\/)zeroone-frontend:v\d+\.\d+\.\d+(?:-hotfix\.\d+)?-[0-9A-Za-z]{7,}$/; -const BACKEND_IMAGE = - /(?:^|\/)zeroone-backend:v\d+\.\d+\.\d+(?:-hotfix\.\d+)?-[0-9A-Za-z]{7,}$/; + /(?:^|\/)zeroone-frontend:v\d+\.\d+\.\d+-[0-9A-Za-z]{7,}$/; +const BACKEND_IMAGE = /(?:^|\/)zeroone-backend:v\d+\.\d+\.\d+-[0-9A-Za-z]{7,}$/; +const SERVICE_VERSION = /^v\d+\.\d+\.\d+$/; +const RELEASE_INTENT = /^(patch|minor|major)$/; const args = process.argv.slice(2); const targets = args.length ? args : ['releases']; @@ -67,6 +68,10 @@ const validateFile = (file) => { if (parseScalar(content, 'env') !== 'prod') fail(file, 'env must be prod'); if (parseScalar(content, 'status') !== 'success') fail(file, 'status must be success'); + const serviceVersion = parseScalar(content, 'service_version'); + if (!SERVICE_VERSION.test(serviceVersion)) { + fail(file, `service_version must be vMAJOR.MINOR.PATCH: ${serviceVersion}`); + } validateImage( file, @@ -93,6 +98,24 @@ const validateFile = (file) => { BACKEND_IMAGE, ); + const backendChanged = parseScalar(content, 'components.backend.changed'); + if (backendChanged === 'true') { + const backendDeployId = parseScalar(content, 'metadata.backend_deploy_id'); + const releaseIntent = parseScalar(content, 'metadata.release_intent'); + const bootstrapMode = parseScalar(content, 'metadata.bootstrap_mode'); + if (!backendDeployId) + fail(file, 'metadata.backend_deploy_id is required when backend changed'); + if (!RELEASE_INTENT.test(releaseIntent)) { + fail(file, 'metadata.release_intent must be patch, minor, or major'); + } + if (bootstrapMode !== 'true' && bootstrapMode !== 'false') { + fail( + file, + 'metadata.bootstrap_mode must be true or false when backend changed', + ); + } + } + const dbChanged = parseScalar(content, 'database.changed'); const migrationVersion = parseScalar(content, 'database.migration_version'); if ( @@ -120,5 +143,18 @@ const validateFile = (file) => { const files = targets .flatMap(listYamlFiles) .filter((file) => /prod-\d{8}-\d{4}\.yaml$/.test(file)); -for (const file of files) validateFile(file); +const backendDeployIds = new Map(); +for (const file of files) { + validateFile(file); + const content = readFileSync(file, 'utf8'); + const backendDeployId = parseScalar(content, 'metadata.backend_deploy_id'); + if (!backendDeployId) continue; + if (backendDeployIds.has(backendDeployId)) { + fail( + file, + `duplicate metadata.backend_deploy_id also recorded in ${backendDeployIds.get(backendDeployId)}`, + ); + } + backendDeployIds.set(backendDeployId, file); +} process.stdout.write(`Validated ${files.length} release record(s).\n`); From ff6672b4d67f39f8062ff7bbe95a33652ac5b7cb Mon Sep 17 00:00:00 2001 From: Hyeonjun0527 Date: Sun, 17 May 2026 20:32:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: OmX --- .github/workflows/deploy-prod.yml | 16 +- .../workflows/record-backend-prod-release.yml | 23 +- .../zeroone-version-management.md | 14 +- ops/backend-release-dispatch.md | 4 +- ops/deploy-checklist.md | 2 + ops/release-intent.md | 2 + ops/release-record-shared-contract.md | 300 ++++++++++++++++++ ops/version-management.md | 2 + .../generate-backend-prod-release-record.mjs | 24 +- .../release/generate-prod-release-record.mjs | 36 ++- .../release/resolve-prod-release-intent.mjs | 85 ++++- scripts/release/validate-release-record.mjs | 44 ++- 12 files changed, 514 insertions(+), 38 deletions(-) create mode 100644 ops/release-record-shared-contract.md diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 103319be0..3d40ceee1 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -44,6 +44,11 @@ jobs: 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 }} @@ -116,6 +121,11 @@ jobs: 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 }} @@ -222,8 +232,12 @@ jobs: 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:-none}" + 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:-}" diff --git a/.github/workflows/record-backend-prod-release.yml b/.github/workflows/record-backend-prod-release.yml index 229ba141e..82f005ce5 100644 --- a/.github/workflows/record-backend-prod-release.yml +++ b/.github/workflows/record-backend-prod-release.yml @@ -6,6 +6,15 @@ on: repository_dispatch: types: - backend-prod-deployed + workflow_dispatch: + inputs: + backend_payload_json: + description: 'Backend dispatch wrapper or client_payload JSON for validation/dry-run' + required: true + dry_run: + description: 'true면 record 생성/검증만 하고 commit/push하지 않는다' + required: true + default: 'true' permissions: contents: write @@ -18,7 +27,8 @@ jobs: record-backend-release: runs-on: ubuntu-latest env: - BACKEND_RELEASE_PAYLOAD_JSON: ${{ toJSON(github.event.client_payload) }} + BACKEND_RELEASE_PAYLOAD_JSON: ${{ github.event_name == 'workflow_dispatch' && inputs.backend_payload_json || toJSON(github.event.client_payload) }} + BACKEND_RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} steps: - name: Checkout code @@ -38,8 +48,19 @@ jobs: RECORD_PATH="$(node scripts/release/generate-backend-prod-release-record.mjs)" node scripts/release/validate-release-record.mjs "$RECORD_PATH" echo "RECORD_PATH=$RECORD_PATH" >> "$GITHUB_ENV" + echo "Generated $RECORD_PATH" + sed -n '1,220p' "$RECORD_PATH" + + - name: Upload dry-run release record + if: ${{ env.BACKEND_RELEASE_DRY_RUN == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: backend-release-record-dry-run + path: ${{ env.RECORD_PATH }} + if-no-files-found: error - name: Commit release record + if: ${{ env.BACKEND_RELEASE_DRY_RUN != 'true' }} run: | set -euo pipefail git config user.name "github-actions[bot]" diff --git a/ops/agent-skills/zeroone-version-management.md b/ops/agent-skills/zeroone-version-management.md index f01a48396..f210cfe50 100644 --- a/ops/agent-skills/zeroone-version-management.md +++ b/ops/agent-skills/zeroone-version-management.md @@ -6,10 +6,11 @@ This file is the shared skill source of truth. Codex and Claude wrappers must st ## Required reading order -1. `ops/version-management.md` - repository rule and release-record policy. -2. `ops/release-intent.md` - human usage for PR labels/body and bootstrap. -3. `ops/deploy-checklist.md` or `ops/rollback.md` only when deploying or rolling back. -4. Relevant scripts/workflows only after the docs above: +1. `ops/release-record-shared-contract.md` - FE/BE shared payload and final record contract. +2. `ops/version-management.md` - frontend repository rule and release-record policy. +3. `ops/release-intent.md` - human usage for PR labels/body and bootstrap. +4. `ops/deploy-checklist.md` or `ops/rollback.md` only when deploying or rolling back. +5. Relevant scripts/workflows only after the docs above: - `.github/workflows/deploy-prod.yml` - `.github/workflows/release-record-check.yml` - `scripts/release/resolve-prod-release-intent.mjs` @@ -17,14 +18,15 @@ This file is the shared skill source of truth. Codex and Claude wrappers must st - `scripts/release/generate-backend-prod-release-record.mjs` - `scripts/release/validate-release-record.mjs` - `ops/backend-release-dispatch.md` + - `ops/release-record-shared-contract.md` ## Non-negotiable rules - This skill applies to `main` production releases only. Do not change `develop` deployment behavior unless the user explicitly asks. -- `releases/` is the frontend repository source of truth for successful production FE/BE/DB/rollback combinations. +- `ops/release-record-shared-contract.md` is the shared FE/BE contract; `releases/` is the frontend repository source of truth for successful production FE/BE/DB/rollback combinations. - Frontend repo owns only the frontend version-management rule. Do not add the backend repository rule here. - Production version metadata comes from PR intent or backend dispatch payload, not per-release repository variables. -- Exactly one release intent is allowed: `release:major`, `release:minor`, or `release:patch`. +- Exactly one release intent is allowed: `release:major`, `release:minor`, or `release:patch`. Use `N/A` for no DB migration version. - If multiple `release:*` labels are present, or label intent conflicts with body `release`, fail instead of guessing. - First recorded frontend production release requires explicit bootstrap approval metadata in the PR body: `bootstrap: approved`, `base_version` or `version`, backend image/commit/version, and rollback frontend/backend fixed image tags. - `prod` and `latest-prod` are pointer tags only. They are never valid rollback targets and must fail if used as inherited or supplied backend/rollback images. diff --git a/ops/backend-release-dispatch.md b/ops/backend-release-dispatch.md index c1c6bb021..954d3ed35 100644 --- a/ops/backend-release-dispatch.md +++ b/ops/backend-release-dispatch.md @@ -2,6 +2,8 @@ This is the frontend repository contract for backend production deployments. Backend automation is the producer of backend deploy facts. The frontend repository is the final release-record writer. +This document follows the shared FE/BE contract in `ops/release-record-shared-contract.md`. + ## Trigger Backend production deploy success must call the frontend repository with `repository_dispatch` or an equivalent API trigger. @@ -63,7 +65,7 @@ The frontend workflow that receives this event is: - `backend.version` - `vMAJOR.MINOR.PATCH` - `backend.changed` - must be `true` - `database.changed` - boolean -- `database.migration_version` - migration version or `none` +- `database.migration_version` - migration version or `N/A` - `database.migration_files` - array - `rollback.backend` - fixed immutable backend rollback image tag - `metadata.release_intent` - `patch`, `minor`, or `major` diff --git a/ops/deploy-checklist.md b/ops/deploy-checklist.md index e86dc093b..0c1f2e7b8 100644 --- a/ops/deploy-checklist.md +++ b/ops/deploy-checklist.md @@ -2,6 +2,8 @@ This checklist applies to `main` production deployments only. `develop` test-server deployment keeps the existing lightweight pointer-tag flow. +This document follows the shared FE/BE contract in `ops/release-record-shared-contract.md`. + ## Before production deploy - Confirm PR CI is green before merging to `main`. diff --git a/ops/release-intent.md b/ops/release-intent.md index 36210fa65..ea419921d 100644 --- a/ops/release-intent.md +++ b/ops/release-intent.md @@ -4,6 +4,8 @@ Production release intent is how a PR tells the production automation what seman This applies only when code is deployed from `main`. `develop` test-server deployment keeps its existing flow. +This document follows the shared FE/BE contract in `ops/release-record-shared-contract.md`. + ## Allowed labels Use exactly one release label: diff --git a/ops/release-record-shared-contract.md b/ops/release-record-shared-contract.md new file mode 100644 index 000000000..d01b9ec06 --- /dev/null +++ b/ops/release-record-shared-contract.md @@ -0,0 +1,300 @@ +# ZERO-ONE Release Record Shared Contract + +This is the shared production release-record contract between `study-platform-mvp` backend and `study-platform-client` frontend. + +It fixes two schemas: + +1. the backend-to-frontend release payload schema, and +2. the final frontend repository release record schema. + +The final release-record source of truth is: + +```txt +study-platform-client/releases/ +``` + +## 1. Roles + +### Backend + +Backend is the release fact producer. It deploys production backend, resolves backend version/image, DB migration metadata, and backend rollback target, then sends that fact to the frontend workflow. + +### Frontend + +Frontend is the final release record writer. It validates backend facts, reads the current frontend production state, and records the final FE/BE/DB production combination. + +## 2. Release intent + +Exactly one release intent is allowed: + +- `release:major` +- `release:minor` +- `release:patch` + +`hotfix` labels and `-hotfix.N` image tags are not used. + +## 3. Identifiers + +### Service version + +```txt +vMAJOR.MINOR.PATCH +``` + +### Image tags + +Image tags must not contain dates. + +```txt +zeroone-frontend:vMAJOR.MINOR.PATCH-shortCommit +zeroone-backend:vMAJOR.MINOR.PATCH-shortCommit +``` + +A registry prefix is allowed, for example `zerooneitkr/zeroone-backend:v1.4.3-b7c8d9e`. + +### Release ID + +Date/time belongs only in `release_id`. + +```txt +prod-YYYYMMDD-HHmm +``` + +### Mutable tags + +`prod` and `latest-prod` are deployment pointers only. They are never valid rollback targets. + +## 4. Backend to frontend dispatch contract + +Backend production deploy success triggers the frontend repository workflow using `repository_dispatch`. + +- target repository: `code-zero-to-one/study-platform-client` +- event type: `backend-prod-deployed` + +### Dispatch wrapper + +```json +{ + "event_type": "backend-prod-deployed", + "client_payload": { + "release_id": "prod-20260517-2100", + "env": "prod", + "summary": "[patch] Backend release summary", + "backend": { + "repo": "study-platform-mvp", + "image": "zeroone-backend:v1.4.3-b7c8d9e", + "commit": "b7c8d9e", + "version": "v1.4.3", + "changed": true + }, + "database": { + "changed": true, + "migration_version": "V45", + "migration_files": [ + "src/main/resources/db/migration/V45__create_course_refund.sql" + ] + }, + "rollback": { + "backend": "zeroone-backend:v1.4.2-a1b2c3d" + }, + "metadata": { + "release_intent": "patch", + "bootstrap_mode": false, + "previous_deploy_image": "zeroone-backend:v1.4.2-a1b2c3d", + "pull_request_number": 1234, + "pull_request_labels": ["release:patch", "db:backup-confirmed"], + "backend_deploy_id": "backend-prod-123" + } + } +} +``` + +The frontend script also accepts the inner `client_payload` object directly when GitHub Actions already selected `github.event.client_payload`. + +### Required payload fields + +- `release_id` +- `env` +- `backend.repo` +- `backend.image` +- `backend.commit` +- `backend.version` +- `backend.changed` +- `database.changed` +- `database.migration_version` +- `database.migration_files` +- `rollback.backend` +- `metadata.release_intent` +- `metadata.bootstrap_mode` +- `metadata.backend_deploy_id` + +### Optional payload fields + +- `summary` +- `metadata.previous_deploy_image` +- `metadata.pull_request_number` +- `metadata.pull_request_labels` + +### Field semantics + +- `database.migration_version`: representative migration version. Use `N/A` when there is no migration. +- `database.migration_files`: migration file list. Use `[]` when there is no migration. +- `metadata.backend_deploy_id`: backend deploy dedupe key. Frontend must not record the same backend deployment twice as a new backend-only fact. + +## 5. Final release record contract + +Records are written to: + +```txt +releases/.yaml +``` + +### Backend-origin record example + +```yaml +release_id: prod-20260517-2100 +env: prod +service_version: v1.4.3 + +summary: backend patch release + +components: + frontend: + repo: study-platform-client + image: zeroone-frontend:v1.4.2-f1a2b3c + commit: f1a2b3c + version: v1.4.2 + changed: false + + backend: + repo: study-platform-mvp + image: zeroone-backend:v1.4.3-b7c8d9e + commit: b7c8d9e + version: v1.4.3 + changed: true + +database: + changed: true + migration_version: V45 + migration_files: + - src/main/resources/db/migration/V45__create_course_refund.sql + +rollback: + app_rollback_target: + frontend: zeroone-frontend:v1.4.2-f1a2b3c + backend: zeroone-backend:v1.4.2-a1b2c3d + db_rollback_note: Verify compatibility before app rollback if DB changed. + +deploy_order: + - db_migration + - backend + - backend_health_check + - frontend + - e2e_check + +deployed_at: 2026-05-17T21:00:00+09:00 +deployed_by: automation +status: success + +metadata: + backend_deploy_id: backend-prod-123 + release_intent: patch + bootstrap_mode: false +``` + +### Required final fields + +- `release_id` +- `env` +- `service_version` +- `components.frontend.image` +- `components.frontend.commit` +- `components.frontend.version` +- `components.frontend.changed` +- `components.backend.image` +- `components.backend.commit` +- `components.backend.version` +- `components.backend.changed` +- `database.changed` +- `database.migration_version` +- `database.migration_files` +- `rollback.app_rollback_target.frontend` +- `rollback.app_rollback_target.backend` +- `deploy_order` +- `deployed_at` +- `deployed_by` +- `status` + +Additionally: + +- if `components.backend.changed: true`, `metadata.backend_deploy_id`, `metadata.release_intent`, and `metadata.bootstrap_mode` are required. +- if `components.frontend.changed: true`, `metadata.frontend_deploy_id` is required. +- if a frontend deployment intentionally pairs with a previous backend deploy fact, `metadata.paired_backend_deploy_id` must equal `metadata.backend_deploy_id`. + +## 6. Atomic release record rules + +The release record must describe the actual production FE/BE combination at that point in time. + +### Backend-only + +```yaml +components: + frontend: + changed: false + backend: + changed: true +``` + +### Frontend-only + +```yaml +components: + frontend: + changed: true + backend: + changed: false +``` + +### Paired FE/BE change + +When frontend deploy intentionally pairs with a previously recorded backend deploy id: + +```yaml +components: + frontend: + changed: true + backend: + changed: true +metadata: + backend_deploy_id: backend-prod-123 + paired_backend_deploy_id: backend-prod-123 +``` + +The paired mode must be explicit. Frontend must not guess. + +## 7. Validation and failure rules + +Frontend fails instead of writing a release record when: + +- payload is missing, +- schema mismatches, +- image/version/release_id is invalid, +- rollback image is missing or mutable, +- DB field shape is invalid, +- backend deploy id is missing for backend-origin records, +- duplicate backend-only deploy id is detected, +- current frontend state cannot be identified for backend-origin records. + +Wrong release records are worse than failed automation. + +## 8. Bootstrap + +If current production is not yet on canonical image tags, backend may use bootstrap mode only with explicit `bootstrap:approved` approval on the backend side. + +Bootstrap version calculation: + +- `release:patch` -> `v0.0.1` +- `release:minor` -> `v0.1.0` +- `release:major` -> `v1.0.0` + +Frontend records backend bootstrap facts with `metadata.bootstrap_mode: true`. diff --git a/ops/version-management.md b/ops/version-management.md index 15ea93147..7e02bc726 100644 --- a/ops/version-management.md +++ b/ops/version-management.md @@ -2,6 +2,8 @@ This document is the frontend repository source of truth for ZERO-ONE production release records. It is a version-management rule, not a frontend coding-style rule. +This document follows the shared FE/BE contract in `ops/release-record-shared-contract.md`. + ## Responsibility - `study-platform-client` owns the final production release record because it is the user-facing application and the running product depends on a compatible frontend/backend/database combination. diff --git a/scripts/release/generate-backend-prod-release-record.mjs b/scripts/release/generate-backend-prod-release-record.mjs index 0578b5fed..f5634d357 100644 --- a/scripts/release/generate-backend-prod-release-record.mjs +++ b/scripts/release/generate-backend-prod-release-record.mjs @@ -75,7 +75,19 @@ const readPayload = () => { const raw = getEnv('BACKEND_RELEASE_PAYLOAD_JSON'); if (!raw) throw new Error('BACKEND_RELEASE_PAYLOAD_JSON is required'); try { - return JSON.parse(raw); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && 'event_type' in parsed) { + if (parsed.event_type !== 'backend-prod-deployed') { + throw new Error( + `event_type must be backend-prod-deployed: ${parsed.event_type}`, + ); + } + if (!parsed.client_payload || typeof parsed.client_payload !== 'object') { + throw new Error('client_payload is required'); + } + return parsed.client_payload; + } + return parsed; } catch (error) { throw new Error(`Invalid backend release payload JSON: ${error.message}`); } @@ -138,10 +150,7 @@ if (!RELEASE_INTENTS.has(releaseIntent)) { fail('metadata.release_intent must be patch, minor, or major'); } -const backendRepo = - backend.repo === undefined - ? 'study-platform-mvp' - : requireString(backend.repo, 'backend.repo'); +const backendRepo = requireString(backend.repo, 'backend.repo'); const backendImage = requireString(backend.image, 'backend.image'); const backendCommit = requireString(backend.commit, 'backend.commit'); const backendVersion = requireString(backend.version, 'backend.version'); @@ -163,7 +172,10 @@ if (!backendChanged) fail('backend.changed must be true for backend-prod-deployed'); if (!VERSION.test(backendVersion)) fail(`backend.version must be vMAJOR.MINOR.PATCH: ${backendVersion}`); -if (dbChanged && migrationVersion === 'none') { +if (migrationVersion === 'none') { + fail('database.migration_version must use N/A when there is no migration'); +} +if (dbChanged && migrationVersion === 'N/A') { fail('database.changed=true requires database.migration_version'); } validateImage({ diff --git a/scripts/release/generate-prod-release-record.mjs b/scripts/release/generate-prod-release-record.mjs index 16a489e75..c9c6a14b4 100644 --- a/scripts/release/generate-prod-release-record.mjs +++ b/scripts/release/generate-prod-release-record.mjs @@ -91,7 +91,7 @@ if (!rollbackBackend) { } const dbChanged = /^true$/i.test(getEnv('DB_CHANGED', 'false')); -const migrationVersion = getEnv('DB_MIGRATION_VERSION', 'none'); +const migrationVersion = getEnv('DB_MIGRATION_VERSION', 'N/A'); const migrationFiles = getEnv('DB_MIGRATION_FILES') .split(',') .map((item) => item.trim()) @@ -104,6 +104,33 @@ const dbRollbackNote = getEnv( 'DB rollback is not automated. Confirm app compatibility with the recorded DB state.', ); const backendChanged = /^true$/i.test(getEnv('BACKEND_CHANGED', 'false')); +const frontendDeployId = getEnv( + 'FRONTEND_DEPLOY_ID', + `frontend-${releaseId}-${frontendCommit}`, +); +const backendDeployId = getEnv('BACKEND_DEPLOY_ID'); +const pairedBackendDeployId = getEnv('PAIRED_BACKEND_DEPLOY_ID'); +const backendReleaseIntent = getEnv('BACKEND_RELEASE_INTENT'); +const bootstrapMode = getEnv('BOOTSTRAP_MODE'); + +if (migrationVersion === 'none') { + throw new Error( + 'DB_MIGRATION_VERSION must use N/A when there is no migration', + ); +} +if (dbChanged && migrationVersion === 'N/A') { + throw new Error('DB_CHANGED=true requires DB_MIGRATION_VERSION'); +} +if (backendChanged && !backendDeployId) { + throw new Error('BACKEND_DEPLOY_ID is required when BACKEND_CHANGED=true'); +} +if ( + backendChanged && + pairedBackendDeployId && + pairedBackendDeployId !== backendDeployId +) { + throw new Error('PAIRED_BACKEND_DEPLOY_ID must match BACKEND_DEPLOY_ID'); +} mkdirSync(RELEASES_DIR, { recursive: true }); @@ -150,6 +177,13 @@ ${DEPLOY_ORDER.map((item) => ` - ${item}`).join('\n')} deployed_at: ${quote(deployedAt)} deployed_by: ${quote(deployedBy)} status: success + +metadata: + frontend_deploy_id: ${quote(frontendDeployId)} + backend_deploy_id: ${quote(backendDeployId)} + paired_backend_deploy_id: ${quote(pairedBackendDeployId)} + release_intent: ${quote(backendReleaseIntent)} + bootstrap_mode: ${quote(bootstrapMode)} `; const outFile = join(RELEASES_DIR, `${releaseId}.yaml`); diff --git a/scripts/release/resolve-prod-release-intent.mjs b/scripts/release/resolve-prod-release-intent.mjs index 52ed0214a..77fe2178b 100644 --- a/scripts/release/resolve-prod-release-intent.mjs +++ b/scripts/release/resolve-prod-release-intent.mjs @@ -35,17 +35,32 @@ const parseField = (content, path) => { return ''; }; -const latestRelease = () => { - if (!existsSync(RELEASES_DIR)) return null; - const files = readdirSync(RELEASES_DIR) +const releaseFiles = () => { + if (!existsSync(RELEASES_DIR)) return []; + return readdirSync(RELEASES_DIR) .filter((file) => /^prod-\d{8}-\d{4}\.yaml$/.test(file)) .sort(); +}; + +const latestRelease = () => { + const files = releaseFiles(); if (files.length === 0) return null; const file = files[files.length - 1]; const content = readFileSync(join(RELEASES_DIR, file), 'utf8'); return { file, content }; }; +const releaseByBackendDeployId = (backendDeployId) => { + if (!backendDeployId) return null; + for (const file of releaseFiles().reverse()) { + const content = readFileSync(join(RELEASES_DIR, file), 'utf8'); + if (parseField(content, 'metadata.backend_deploy_id') === backendDeployId) { + return { file, content }; + } + } + return null; +}; + const normalizeVersion = (value) => { const version = String(value || '').trim(); if (!version) return ''; @@ -297,26 +312,51 @@ const deployedAt = new Intl.DateTimeFormat('sv-SE', { .replace(' ', 'T') .concat('+09:00'); -const previousBackendImage = previous - ? parseField(previous.content, 'components.backend.image') +const pairedBackendDeployId = values.get('paired_backend_deploy_id') || ''; +const pairedBackendRelease = releaseByBackendDeployId(pairedBackendDeployId); +if (pairedBackendDeployId && !pairedBackendRelease) { + throw new Error( + `paired_backend_deploy_id was not found in releases: ${pairedBackendDeployId}`, + ); +} +const backendSource = pairedBackendRelease || previous; +const previousBackendImage = backendSource + ? parseField(backendSource.content, 'components.backend.image') : ''; -const previousBackendCommit = previous - ? parseField(previous.content, 'components.backend.commit') +const previousBackendCommit = backendSource + ? parseField(backendSource.content, 'components.backend.commit') : ''; -const previousBackendVersion = previous - ? parseField(previous.content, 'components.backend.version') +const previousBackendVersion = backendSource + ? parseField(backendSource.content, 'components.backend.version') : ''; const backendImage = values.get('backend_image') || previousBackendImage; const backendCommit = values.get('backend_commit') || previousBackendCommit; const backendVersion = normalizeVersion( values.get('backend_version') || previousBackendVersion, ); +const backendDeployId = pairedBackendRelease + ? parseField(pairedBackendRelease.content, 'metadata.backend_deploy_id') + : ''; +const bootstrapMode = pairedBackendRelease + ? parseField(pairedBackendRelease.content, 'metadata.bootstrap_mode') + : ''; +const backendReleaseIntent = pairedBackendRelease + ? parseField(pairedBackendRelease.content, 'metadata.release_intent') + : ''; +const backendChanged = pairedBackendRelease ? 'true' : 'false'; const rollbackFrontendImage = values.get('rollback_frontend_image') || (previous ? parseField(previous.content, 'components.frontend.image') : ''); const rollbackBackendImage = values.get('rollback_backend_image') || - (previous ? parseField(previous.content, 'components.backend.image') : ''); + (pairedBackendRelease + ? parseField( + pairedBackendRelease.content, + 'rollback.app_rollback_target.backend', + ) + : previous + ? parseField(previous.content, 'components.backend.image') + : ''); if (!backendImage || !backendCommit || !backendVersion) { throw new Error( @@ -360,17 +400,30 @@ writeOutput({ source_pr_number: intent.number, source_intent: intent.source, release_type: releaseType || 'patch', - backend_changed: 'false', + frontend_deploy_id: `frontend-${releaseId}-${shortSha}`, + backend_changed: backendChanged, + backend_deploy_id: backendDeployId, + paired_backend_deploy_id: pairedBackendDeployId, + backend_release_intent: backendReleaseIntent, + bootstrap_mode: bootstrapMode, backend_image: backendImage, backend_commit: backendCommit, backend_version: backendVersion, - db_changed: values.get('db_changed') || 'false', + db_changed: + values.get('db_changed') || + (pairedBackendRelease + ? parseField(pairedBackendRelease.content, 'database.changed') + : 'false'), db_migration_version: values.get('db_migration_version') || - (previous - ? parseField(previous.content, 'database.migration_version') - : 'none'), - db_migration_files: values.get('db_migration_files') || '', + (backendSource + ? parseField(backendSource.content, 'database.migration_version') + : 'N/A'), + db_migration_files: + values.get('db_migration_files') || + (pairedBackendRelease + ? parseField(pairedBackendRelease.content, 'database.migration_files') + : ''), rollback_frontend_image: rollbackFrontendImage, rollback_backend_image: rollbackBackendImage, db_rollback_note: diff --git a/scripts/release/validate-release-record.mjs b/scripts/release/validate-release-record.mjs index 110580acf..31e5796a6 100644 --- a/scripts/release/validate-release-record.mjs +++ b/scripts/release/validate-release-record.mjs @@ -99,12 +99,30 @@ const validateFile = (file) => { ); const backendChanged = parseScalar(content, 'components.backend.changed'); + const frontendChanged = parseScalar(content, 'components.frontend.changed'); + if ( + frontendChanged === 'true' && + !parseScalar(content, 'metadata.frontend_deploy_id') + ) { + fail(file, 'metadata.frontend_deploy_id is required when frontend changed'); + } + if (backendChanged === 'true') { const backendDeployId = parseScalar(content, 'metadata.backend_deploy_id'); const releaseIntent = parseScalar(content, 'metadata.release_intent'); const bootstrapMode = parseScalar(content, 'metadata.bootstrap_mode'); + const pairedBackendDeployId = parseScalar( + content, + 'metadata.paired_backend_deploy_id', + ); if (!backendDeployId) fail(file, 'metadata.backend_deploy_id is required when backend changed'); + if (pairedBackendDeployId && pairedBackendDeployId !== backendDeployId) { + fail( + file, + 'metadata.paired_backend_deploy_id must match metadata.backend_deploy_id', + ); + } if (!RELEASE_INTENT.test(releaseIntent)) { fail(file, 'metadata.release_intent must be patch, minor, or major'); } @@ -118,9 +136,15 @@ const validateFile = (file) => { const dbChanged = parseScalar(content, 'database.changed'); const migrationVersion = parseScalar(content, 'database.migration_version'); + if (migrationVersion === 'none') { + fail( + file, + 'database.migration_version must use N/A when there is no migration', + ); + } if ( dbChanged === 'true' && - (!migrationVersion || migrationVersion === 'none') + (!migrationVersion || migrationVersion === 'N/A') ) { fail(file, 'database.changed=true requires database.migration_version'); } @@ -149,12 +173,20 @@ for (const file of files) { const content = readFileSync(file, 'utf8'); const backendDeployId = parseScalar(content, 'metadata.backend_deploy_id'); if (!backendDeployId) continue; + const pairedBackendDeployId = parseScalar( + content, + 'metadata.paired_backend_deploy_id', + ); if (backendDeployIds.has(backendDeployId)) { - fail( - file, - `duplicate metadata.backend_deploy_id also recorded in ${backendDeployIds.get(backendDeployId)}`, - ); + const original = backendDeployIds.get(backendDeployId); + if (pairedBackendDeployId !== backendDeployId) { + fail( + file, + `duplicate metadata.backend_deploy_id also recorded in ${original}`, + ); + } + } else { + backendDeployIds.set(backendDeployId, file); } - backendDeployIds.set(backendDeployId, file); } process.stdout.write(`Validated ${files.length} release record(s).\n`);