#!/usr/bin/env bash # auto-stock 배포: 작업트리 확인 → 깨끗한 worktree 유닛 스위트 → push dev → 임시 worktree 에서 main 병합·푸시 # → 재기동 감시(다운 → 기동) → 공개/인증 API 스모크. # 사용: # deploy.sh [-m "병합 메시지"] [--no-test] [--dry-run] # deploy.sh --watch-only # 이미 푸시된 배포의 재기동 감시 + 스모크만 # 사용자의 작업 브랜치(dev)는 바꾸지 않는다. 운영 서버/KIS 직접 호출 없음(공개 GET 만). set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" SCRIPT_DIR="$ROOT/.claude/scripts" BASE="${PROD_BASE_URL:-https://stock.shium.net}" # DB 만 읽는 가벼운 엔드포인트. 2026-10-02(H-1)부터 키 없이 401 — 200(구버전)·401(신버전) 모두 "기동됨". WATCH_URL="$BASE/api/v1/journals/stats" WATCH_MAX_SEC="${WATCH_MAX_SEC:-900}" WATCH_INTERVAL="${WATCH_INTERVAL:-15}" DEFAULT_MSG="Merge dev into main (deploy)" TRAILER="Co-Authored-By: Claude Opus 5.5 " MSG="$DEFAULT_MSG"; NO_TEST=0; WATCH_ONLY=0; DRY=0 while [ $# -gt 0 ]; do case "$1" in -m|--message) MSG="${2:?-m 에 메시지 필요}"; shift 2 ;; --no-test) NO_TEST=1; shift ;; --watch-only) WATCH_ONLY=1; shift ;; --dry-run) DRY=1; shift ;; -h|--help) sed -n '2,9p' "$0"; exit 0 ;; *) echo "알 수 없는 옵션: $1" >&2; exit 64 ;; esac done TMPDIRS=() cleanup() { for d in "${TMPDIRS[@]:-}"; do [ -n "$d" ] && git -C "$ROOT" worktree remove --force "$d" >/dev/null 2>&1 || true; done; git -C "$ROOT" worktree prune >/dev/null 2>&1 || true; } trap cleanup EXIT kst() { TZ=Asia/Seoul date "$@"; } market_warning() { local dow hm dow="$(kst +%u)"; hm="$(kst +%H%M)"; hm=$((10#$hm)) if [ "$dow" -le 5 ] && [ "$hm" -ge 830 ] && [ "$hm" -le 1540 ]; then echo "!! 장중 배포 경고 — KST $(kst '+%H:%M') (평일 08:30~15:40). 재기동이 매매에 영향(DT 포지션·스케줄러 상태·KIS 토큰 재발급)." else echo "배포 시각 KST $(kst '+%Y-%m-%d %H:%M') — 장외." fi } http_code() { local c; c="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$1" 2>/dev/null || true)"; echo "${c:-000}"; } # 앱이 요청을 처리하고 있는가: 200(공개/구버전) 또는 401(인증 필수 — 정상 거부) is_up() { [ "$1" = "200" ] || [ "$1" = "401" ]; } watch_restart() { echo "== (e) 배포 감시: $WATCH_URL (다운 → 200/401 재기동 감지, 최대 ${WATCH_MAX_SEC}s, ${WATCH_INTERVAL}s 간격) ==" local start now code seen_down=0 first=1 elapsed start="$(date +%s)" while :; do now="$(date +%s)"; elapsed=$((now - start)) code="$(http_code "$WATCH_URL")" printf '[%4ss] http=%s\n' "$elapsed" "$code" if ! is_up "$code"; then [ "$seen_down" = 0 ] && echo " -> 다운 감지(재기동 중)" seen_down=1 elif [ "$seen_down" = 1 ]; then echo " -> 재기동 완료: http=$code 복귀 (${elapsed}s)"; return 0 elif [ "$first" = 1 ]; then echo " (현재 http=$code — 아직 옛 컨테이너일 수 있음. 빌드/재기동 대기)" fi first=0 if [ "$elapsed" -ge "$WATCH_MAX_SEC" ]; then if [ "$seen_down" = 1 ]; then echo "!! ${WATCH_MAX_SEC}s 경과 — 여전히 다운(200/401 아님). Jenkins 로그/컨테이너 확인 필요."; return 2; fi echo "!! ${WATCH_MAX_SEC}s 동안 재기동 미감지(계속 기동 상태) — 빌드 지연·무중단 교체·변경 없음 가능. Jenkins 로그 확인."; return 1 fi sleep "$WATCH_INTERVAL" done } if [ "$WATCH_ONLY" = 1 ]; then market_warning rc=0; watch_restart || rc=$? echo "== (f) 스모크 =="; "$SCRIPT_DIR/prod_smoke.sh" || rc=$? exit "$rc" fi market_warning # (a) 작업트리 확인 BR="$(git rev-parse --abbrev-ref HEAD)" [ "$BR" = "dev" ] || { echo "중단: 현재 브랜치가 dev 가 아님($BR). 작업은 dev 에서만(branch-workflow-dev-only)." >&2; exit 1; } DIRTY="$(git status --porcelain | grep -v -E '^.. \.claude/settings\.local\.json$' || true)" if [ -n "$DIRTY" ]; then echo "중단: 미커밋 변경이 있다(.claude/settings.local.json 제외):" >&2; echo "$DIRTY" >&2; exit 1; fi HEAD_SHA="$(git rev-parse HEAD)" echo "== (a) 작업트리 깨끗 — dev @ ${HEAD_SHA:0:9} ==" git fetch origin main --quiet || { echo "중단: origin fetch 실패" >&2; exit 1; } if git diff --name-only origin/main..HEAD -- backend/alembic/versions | grep -q .; then echo "!! alembic 마이그레이션 포함: $(git diff --name-only origin/main..HEAD -- backend/alembic/versions | tr '\n' ' ')" echo " 배포 후 Jenkins 로그에서 'alembic upgrade head' 성공 여부를 반드시 확인(앱 기동보다 먼저 실행돼야 함)." fi # (b) 깨끗한 worktree 에서 유닛 스위트 if [ "$NO_TEST" = 1 ]; then echo "!! 경고: --no-test — 유닛 스위트 검증 없이 진행한다." else PY="$ROOT/.venv/bin/python" [ -x "$PY" ] || { echo "중단: $PY 없음(테스트 불가). 정말 건너뛰려면 --no-test." >&2; exit 1; } WT1="$(mktemp -d "${TMPDIR:-/tmp}/autostock-deploy-test.XXXXXX")"; rmdir "$WT1" git worktree add --detach "$WT1" "$HEAD_SHA" >/dev/null; TMPDIRS+=("$WT1") echo "== (b) HEAD 기준 유닛 스위트 (깨끗한 worktree) ==" ( cd "$WT1/backend" && DATABASE_URL="postgresql+asyncpg://x:x@localhost/autostock_test" \ "$PY" -m pytest tests/ --ignore=tests/integration -q -x -p no:cacheprovider ) \ || { echo "중단: 유닛 스위트 실패 — 배포하지 않음." >&2; exit 1; } fi if [ "$DRY" = 1 ]; then echo "== --dry-run: 푸시/병합 생략. 여기까지 통과 =="; exit 0; fi # (c) dev 푸시 echo "== (c) git push origin dev ==" git push origin dev # (d) 임시 worktree 에서 origin/main 에 --no-ff 병합 후 푸시 (작업 브랜치 불변) git fetch origin main --quiet WT2="$(mktemp -d "${TMPDIR:-/tmp}/autostock-deploy-merge.XXXXXX")"; rmdir "$WT2" git worktree add --detach "$WT2" origin/main >/dev/null; TMPDIRS+=("$WT2") echo "== (d) origin/main 에 dev(${HEAD_SHA:0:9}) --no-ff 병합 ==" if git -C "$WT2" merge-base --is-ancestor "$HEAD_SHA" origin/main; then echo "main 이 이미 dev 를 포함 — 병합/푸시할 것 없음. 스모크만 수행." "$SCRIPT_DIR/prod_smoke.sh"; exit $? fi if ! git -C "$WT2" merge --no-ff "$HEAD_SHA" -m "$MSG" -m "$TRAILER"; then git -C "$WT2" merge --abort >/dev/null 2>&1 || true echo "중단: 병합 충돌. main 은 건드리지 않았다. dev 에 origin/main 을 먼저 병합해 해결하라." >&2; exit 1 fi git -C "$WT2" push origin HEAD:main echo " main -> $(git -C "$WT2" rev-parse --short HEAD) 푸시 완료 (Jenkins 배포 트리거)" # (e)(f) rc=0; watch_restart || rc=$? echo "== (f) 스모크 =="; "$SCRIPT_DIR/prod_smoke.sh" || rc=$? exit "$rc"