Skip to content

Commit ed7e758

Browse files
committed
chore(week2): photo/admin form retrofit + CI + Deploy + retro (#31)
* fix(forms): SKILL 4.5 retrofit — photo & admin members inline alert + dedupe PR #30 에 박은 폼 표준 체크리스트(SKILL.md §4.5)를 이미 있는 폼에 소급 적용. Photo upload (app/sessions/[id]/photo-actions.ts + PhotoSection.tsx): - uploadSessionPhoto / removeSessionPhoto 시그니처를 useActionState 패턴 ((prev, formData) => Promise<{ ok, error? }>) 으로 변경. - 검증 실패 (15MB 초과 / MIME 거부 / 빈 파일 / 세션 id 불량) 시 throw 대신 명시적 PhotoResult.error 반환. - PhotoSection 을 server → client component 로 (useActionState). - 업로드/교체/삭제 모두 SubmitButton (useFormStatus) — pending 중 disabled + 라벨 교체. - 사진 우측 [교체] 버튼은 부모의 useActionState formAction 을 props 로 받음 → alert/pending 상태가 한 곳에서 관리됨. Admin members (app/admin/members/actions.ts + page.tsx + _components): - approveUser / rejectUser 시그니처 동일 패턴. - 'cannot approve/reject self' 를 throw 대신 inline alert 메시지로. - 페이지 (server component) 가 PendingMemberActions (client) 를 행마다 렌더. - 두 버튼 ([승인]/[거절]) 이 같은 row 의 alert 영역 공유. 검증된 회귀 항목: - 사진 업로드 [올리기] 빠른 더블 클릭 → 첫 클릭 후 button disabled. - 본인 [승인] 시도 → '본인은 본인을 승인할 수 없습니다.' alert, 페이지 그대로. - 빌드 9 → 10 routes, /sessions/[id] +1.2kB (client form 추가분). Spec: .gjc/specs/week2-wrapup.md §1 * chore(ci): typecheck + lint + build on PR/push (#5) .github/workflows/ci.yml — PR to dev/main 과 dev/main push 에서 동작. Jobs: - Setup Node 22 + pnpm 11.5.1 (lockfile 의 packageManager 와 일치) - pnpm store 캐시 (lockfile hash 기반) - .next/cache 캐시 (ts/tsx hash 기반) - pnpm install --frozen-lockfile (lockfile drift 차단) - pnpm db:generate (postinstall 보강) - typecheck → lint → build 직렬 - build env: dummy 값 주입 (auth/db url 필요) Concurrency: 같은 ref 의 새 push 가 들어오면 이전 run 취소. Timeout: 10분. 브랜치 보호 (Settings → Branches) 설정으로 'check' 통과 강제는 호스트(나) 가 main, dev 두 곳에 별도 등록 — 코드 변경 X. * chore(deploy): GH Actions SSH deploy pipeline (#22) .github/workflows/deploy.yml + deploy/README.md secret 등록 절차. Workflow: - trigger: push to dev (→ staging), main (→ prod), 또는 workflow_dispatch - build job: Docker Buildx + GHCR (ghcr.io) push, GHA cache 사용 태그: '<target>-<sha7>' + '<target>' (latest 역할) - deploy job: appleboy/ssh-action 으로 N100 접속, docker compose pull && up -d (project name 으로 staging/prod 분리) - concurrency: 같은 ref 의 동시 배포 금지 - environment: GitHub Environments 의 'staging' / 'prod' 사용 (prod 는 수동 승인 가능) - Sanity check 단계: secret 누락 시 명시적 에러로 즉시 실패 필요 secrets (사용자 직접 등록): - N100_HOST / N100_USER / N100_SSH_KEY - GITHUB_TOKEN 은 자동 (GHCR push 권한 위해 permissions.packages: write) deploy/README.md: - '0. CI/CD 사용 시' 섹션 신설 — secret 등록 절차, SSH key 생성, prod environment 보호. - 기존 '1. 첫 배포 (수동)' 은 'CI/CD 우회/디버깅' 으로 강등 (호스트 init 1회는 여전히 필요) * docs(retro): Week 2 회고 + Home toc
1 parent ecb765d commit ed7e758

12 files changed

Lines changed: 653 additions & 99 deletions

File tree

.github/workflows/ci.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [dev, main]
6+
push:
7+
branches: [dev, main]
8+
9+
# 같은 PR 에 빠르게 여러 push 가 와도 마지막 것만 실행
10+
concurrency:
11+
group: ci-${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
check:
16+
name: typecheck + lint + build
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 10
19+
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v4
23+
24+
- name: Setup Node 22
25+
uses: actions/setup-node@v4
26+
with:
27+
node-version: "22"
28+
29+
- name: Setup pnpm
30+
uses: pnpm/action-setup@v4
31+
with:
32+
version: 11.5.1
33+
run_install: false
34+
35+
- name: Resolve pnpm store path
36+
id: pnpm-cache
37+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
38+
39+
- name: Cache pnpm store
40+
uses: actions/cache@v4
41+
with:
42+
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
43+
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
44+
restore-keys: |
45+
pnpm-${{ runner.os }}-
46+
47+
- name: Cache Next.js build
48+
uses: actions/cache@v4
49+
with:
50+
path: |
51+
${{ github.workspace }}/.next/cache
52+
key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
53+
restore-keys: |
54+
next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
55+
56+
- name: Install dependencies
57+
# frozen-lockfile 로 의존 정확성 강제 (lockfile mismatch 면 fail)
58+
run: pnpm install --frozen-lockfile
59+
60+
# postinstall 이 prisma generate 를 이미 돌리지만, hoisted layout 에서
61+
# 한 번 더 명시적으로 (CI 캐시 hit 시점 안전)
62+
- name: Prisma generate
63+
run: pnpm db:generate
64+
65+
- name: Typecheck
66+
run: pnpm typecheck
67+
68+
- name: Lint
69+
run: pnpm lint
70+
71+
- name: Build
72+
run: pnpm build
73+
env:
74+
# build 단계에 일부 env 필요 — Auth.js / DB url 은 dummy 로 충분
75+
DATABASE_URL: file:./data/ci-stub.db
76+
AUTH_URL: http://localhost:3000
77+
AUTH_SECRET: ci-stub-secret-ci-stub-secret-ci-stub-secret
78+
AUTH_TRUST_HOST: "true"
79+
AUTH_GOOGLE_ID: ci-stub
80+
AUTH_GOOGLE_SECRET: ci-stub
81+
AUTH_GOOGLE_HD: snu.ac.kr
82+
NEXT_PUBLIC_APP_URL: http://localhost:3000

.github/workflows/deploy.yml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
name: Deploy
2+
3+
on:
4+
push:
5+
branches: [dev, main]
6+
workflow_dispatch:
7+
inputs:
8+
target:
9+
description: "배포 대상 (staging | prod)"
10+
required: true
11+
default: "staging"
12+
type: choice
13+
options: [staging, prod]
14+
15+
# 동시 배포 금지. 같은 대상에 새 push 가 오면 in-progress 취소.
16+
concurrency:
17+
group: deploy-${{ github.ref_name }}
18+
cancel-in-progress: false
19+
20+
permissions:
21+
contents: read
22+
packages: write # ghcr.io push 권한
23+
24+
env:
25+
REGISTRY: ghcr.io
26+
IMAGE: ${{ github.repository }}
27+
28+
jobs:
29+
# ---------------------------------------------------------------------------
30+
# 1) build image → ghcr.io 푸시
31+
# ---------------------------------------------------------------------------
32+
build:
33+
name: Build & push image
34+
runs-on: ubuntu-latest
35+
timeout-minutes: 15
36+
outputs:
37+
image_tag: ${{ steps.meta.outputs.image_tag }}
38+
target: ${{ steps.meta.outputs.target }}
39+
40+
steps:
41+
- name: Checkout
42+
uses: actions/checkout@v4
43+
44+
- name: Resolve target + tag
45+
id: meta
46+
run: |
47+
# 우선순위: workflow_dispatch.inputs.target → branch
48+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
49+
target="${{ inputs.target }}"
50+
elif [ "${{ github.ref_name }}" = "main" ]; then
51+
target="prod"
52+
else
53+
target="staging"
54+
fi
55+
sha_short="$(echo "${{ github.sha }}" | cut -c1-7)"
56+
image_tag="${target}-${sha_short}"
57+
echo "target=$target" >> "$GITHUB_OUTPUT"
58+
echo "image_tag=$image_tag" >> "$GITHUB_OUTPUT"
59+
echo "image_ref=${REGISTRY}/${IMAGE}:${image_tag}" >> "$GITHUB_OUTPUT"
60+
echo "image_ref_target=${REGISTRY}/${IMAGE}:${target}" >> "$GITHUB_OUTPUT"
61+
62+
- name: Set up Docker Buildx
63+
uses: docker/setup-buildx-action@v3
64+
65+
- name: Login to GHCR
66+
uses: docker/login-action@v3
67+
with:
68+
registry: ${{ env.REGISTRY }}
69+
username: ${{ github.actor }}
70+
password: ${{ secrets.GITHUB_TOKEN }}
71+
72+
- name: Build & push
73+
uses: docker/build-push-action@v6
74+
with:
75+
context: .
76+
file: deploy/Dockerfile
77+
push: true
78+
tags: |
79+
${{ steps.meta.outputs.image_ref }}
80+
${{ steps.meta.outputs.image_ref_target }}
81+
cache-from: type=gha
82+
cache-to: type=gha,mode=max
83+
84+
# ---------------------------------------------------------------------------
85+
# 2) SSH 로 N100 접속 → docker compose pull && up -d
86+
# ---------------------------------------------------------------------------
87+
deploy:
88+
name: Deploy to ${{ needs.build.outputs.target }}
89+
needs: build
90+
runs-on: ubuntu-latest
91+
timeout-minutes: 10
92+
# prod 는 환경(environment) 보호로 수동 승인 가능
93+
environment:
94+
name: ${{ needs.build.outputs.target }}
95+
96+
steps:
97+
- name: Sanity check secrets
98+
run: |
99+
missing=""
100+
for v in N100_HOST N100_USER N100_SSH_KEY; do
101+
eval val="\$$v"
102+
if [ -z "$val" ]; then missing="$missing $v"; fi
103+
done
104+
if [ -n "$missing" ]; then
105+
echo "::error::Missing required secrets:$missing"
106+
echo "GitHub repo Settings → Secrets and variables → Actions 에서 등록하세요."
107+
exit 1
108+
fi
109+
env:
110+
N100_HOST: ${{ secrets.N100_HOST }}
111+
N100_USER: ${{ secrets.N100_USER }}
112+
N100_SSH_KEY: ${{ secrets.N100_SSH_KEY }}
113+
114+
- name: Pull & restart on N100
115+
uses: appleboy/ssh-action@v1.0.3
116+
with:
117+
host: ${{ secrets.N100_HOST }}
118+
username: ${{ secrets.N100_USER }}
119+
key: ${{ secrets.N100_SSH_KEY }}
120+
# 호스트의 deploy/ 디렉터리에서 pull → up.
121+
# IMAGE_TAG 가 .env 의 placeholder 를 덮어쓰며 docker compose 가 사용.
122+
script: |
123+
set -e
124+
cd ${MURUN_DEPLOY_DIR:-~/murun-peterabcd/deploy}
125+
export IMAGE_TAG="${{ needs.build.outputs.image_tag }}"
126+
docker compose -p murun-${{ needs.build.outputs.target }} pull
127+
docker compose -p murun-${{ needs.build.outputs.target }} up -d --remove-orphans
128+
docker image prune -f

.gjc/specs/week2-wrapup.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Spec · week2-wrapup
2+
3+
> 2주차 마무리 작업 한 PR. commit 단위로 분리해 review 가능하게, 머지는 한 번.
4+
5+
## Story
6+
7+
PR #30 까지 핵심 surface 가 다 살아있는 상태. 남은 작업은 1주차/2주차에 OOS 로 미뤘던 것 + 운영 인프라(CI, deploy) + 회고. 작은 작업이라 각각 PR 사이클 도는 비용 > 가치. 한 PR + 분리된 commit.
8+
9+
## Commits (순서대로)
10+
11+
### 1. fix(forms): photo & admin members inline alert + dedupe (SKILL 4.5 소급 적용)
12+
- 사진 업로드/교체/삭제 폼 + admin approve/reject 폼에 PR #29 와 같은 패턴 적용
13+
- server action 들을 `(prev, formData) => Promise<{ ok, error? }>`
14+
- client form component + `useActionState` + `SubmitButton` (`useFormStatus`)
15+
- inline `ErrorAlert` 표시
16+
- "본인 승인 불가" 같은 메시지를 throw 대신 alert 로
17+
18+
### 2. chore(ci): PR 마다 typecheck + lint + build 자동 (#5)
19+
- `.github/workflows/ci.yml`
20+
- Node 22 + pnpm 11.5.1 + prisma generate + check
21+
- PR to dev 트리거. dev/main push 시에도 한 번 더.
22+
23+
### 3. chore(deploy): GH Actions SSH deploy 파이프라인 (#22)
24+
- `.github/workflows/deploy.yml`
25+
- build job: Dockerfile 빌드 → ghcr.io 푸시 (tag `<sha>` + `staging` or `prod`)
26+
- deploy job: SSH 로 N100 접속 → `docker compose pull && up -d`
27+
- 트리거: dev push → staging 자동, main push → prod (environment approval)
28+
- 필요 secrets (사용자 등록 — README 안내 추가):
29+
- `N100_HOST`, `N100_USER`, `N100_SSH_KEY`
30+
- `GHCR_TOKEN` 또는 `GITHUB_TOKEN` (자동)
31+
32+
### 4. docs(retro): Week 2 회고 (`docs/wiki/Retrospective-Week2.md`)
33+
- 1주차 회고와 같은 결, AI 톤 X
34+
- 핵심: AI 가 스스로 못 잡은 UX 사고들 + 워크플로우 보강의 이유 + 3주차에 들고 가는 것
35+
36+
## Closes
37+
38+
- #5 CI: lint + typecheck + build on PR
39+
- #22 GH Actions SSH deploy 파이프라인
40+
41+
(사진 폼/admin 폼은 별도 issue 없음 — 직접 PR 본문에 기재)
42+
43+
## Acceptance
44+
45+
### 1. Form polish
46+
- [ ] 사진 폼: 15MB 초과/MIME 거부/빈 파일 → inline alert
47+
- [ ] 사진 업로드 중 [올리기] disabled + "올리는 중..."
48+
- [ ] 사진 [삭제]/[교체] 중 disabled
49+
- [ ] admin 승인/거절 폼: 본인 승인 시도 → inline alert (페이지 그대로)
50+
- [ ] admin 승인/거절 중 button disabled + 라벨 교체
51+
52+
### 2. CI
53+
- [ ] `.github/workflows/ci.yml` push/pr 에서 정상 동작 (시뮬레이션은 PR open 시점에)
54+
- [ ] cache: pnpm store + .next/cache
55+
- [ ] 빌드 시간 합리적 (< 5분 목표)
56+
57+
### 3. Deploy
58+
- [ ] `.github/workflows/deploy.yml` 작성, secrets 누락 시 명확한 실패
59+
- [ ] ghcr.io 이미지 태그 규칙 명확
60+
- [ ] README + deploy/README 에 secret 등록 절차 추가
61+
62+
### 4. 회고
63+
- [ ] 1주차 회고와 같은 톤. AI 생성 흔적 없음
64+
- [ ] "다르게 한다면" 명시적 섹션 (3주차 가는 습관)
65+
- [ ] 부트캠프 요구 사항 (워크플로우 문제 발견 + 동료 아이디어 자리 + 향후 가이드) 반영
66+
67+
## Out of scope (이 PR 에서도 안 함)
68+
69+
- 세션 수정/삭제 UI 페이지 (별 PR — 호스트가 오타·사진 고치는 흐름)
70+
- HEIC → jpg 변환 (Week 3 stretch)
71+
- Orphan 파일 정리 cron (Week 3)
72+
- 백업 restic cron (Week 3)
73+
- UPS / 정전 대응
74+
- Wiki sync (사용자 직접)
75+
76+
## Verification (사용자가 직접)
77+
78+
머지 후:
79+
1. `pnpm dev` → 사진 업로드 시 [올리기] 한 번 누름 후 빠르게 두 번째 → disable 확인
80+
2. 큰 파일 (15MB+) 업로드 시도 → inline alert
81+
3. `/admin/members` 에서 본인 행 [승인] 클릭 시도 → inline alert
82+
4. (CI) PR 새로 열 때 Actions 탭에서 ci.yml 동작 확인
83+
5. (Deploy) secrets 등록 후 dev 에 commit push → Actions 에서 deploy 동작 확인
84+
```
85+
gh secret set N100_HOST --body "<ip>"
86+
gh secret set N100_USER --body "<user>"
87+
gh secret set N100_SSH_KEY < ~/.ssh/n100_deploy
88+
```
89+
6. 회고 read-through, 본인 어색하지 않은지
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use client";
2+
3+
import { useActionState } from "react";
4+
5+
import { ErrorAlert } from "@/components/form/ErrorAlert";
6+
import { SubmitButton } from "@/components/form/SubmitButton";
7+
8+
import { approveUser, rejectUser } from "../actions";
9+
10+
type Props = {
11+
userId: string;
12+
};
13+
14+
/**
15+
* 승인 대기 1행에 붙는 [승인] / [거절] 버튼.
16+
* 두 server action 을 각각 useActionState 로 묶고, 에러는 행 아래 inline alert.
17+
* 같은 row 에서 두 버튼이 같은 alert 영역을 공유.
18+
*/
19+
export function PendingMemberActions({ userId }: Props) {
20+
const [approveState, approveAction] = useActionState(approveUser, null);
21+
const [rejectState, rejectAction] = useActionState(rejectUser, null);
22+
23+
const error =
24+
(approveState && !approveState.ok ? approveState.error : null) ??
25+
(rejectState && !rejectState.ok ? rejectState.error : null);
26+
27+
return (
28+
<div className="flex flex-col gap-2">
29+
<div className="flex gap-2">
30+
<form action={approveAction}>
31+
<input type="hidden" name="userId" value={userId} />
32+
<SubmitButton size="sm" idleLabel="승인" pendingLabel="승인 중..." />
33+
</form>
34+
<form action={rejectAction}>
35+
<input type="hidden" name="userId" value={userId} />
36+
<SubmitButton
37+
variant="outline"
38+
size="sm"
39+
idleLabel="거절"
40+
pendingLabel="거절 중..."
41+
/>
42+
</form>
43+
</div>
44+
<ErrorAlert message={error} />
45+
</div>
46+
);
47+
}

0 commit comments

Comments
 (0)