Files
auto-stock/.claude/scripts/deploy.sh
shuim ea644a37f3 chore(harness): Claude Code 하네스 구성 — 안전 가드·규칙 검사·배포 스킬
- .claude/settings.json (공유) 신설, hooks 를 settings.local.json 에서 이동
- bash_guard (PreToolUse): 로컬 스택 기동·운영 DB alembic·_test 아닌 pytest·main 강제 push·위험 rm 차단,
  main push/deploy.sh 는 장중 경고와 함께 확인 요청, backend 커밋 전 유닛 스위트 게이트
- rules_check (PostToolUse): py_compile + CLAUDE.md 규칙(json.dumps, except 후 logger.debug,
  except-pass, 실시간 계층 DB I/O) — diff 추가 라인만
- session_context (SessionStart): 브랜치·미커밋·장 상태·alembic head 주입
- stop_check (Stop): frontend 변경 시 tsc
- auto-orchestrate: 서브에이전트 보고·시스템 알림·질문형 프롬프트 오발동 제거
- /deploy 스킬 + deploy.sh / prod_smoke.sh: 테스트→dev push→main 병합→재기동 감시→공개 API 스모크
- 팀 에이전트·orchestrate 스킬 추적 시작, settings.local.json 추적 해제(.gitignore)
- CLAUDE.md §9 하네스
- 훅 테스트 69 passed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:05:35 +09:00

137 lines
6.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# auto-stock 배포: 작업트리 확인 → 깨끗한 worktree 유닛 스위트 → push dev → 임시 worktree 에서 main 병합·푸시
# → 재기동 감시(비-200 → 200) → 공개 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}"
WATCH_URL="$BASE/api/v1/analysis/performance"
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 <noreply@anthropic.com>"
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}"; }
watch_restart() {
echo "== (e) 배포 감시: $WATCH_URL (비-200 → 200 재기동 감지, 최대 ${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 [ "$code" != "200" ]; then
[ "$seen_down" = 0 ] && echo " -> 다운 감지(재기동 중)"
seen_down=1
elif [ "$seen_down" = 1 ]; then
echo " -> 재기동 완료: 200 복귀 (${elapsed}s)"; return 0
elif [ "$first" = 1 ]; then
echo " (현재 200 — 아직 옛 컨테이너일 수 있음. 빌드/재기동 대기)"
fi
first=0
if [ "$elapsed" -ge "$WATCH_MAX_SEC" ]; then
if [ "$seen_down" = 1 ]; then echo "!! ${WATCH_MAX_SEC}s 경과 — 여전히 비-200. Jenkins 로그/컨테이너 확인 필요."; return 2; fi
echo "!! ${WATCH_MAX_SEC}s 동안 재기동 미감지(200 유지) — 빌드 지연·무중단 교체·변경 없음 가능. 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"