From f9b000d404911dcb68de9794bd81222228aff909 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 01:18:32 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20CI=EC=9D=98=20=EB=B3=91=EB=A0=AC=20job?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=98=AE=EA=B2=A8=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=95=9E=EB=8B=B9=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main push 뒤 CI(약 7분) → Build Image(약 2분) → Deploy 순차라 릴리즈마다 배포가 늦었다. 빌드는 테스트 결과가 필요 없으므로 check와 나란히 돌리고, Deploy는 CI 전체 결론을 기다린다. - pr-check.yml(CI)에 image job 추가: PR은 arm64 빌드만, main push는 GHCR : 푸시. packages:write는 이 job에만 - build-image.yml 삭제, deploy.yml은 CI의 workflow_run(이 레포 main push, 결론 success = check·image 모두 성공)을 받는다 - 수동 Deploy(재배포·롤백)는 그 sha의 main push CI 성공을 gh api로 확인한 뒤에만 진행한다 - 이미지가 check와 나란히 올라가 테스트에 실패한 커밋에도 생기므로, 예전처럼 "이미지가 있으면 CI 통과"에 기댈 수 없다 - 실제 API로 확인: 성공한 main sha 2건 → 1, develop 전용·없는 sha → 0 - deploy-workflow spec을 새 구조로 갱신하고 수동 실행 게이트 반증을 추가(조건을 되돌리면 2건 실패 확인), README 2종 갱신 --- .github/workflows/build-image.yml | 73 ------------------------- .github/workflows/deploy.yml | 26 ++++++--- .github/workflows/pr-check.yml | 46 ++++++++++++++++ README.en.md | 22 ++++---- README.md | 22 ++++---- scripts/deploy-workflow.spec.ts | 86 +++++++++++++++--------------- scripts/pre-push-test-plan.spec.ts | 2 +- 7 files changed, 131 insertions(+), 146 deletions(-) delete mode 100644 .github/workflows/build-image.yml diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml deleted file mode 100644 index c6bffe9e..00000000 --- a/.github/workflows/build-image.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Build Image - -# 앱 이미지. main은 **CI(pr-check)가 같은 커밋에서 성공한 뒤** arm64 빌드 → GHCR(:만 — 가변 태그 :main은 늦게 끝난 옛 빌드가 -# 덮어쓸 수 있어 두지 않는다. 배포·롤백은 항상 sha) → Deploy. -# 이미지 빌드는 lint·테스트를 돌리지 않으므로 push 직후가 아니라 CI 성공(workflow_run)에 건다 — 두 PR이 각각 통과해도 합친 main이 -# 깨질 수 있어서(strict_required_status_checks_policy=false). PR은 빌드만(푸시 없음) — Dockerfile 게이트. -# 홈서버(맥미니, arm64)에 맞춰 arm64만 만든다. 공개 레포라 GitHub 호스트 arm64 러너를 쓴다(QEMU 없이 네이티브). -on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] - pull_request: - branches: [main, develop, develop-msa] - -permissions: - contents: read - packages: write - -# 그룹은 커밋(sha)별 — 옛 커밋의 늦은 CI 완료가 지금 main 끝의 빌드를 취소하지 않게. 순서 보호는 아래 job의 -# head_sha == github.sha(빌드)와 Deploy의 같은 검사가 맡는다(옛 커밋은 빌드도 배포도 건너뛴다) -concurrency: - group: ${{ github.event_name == 'workflow_run' && format('build-image-main-{0}', github.event.workflow_run.head_sha) || format('build-image-pr-{0}', github.event.pull_request.number) }} - cancel-in-progress: true - -jobs: - build: - # main: 이 레포 main에 대한 push의 CI가 성공했고, 그 커밋이 아직 main 끝일 때만(옛 커밋의 늦은 CI는 건너뜀) - if: >- - github.event_name == 'pull_request' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' && - github.event.workflow_run.head_repository.full_name == github.repository && - github.event.workflow_run.head_sha == github.sha) - runs-on: ubuntu-24.04-arm - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - - - name: Set up Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - - name: Login to GHCR (main만) - if: github.event_name == 'workflow_run' - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Image metadata - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ghcr.io/caquick/caquick-be - tags: | - type=raw,value=${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - - - name: Build (main이면 push) - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - platforms: linux/arm64 - push: ${{ github.event_name == 'workflow_run' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8bc08a88..3e57a682 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,11 +1,11 @@ name: Deploy -# 홈서버 자동 배포. Build Image가 main에서 성공하면 셀프호스트 러너(맥미니)가 +# 홈서버 자동 배포. main push의 CI(check·image)가 전부 성공하면 셀프호스트 러너(맥미니)가 # .env(600) 생성 → compose pull → migrate → worker → api → ready 대기 → Discord 순서로 교체한다. # 롤백 = workflow_dispatch에 이전 sha를 넣어 같은 잡을 돌린다. main 밖의 실행은 environment 규칙과 if로 막는다. on: workflow_run: - workflows: [Build Image] + workflows: [CI] types: [completed] branches: [main] workflow_dispatch: @@ -18,6 +18,7 @@ on: permissions: contents: read packages: read + actions: read concurrency: group: deploy-production @@ -25,12 +26,12 @@ concurrency: jobs: deploy: - # workflow_run은 PR 빌드(포크의 main 브랜치 포함)도 보낸다. Build Image가 GHCR에 푸시하는 유일한 경로는 - # CI 성공(workflow_run) 트리거이고 그 빌드는 이 레포 main push의 CI에서만 시작되므로, 여기서는 그 체인 이벤트만 받는다 + # workflow_run은 PR의 CI(포크의 main 브랜치 포함)도 보낸다. 이미지를 GHCR에 푸시하는 건 이 레포 main push의 CI뿐이므로 + # 그 이벤트만 받는다. 워크플로 결론이 success면 check(테스트)와 image(빌드·푸시)가 모두 성공한 것이다 if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'workflow_run' && + github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) runs-on: [self-hosted, macmini] @@ -65,10 +66,23 @@ jobs: else tag=$RUN_SHA fi - # build-image는 전체 sha(40 hex)만 푸시한다 — 짧은 sha는 존재하지 않는 태그라 pull에서 실패한다 + # CI image job은 전체 sha(40 hex)만 푸시한다 — 짧은 sha는 존재하지 않는 태그라 pull에서 실패한다 [[ "$tag" =~ ^[0-9a-f]{40}$ ]] || { echo "image_tag은 전체 커밋 sha(40 hex)만: $tag" >&2; exit 1; } echo "tag=$tag" >> "$GITHUB_OUTPUT" + # 이미지는 CI의 image job이 check와 나란히 푸시해 테스트에 실패한 커밋에도 있다. 자동 배포는 CI 전체 성공으로 걸러지지만 + # 수동 실행(재배포·롤백)은 CI를 거치지 않으므로, 그 sha의 main push CI가 성공했는지 확인한다(마이그레이션은 되돌릴 수 없다) + - name: Require CI success (수동 실행) + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + set -euo pipefail + n=$(gh api "repos/$REPO/actions/workflows/pr-check.yml/runs?head_sha=$TAG&event=push&branch=main&status=success" --jq .total_count) + [ "$n" -gt 0 ] || { echo "$TAG: main push CI 성공 기록이 없다 — 배포하지 않는다" >&2; exit 1; } + # 빌드 완료 순서는 커밋 순서와 다를 수 있다 — 지금 main 끝이 아닌 커밋의 빌드는 배포하지 않는다(수동 실행은 그대로) - name: Skip if not the current main head if: github.event_name == 'workflow_run' diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 2622e783..7c2f0be1 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -87,6 +87,52 @@ jobs: yarn build test -f dist/main.js + # arm64 앱 이미지(홈서버 맥미니). check와 나란히 돌려 배포까지 걸리는 시간을 줄인다 — Deploy는 이 워크플로(CI) 전체가 + # 성공해야 시작하므로, check가 실패한 커밋은 이미지가 올라가도 배포되지 않는다. + # PR은 빌드만(Dockerfile 게이트), main push만 GHCR에 :로 푸시한다. 가변 태그(:main)는 늦게 끝난 옛 빌드가 덮어쓸 수 있어 + # 두지 않는다 — 배포·롤백은 항상 sha. 공개 레포라 셀프호스트가 아닌 GitHub 호스트 arm64 러너(QEMU 없이 네이티브)를 쓴다. + image: + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Login to GHCR (main만) + if: github.event_name == 'push' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/caquick/caquick-be + tags: | + type=raw,value=${{ github.sha }} + + - name: Build (main이면 push) + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/arm64 + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + coverage-report: if: github.event_name == 'pull_request' runs-on: ubuntu-latest diff --git a/README.en.md b/README.en.md index c61c8302..3610968e 100644 --- a/README.en.md +++ b/README.en.md @@ -264,7 +264,7 @@ caquick-be/ ├── terraform/ # GitHub repo/branch protection + AWS (S3, IAM) as code ├── scripts/ # gate and ops scripts (dto:check · docs:check · outbox:requeue) + infra specs ├── docs/guide/ # architecture conventions (source of truth) -└── .github/workflows/ # pr-check · build-image · deploy · codeql · knip · nestjs-doctor · discord-notify +└── .github/workflows/ # pr-check · deploy · codeql · knip · nestjs-doctor · discord-notify ``` ## 🚀 Getting Started @@ -405,14 +405,14 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ ### Workflows -| Workflow | Trigger | Role | -| -------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch gates, dto:check (warning only; the hard gate is the pre-push hook), infrastructure specs, integration tests, coverage, and two consecutive builds (cache regression check) | -| `codeql.yml` | PR · push · weekly | CodeQL static security analysis | -| `knip.yml` · `nestjs-doctor.yml` | PR | comments with unused-code and NestJS health reports (advisory) | -| `build-image.yml` | PR (build only) · main **after CI succeeds** (push) | builds an arm64 image and pushes `ghcr.io/caquick/caquick-be:` (no mutable tags) | -| `deploy.yml` | `build-image` success (main) · manual (rollback sha) | the self-hosted runner (Mac mini) writes `.env` and `app.env` (mode 600) from secrets, then pull → migrate → worker → api → readiness wait → observability, and notifies Discord | -| `discord-notify.yml` | PR · push · issue | Discord notifications | +| Workflow | Trigger | Role | +| -------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch gates, dto:check (warning only; the hard gate is the pre-push hook), infrastructure specs, integration tests, coverage, and two consecutive builds (cache regression check) | +| `codeql.yml` | PR · push · weekly | CodeQL static security analysis | +| `knip.yml` · `nestjs-doctor.yml` | PR | comments with unused-code and NestJS health reports (advisory) | +| `pr-check.yml` `image` job | PR (build only) · main push (push) | builds an arm64 image alongside the tests and pushes `ghcr.io/caquick/caquick-be:` (no mutable tags) | +| `deploy.yml` | main **CI fully succeeds** · manual (rollback sha, CI-passed only) | the self-hosted runner (Mac mini) writes `.env` and `app.env` (mode 600) from secrets, then pull → migrate → worker → api → readiness wait → observability, and notifies Discord | +| `discord-notify.yml` | PR · push · issue | Discord notifications | ### Flow @@ -424,13 +424,13 @@ flowchart LR Develop[🌿 develop] Release[🔀 Release PR
develop → main] Main[🌲 main] - Image[📦 build-image → GHCR :sha] + Image[📦 image job → GHCR :sha] Deploy[🚀 deploy
self-hosted macmini] Dev --> PR --> Checks Checks -->|✅ pass| Develop Develop --> Release --> Main - Main -->|CI ✅| Image --> Deploy + Main --> Image -->|CI all ✅| Deploy ``` ### Branch Protection diff --git a/README.md b/README.md index 83f179f3..19b9839d 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ caquick-be/ ├── terraform/ # GitHub 레포/브랜치 보호 + AWS(S3 · IAM) IaC ├── scripts/ # 게이트·운영 스크립트(dto:check · docs:check · outbox:requeue) + 인프라 spec ├── docs/guide/ # 아키텍처 컨벤션(정본) -└── .github/workflows/ # pr-check · build-image · deploy · codeql · knip · nestjs-doctor · discord-notify +└── .github/workflows/ # pr-check · deploy · codeql · knip · nestjs-doctor · discord-notify ``` ## 🚀 시작하기 @@ -405,14 +405,14 @@ docker compose --profile edge up -d # cloudflared (TUNNEL_ ### 워크플로우 -| Workflow | Trigger | 역할 | -| -------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch 게이트, dto:check(경고만 — 하드 게이트는 pre-push), 인프라 spec, 전체 테스트, 커버리지, 빌드 2회(캐시 회귀 검출)를 실행합니다 | -| `codeql.yml` | PR · push · 주간 | CodeQL 정적 보안 분석을 실행합니다 | -| `knip.yml` · `nestjs-doctor.yml` | PR | 사용하지 않는 코드와 NestJS 점검 결과를 코멘트로 남깁니다(advisory) | -| `build-image.yml` | PR(빌드만) · main **CI 성공 뒤**(GHCR 푸시) | arm64 이미지를 빌드해 `ghcr.io/caquick/caquick-be:`로 푸시합니다(가변 태그는 두지 않습니다) | -| `deploy.yml` | `build-image` 성공(main) · 수동(롤백 sha) | 셀프호스트 러너(맥미니)가 secrets로 `.env`·`app.env`(600)를 만들고 pull → migrate → worker → api → ready 대기 → 관측 순서로 배포한 뒤 Discord에 알립니다 | -| `discord-notify.yml` | PR · push · issue | Discord에 알림을 보냅니다 | +| Workflow | Trigger | 역할 | +| -------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pr-check.yml` | PR · push (main/develop) | codegen, tsc, lint, docs/arch 게이트, dto:check(경고만 — 하드 게이트는 pre-push), 인프라 spec, 전체 테스트, 커버리지, 빌드 2회(캐시 회귀 검출)를 실행합니다 | +| `codeql.yml` | PR · push · 주간 | CodeQL 정적 보안 분석을 실행합니다 | +| `knip.yml` · `nestjs-doctor.yml` | PR | 사용하지 않는 코드와 NestJS 점검 결과를 코멘트로 남깁니다(advisory) | +| `pr-check.yml` `image` job | PR(빌드만) · main push(GHCR 푸시) | 테스트와 나란히 arm64 이미지를 빌드해 `ghcr.io/caquick/caquick-be:`로 푸시합니다(가변 태그는 두지 않습니다) | +| `deploy.yml` | main **CI 전체 성공** · 수동(롤백 sha, CI 성공분만) | 셀프호스트 러너(맥미니)가 secrets로 `.env`·`app.env`(600)를 만들고 pull → migrate → worker → api → ready 대기 → 관측 순서로 배포한 뒤 Discord에 알립니다 | +| `discord-notify.yml` | PR · push · issue | Discord에 알림을 보냅니다 | ### 흐름 @@ -424,13 +424,13 @@ flowchart LR Develop[🌿 develop] Release[🔀 Release PR
develop → main] Main[🌲 main] - Image[📦 build-image → GHCR :sha] + Image[📦 image job → GHCR :sha] Deploy[🚀 deploy
self-hosted macmini] Dev --> PR --> Checks Checks -->|✅ pass| Develop Develop --> Release --> Main - Main -->|CI ✅| Image --> Deploy + Main --> Image -->|CI 전체 ✅| Deploy ``` ### 브랜치 보호 diff --git a/scripts/deploy-workflow.spec.ts b/scripts/deploy-workflow.spec.ts index ef84fa4d..9d82e625 100644 --- a/scripts/deploy-workflow.spec.ts +++ b/scripts/deploy-workflow.spec.ts @@ -43,61 +43,44 @@ function usesOf(wf: Workflow): string[] { .filter((u): u is string => typeof u === 'string'); } -describe('build-image.yml', () => { - const wf = workflow('.github/workflows/build-image.yml'); - const build = wf.jobs.build; +describe('pr-check.yml image job', () => { + const wf = workflow('.github/workflows/pr-check.yml'); + const image = wf.jobs.image; - it('main은 CI(pr-check) 성공(workflow_run, 같은 sha, 이 레포 push)에서만 GHCR에 푸시하고 PR(main·develop·develop-msa)은 arm64 빌드만 한다', () => { - expect(wf.on.push).toBeUndefined(); - expect(wf.on.workflow_run?.workflows).toEqual(['CI']); - expect(wf.on.workflow_run?.branches).toEqual(['main']); + it('check와 나란히 돌고, PR은 arm64 빌드만·main push만 GHCR에 sha 태그로 푸시한다', () => { + expect(wf.on.push?.branches).toContain('main'); expect(wf.on.pull_request?.branches).toEqual( - expect.arrayContaining(['main', 'develop-msa']), + expect.arrayContaining(['main', 'develop', 'develop-msa']), ); - // 이미지 빌드는 lint·테스트를 돌리지 않는다 — CI가 같은 커밋에서 성공한 뒤에만 - expect(build.if).toContain("conclusion == 'success'"); - expect(build.if).toContain("workflow_run.event == 'push'"); - expect(build.if).toContain( - 'head_repository.full_name == github.repository', + // check를 기다리지 않는다 — 배포 게이트는 Deploy가 CI 전체의 결론으로 건다 + expect(image).not.toHaveProperty('needs'); + expect(image.if).toBe( + "github.event_name == 'pull_request' || github.ref == 'refs/heads/main'", ); - expect(build.if).toContain('head_sha == github.sha'); - const push = build.steps.find((s) => + const push = image.steps.find((s) => s.uses?.startsWith('docker/build-push-action'), ); - expect(push?.with?.push).toBe("${{ github.event_name == 'workflow_run' }}"); + expect(push?.with?.push).toBe("${{ github.event_name == 'push' }}"); expect(push?.with?.platforms).toBe('linux/arm64'); - const meta = build.steps.find((s) => + const meta = image.steps.find((s) => s.uses?.startsWith('docker/metadata-action'), ); - expect(String(meta?.with?.tags)).toContain('workflow_run.head_sha'); + expect(String(meta?.with?.tags)).toContain('value=${{ github.sha }}'); // 가변 태그(main)는 늦게 끝난 옛 빌드가 덮어쓸 수 있다 — sha 태그만 expect(String(meta?.with?.tags)).not.toContain('value=main'); - const login = build.steps.find((s) => + const login = image.steps.find((s) => s.uses?.startsWith('docker/login-action'), ); - expect(login?.if).toBe("github.event_name == 'workflow_run'"); - const checkout = build.steps.find((s) => - s.uses?.startsWith('actions/checkout'), - ); - expect(String(checkout?.with?.ref)).toContain('workflow_run.head_sha'); - }); - - it('반증: main 빌드의 concurrency 그룹은 sha별 — 옛 커밋의 늦은 CI 완료가 지금 main 끝의 빌드를 취소하지 않는다(순서는 head_sha == github.sha 검사가 맡는다)', () => { - expect(String(wf.concurrency?.group)).toContain( - "format('build-image-main-{0}', github.event.workflow_run.head_sha)", - ); - expect(wf.concurrency?.['cancel-in-progress']).toBe(true); - expect(build.if).toContain('head_sha == github.sha'); + expect(login?.if).toBe("github.event_name == 'push'"); }); it('반증: 셀프호스트 러너를 쓰지 않는다 — 공개 레포의 PR 코드가 홈서버에서 돌면 안 된다', () => { - expect(JSON.stringify(build['runs-on'])).not.toContain('self-hosted'); + for (const job of Object.values(wf.jobs)) + expect(JSON.stringify(job['runs-on'])).not.toContain('self-hosted'); }); it('액션은 커밋 SHA로 고정', () => { - const uses = usesOf(wf); - expect(uses.length).toBeGreaterThan(0); - for (const u of uses) expect(u).toMatch(PINNED); + for (const u of usesOf(wf)) expect(u).toMatch(PINNED); }); }); @@ -105,19 +88,19 @@ describe('deploy.yml', () => { const wf = workflow('.github/workflows/deploy.yml'); const job = wf.jobs.deploy; - it('Build Image 성공(main)·수동 실행만 받고, production Environment + 셀프호스트 macmini 러너에서 돈다', () => { - expect(wf.on.workflow_run?.workflows).toEqual(['Build Image']); + it('main push의 CI 성공·수동 실행만 받고, production Environment + 셀프호스트 macmini 러너에서 돈다', () => { + expect(wf.on.workflow_run?.workflows).toEqual(['CI']); expect(wf.on.workflow_run?.branches).toEqual(['main']); expect(wf.on.workflow_dispatch).toBeDefined(); expect(wf.on.push).toBeUndefined(); expect(wf.on.pull_request).toBeUndefined(); expect(job.environment).toBe('production'); expect(job['runs-on']).toEqual(['self-hosted', 'macmini']); - // workflow_run은 실패한 빌드·다른 브랜치에서도 온다 — if로 한 번 더 거른다 + // workflow_run은 실패한 CI·다른 브랜치에서도 온다 — if로 한 번 더 거른다 expect(job.if).toContain("conclusion == 'success'"); expect(job.if).toContain("head_branch == 'main'"); - // 포크 PR의 'main' 브랜치 빌드도 workflow_run으로 온다 — CI 체인(workflow_run) 이벤트 + 이 레포의 빌드만 - expect(job.if).toContain("workflow_run.event == 'workflow_run'"); + // 포크 PR의 'main' 브랜치 CI도 workflow_run으로 온다 — 이 레포의 push CI만 + expect(job.if).toContain("workflow_run.event == 'push'"); expect(job.if).toContain('head_repository.full_name == github.repository'); }); @@ -177,7 +160,7 @@ describe('deploy.yml', () => { } const tag = job.steps.find((s) => s.id === 'tag'); expect(tag?.env?.INPUT_TAG).toBe('${{ inputs.image_tag }}'); - // build-image가 푸시하는 태그(전체 sha·main)만 — 짧은 sha·hex 아닌 접미사는 거절 + // CI image job이 푸시하는 태그(전체 sha)만 — 짧은 sha·hex 아닌 접미사는 거절 expect(tag?.run).toContain('^[0-9a-f]{40}$'); expect(tag?.run).toContain('tag=$MAIN_SHA'); expect(tag?.env?.MAIN_SHA).toBe('${{ github.sha }}'); @@ -208,6 +191,21 @@ describe('deploy.yml', () => { expect(config?.run).not.toContain('.docker/config.json'); }); + it('반증: 수동 실행은 그 sha의 main push CI가 성공했어야 배포한다 — 이미지는 check와 나란히 푸시돼 실패한 커밋에도 있다', () => { + const idx = (prefix: string) => + job.steps.findIndex((s) => s.name?.startsWith(prefix)); + const gate = job.steps[idx('Require CI success')]; + expect(gate?.if).toBe("github.event_name == 'workflow_dispatch'"); + expect(gate?.env?.TAG).toBe('${{ steps.tag.outputs.tag }}'); + expect(gate?.run).toContain( + 'workflows/pr-check.yml/runs?head_sha=$TAG&event=push&branch=main&status=success', + ); + expect(gate?.run).toContain('exit 1'); + // pull·migrate(deploy.sh) 전에 막는다 + expect(idx('Require CI success')).toBeGreaterThan(idx('Resolve image tag')); + expect(idx('Require CI success')).toBeLessThan(idx('Deploy')); + }); + it('반증: workflow_run의 head_sha가 지금 main 끝이 아니면 배포 단계를 전부 건너뛴다', () => { const skip = job.steps.find((s) => s.name?.startsWith('Skip if not')); expect(skip?.if).toBe("github.event_name == 'workflow_run'"); @@ -228,8 +226,8 @@ describe('deploy.yml', () => { }); }); -describe('build-image.yml run 블록', () => { - const wf = workflow('.github/workflows/build-image.yml'); +describe('pr-check.yml run 블록', () => { + const wf = workflow('.github/workflows/pr-check.yml'); it('반증: run 블록에 식(${{ }})을 직접 넣지 않는다', () => { for (const job of Object.values(wf.jobs)) { for (const step of job.steps) expect(step.run ?? '').not.toContain('${{'); diff --git a/scripts/pre-push-test-plan.spec.ts b/scripts/pre-push-test-plan.spec.ts index f842cd56..31e80c98 100644 --- a/scripts/pre-push-test-plan.spec.ts +++ b/scripts/pre-push-test-plan.spec.ts @@ -306,7 +306,7 @@ describe('scriptTestTrigger', () => { 'infra/rabbitmq/20-prometheus.conf', 'infra/deploy.sh', '.github/workflows/deploy.yml', - '.github/workflows/build-image.yml', + '.github/workflows/pr-check.yml', '.husky/pre-push', 'docker/mysql/init.sql', 'Dockerfile',