Operations
Git + 릴리즈 원칙
Git, commit, push gate, and release rules for Codaro changes.
Git 및 릴리즈 원칙
CLAUDE.md의 게이트가 SSOT다. 이 문서는 그 게이트를 풀어 쓴 운영 절차다.
AI 흔적 금지
- 커밋, 주석, 문서 어디에도 AI 생성 흔적 문구를 남기지 않는다.
- 커밋 메시지의 제목, 본문, trailer 어디에도
AI,GPT,OpenAI,ChatGPT,Codex,Claude,Anthropic,Generated by,Co-Authored-By,Assisted-by같은 도구명/생성 흔적/기여자 흔적을 남기지 않는다. Co-Authored-By,Assisted-by,Generated by류의 contributor trailer는 사람/도구 여부와 무관하게 이 저장소 커밋 메시지에서 사용하지 않는다..githooks/commit-msg가 커밋 메시지의 AI 도구명, generated-by 문구, contributor trailer를 차단한다.
문서 표기 금지 문자
- 문서(
*.md)와 커리큘럼(curricula/**)에 em dash 문자(U+2014)를 쓰지 않는다. 하이픈(-), 쉼표, 문장 재구성으로 대체한다. .githooks/pre-commit이 스테이징된 해당 파일에서 이 문자를 발견하면 커밋을 차단한다. 규칙 SSOT는CLAUDE.md절대 게이트다.
브랜치
- 로컬 브랜치 생성 금지. 모든 작업은 로컬
main에서 직접 수행하고, 새 로컬 브랜치 생성/전환/비-mainpush를 하지 않는다. - 저장소 Git hook 경로는
.githooks이며,reference-transaction과pre-push가refs/heads/main외 로컬 브랜치 ref 생성/갱신/푸시를 차단한다. (태그 ref는 허용한다.) git switch -c,git checkout -b,git branch <name>,git push origin HEAD:<branch>,gh pr checkout모두 금지.- 외부 contributor PR은 검토/코멘트/머지할 수 있다. 단 로컬 브랜치를 만들지 않는 GitHub 도구,
gh pr view/diff, detached/FETCH_HEAD 방식만 사용한다. - 직접 원격 반영은
main -> origin/main만. 기본 원격은https://github.com/eddmpython/codaro.git. GitHub 원격 작업은gh사용을 허용한다. isolation: "worktree"는 사용하지 않는다.
스테이징 규율 - 자기 변경만
작업 도중 떠 있던 다른 변경이 본 커밋에 끼면 이력이 한 의도로 안 읽히고 무관 회귀가 숨는다. 그래서 매 커밋:
- 세션 시작 스냅샷 - 작업 직전
git status --short. 이미 떠 있던 staged/unstaged/untracked는 본 세션 작업 아님으로 분류한다. - 본인 변경만 명시 add -
git add <만진 파일>로 경로를 직접 적는다.git add -A·git add .금지. 디렉터리 단위 add는 본 세션이 그 디렉터리 전체를 작성/이동한 경우만. - staging 격리 점검 -
git commit직전git diff --cached --name-only가 본인 add 목록과 정확히 일치하는지 확인. 다르면git restore --staged <파일>로 분리. - commit 후 사후 점검 -
git show --stat HEAD로 실제 들어간 파일 확인. pre-commit hook이 무관 파일을 자동 re-stage했으면 push 전git reset --soft HEAD~1+git restore --staged로 되돌리고 재커밋.
- 한 커밋 = 한 의도. 기능 추가 + 무관 리팩토링 + 자동 생성물을 섞지 않는다. 자동 생성물(landing
generated/등)은 별도 "정리: 생성물 동기화" 커밋으로 분리한다. - 새 기능·버그픽스에는 그 동작을 잠그는 테스트가 같은 커밋 묶음에 동행한다. 의도적 미테스트면 메시지에 사유를 남긴다.
커밋 메시지
- 커밋 하나에 적는 제목과 본문 전체가 commit message다. 여러 commit message가 이력으로 쌓여
git log에서 보이는 전체 기록을 commit log 또는 커밋 이력이라고 한다. 이 저장소에서는 "커밋 로그를 풍부하게 남긴다"는 말을 각 commit message의 본문을 사실 중심으로 충실히 작성한다는 뜻으로 사용한다. - 반드시 한글로 작성한다. 제목은
변경 성격: 구체적인 결과형태의 한 줄로 쓰고, 변경 성격과 실제로 달라진 동작을 함께 담는다.수정,정리,업데이트처럼 대상과 결과를 알 수 없는 제목은 금지한다. - 제목 다음에 빈 줄을 두고 아래 네 항목을 모두 작성한다. 각 항목은 최소 한 문장이고, 목록을 추가해도 된다.
상황:
변경 전 상태, 재현 조건, 사용자 요구, 또는 이 커밋이 필요해진 직접 원인을 적는다.
변경:
실제로 수정한 파일, 핵심 심볼·계약, 데이터 흐름, 전후 동작을 적는다.
영향:
사용자가 체감하는 변화, 호환성·마이그레이션 여부, 의도적으로 건드리지 않은 범위, 남은 위험을 적는다.
검증:
실행한 정확한 명령이나 수동 시나리오와 통과·실패 결과를 적는다. 실행하지 않았으면 미실행 사실과 이유를 적는다.
- diff, 테스트 출력, 재현 결과로 확인되는 구체적인 사실만 쓴다. 설계 의도와 실제 완료를 구분하고, 측정하지 않은 성능 향상이나 확인하지 않은 회귀 부재를 단정하지 않는다.
- 코드 나열을 커밋 본문으로 대체하지 않는다. 중요한 것은 "어느 파일을 만졌다"만이 아니라 "그 변경 때문에 어떤 조건에서 무슨 동작이 달라지는가"다.
- 검증 명령은 가능한 한 옵션과 gate 이름까지 그대로 남기고 결과 수치가 있으면 함께 기록한다. 일부만 검증했으면 범위를 명시한다.
- 의도적 미테스트, 후속 작업, 알려진 제한이 있으면 숨기지 않고
영향:또는검증:에 남긴다. 미실행을 "문서 변경이라 불필요"처럼 사유와 함께 적는 것은 허용하지만, 실행하지 않은 검증을 통과했다고 쓰는 것은 금지한다. - 주체 중립 또는 사용자 주어로 쓴다. 1인칭 자기참조, 작업 과정의 감상, 도구명, 기여자 trailer는 쓰지 않는다.
- merge commit, 자동 생성된 revert commit처럼 Git이 형식을 소유하는 메시지는 네 항목 강제에서 제외한다. 일반 기능·수정·문서·테스트·리팩터링 커밋에는 예외가 없다.
.githooks/commit-msg가 제목 전용 메시지, 필수 항목 누락, AI 흔적 문구를 차단한다. 훅 통과가 내용의 진실성을 보장하지 않으므로 커밋 직전 staged diff와 검증 로그를 다시 대조한다.
예시:
수정: 학습 셀 선택 시 목차 이동과 편집기 활성화를 분리
상황:
학습 셀 내부를 선택해도 URL section 변경 효과가 다시 같은 셀로 스크롤해 편집 흐름이 끊겼다.
변경:
editor/src/App.tsx에 직접 선택 경로와 목차 이동 경로를 분리했다. 직접 선택은 section route를 replace하되 다음 route focus를 한 번 억제하고, 목차 선택은 기존 focus 동작을 유지한다.
영향:
셀 클릭은 현재 스크롤 위치를 유지하고 목차 클릭은 대상 섹션으로 이동한다. route 형식과 저장된 sectionId 계약은 바꾸지 않았다.
검증:
uv run python -X utf8 tests/run.py preflight 통과. 학습 화면에서 셀 클릭과 목차 클릭을 각각 수동 확인했다.
Push 게이트 (위에서 아래로 첫 매치)
- force-push / tag-force / rewrite-push - 사용자가 "force 해" · "tag 옮겨" · "rewrite push" 같은 단어로 직접 명시한 경우만. 자체 판단 force 금지. main 직접 force push 요청 시 위험을 먼저 경고한 뒤
--force-with-lease로 진행. - push 거부/보류 트리거 - 발화에 "푸시 하지마" · "push 보류" · "올리지마" · "로컬에만 두자"가 있으면 사이클 끝에도 push 안 함. "커밋 N개 정리 완료, push 보류 중" 한 줄 보고.
- 그 외 일반 작업 (bugfix · feature · refactor · loop · 위임 task) - 사이클 끝마다 자동 push. 사이클 =
tests/run.py preflight(또는 목표 지정 gate) 통과 + 본 세션 변경 commit 완료 시점. "사이클 N push 완료" 한 줄 보고. 커밋마다 자동 push 금지.
과거 "push는 사용자 명시 지시 때만" default는 폐기되었다.
릴리즈 발동 게이트 - 두 관문 (AND)
릴리즈(버전 bump + 태그 + GitHub Release 게시)는 push와 다른 무거운 작업이다. push는 Push 게이트대로 사이클마다 자동이지만, 릴리즈는 두 관문을 모두 통과할 때만 발동한다. 마구잡이 릴리즈 금지 - 마구잡이 릴리즈란 (a) 사용자 요청 없이 내는 릴리즈, (b) 더러운 상태에서 내는 릴리즈 둘 다를 말한다.
- 관문 1 - 사용자 요청. 사용자가 "어느 정도 안정화됐다"고 판단하고 릴리즈를 명시적으로 요청해야 한다. 이 요청 없이는 어떤 경우에도 릴리즈하지 않는다. preflight가 green이고 커밋이 깔끔해도 자동으로 릴리즈하지 않는다 - 그건 push 대상이지 릴리즈 대상이 아니다. (릴리즈를 먼저 제안·재촉하지 않는다.)
- 관문 2 - 청결 판정(Claude). 사용자 요청이 들어오면, Claude가 아래 깨끗함/더러움 기준을 전수 점검한다. 전부 깨끗하면 추가 질문 없이 자동으로 릴리즈 절차를 끝까지 실행한다(아래 릴리즈 절차). 하나라도 더러우면 릴리즈를 멈추고, 무엇이 어떻게 더러운지 항목별로 보고한 뒤 정공법으로 청소하고(우회·임시 hack 금지), 청소 후 다시 청결 판정을 한다. 청소할 수 없는 더러움이 남으면 릴리즈하지 않고 사용자에게 그 사실을 보고한다.
즉 릴리즈 = 사용자 요청 AND 청결. 사용자 요청이 청결 판정을 면제하지 않는다 - 요청이 있어도 더러우면 청소가 먼저다.
깨끗함/더러움 기준
아래 10개를 모두 충족해야 "깨끗함"이다. 하나라도 위반하면 "더러움"이고, 그 자체로 릴리즈 중단 사유다.
- 게이트 green -
uv run python -X utf8 tests/run.py preflight의 전 게이트 통과 + 릴리즈 표면 게이트(launcher-test,install-launcher-smoke등). 빨간 게이트·스킵된 게이트는 곧 더러움이다. 1-1. 원격 CI 전부 green - 릴리즈 커밋 기준으로 GitHub Actions의 push 트리거 워크플로(CI,Deploy Pages,Security) 전 job이 green이어야 한다. 릴리즈 워크플로만 green이고 main CI가 red인 상태의 릴리즈는 더러움이다(v0.0.13에서 실제 위반 발생). CI 실패가 릴리즈 범위와 무관한 기존 red라도, 먼저 그 red를 정공법으로 청소한 뒤 릴리즈한다. - 빌드 산출물 동기화 - 소스를 바꿨으면 에디터
src/codaro/webBuild/**와 landinggenerated/**가 현재 소스로 재빌드된 상태여야 한다. 소스는 바뀌었는데 산출물은 옛것인 stale 빌드는 더러움이다. - 작업 트리·이력 정합 -
git status클린(미커밋 변경 0),origin/main과 동기화,root-clean게이트 통과. 루트/작업 트리에 임시·스크래치·로그·preview 산출물 없음. 켜져 있지만 미완성인(half-done) 코드 경로가 릴리즈 범위에 없음. - 임시·디버그 잔재 0 - 이번 릴리즈에 들어가는 변경에
TODO/FIXME/XXX, 디버그print/console.log, 주석 처리된 죽은 코드, 임시 hack·우회·fallback 잔재가 없다. - 버전·CHANGELOG·락 정합 - 제품 릴리즈 버전은 **무조건
0.0.x**다.pyproject.tomlversion == 태그(v0.0.x) == (런처를 건드렸으면)launcher/codaro-launcher/Cargo.toml, 그리고uv.lock·launcher/Cargo.lock이 그 버전으로 동기화됨. CHANGELOG에 그 버전 섹션이 실제 파일 단위 변경을 담아 작성됨(문구 나열·placeholder 금지). 다운로드 자산명 계약(다운로드 링크 계약) 유지.0.1.0,0.2.0,1.0.0처럼 minor/major를 올린 버전은 그 자체로 더러움이다. - AI 흔적 0 - 커밋·주석·문서·코드 본문 어디에도 금지 문구(
AI/GPT/Claude/Generated by/Co-Authored-By등) 없음. - 핵심 경로 무회귀 - 직전 릴리즈 대비 사용자 핵심 경로(런처 더블클릭 → 네이티브 창 → 백엔드 프로비저닝 → 에디터/커리큘럼 로드)가 깨지지 않음. 알려진 깨진 기능을 릴리즈에 싣지 않는다.
- 이번 변경 도입 버그 0 - 이번 릴리즈 범위의 변경이 새로 도입한 알려진 크래시·버그가 미해결로 남아있지 않다.
- 발행 아티팩트 cross-env 수신 검증 - 게시(또는 draft →
--draft=false승격) 직후, 릴리즈의release-manifest.json이 가리키는 모든 자산을 빌드 환경이 아닌 별도 환경에서 실제로 끝까지 받아 (a) HTTP 200, (b) 실측 바이트 == Content-Length, (c) 실측 sha256 == manifest sha256, (d) ranged GET 206 지원을 확인했다. 검증은uv run python -X utf8 docs/skills/ops/tools/verifyPublishedRelease.py <manifest-url> --strict(또는published-release-smoke게이트 /release-smoke워크플로)로 수행한다. 200만 확인하고 본문 수신·무결성·타임아웃 내성을 안 본 검증은 더러움이다 - "내 로컬은 되는데 사용자 환경은 터진다"는 정확히 이 빈틈에서 난다.
판정 결과는 한 줄로 보고한다 - 깨끗하면 "청결 판정 9/9 통과 → 릴리즈 진행", 더러우면 "릴리즈 중단: {위반 항목} 더러움 → 청소 후 재판정".
릴리즈 절차
릴리즈는 항상 규칙을 점검하고, 릴리즈마다 릴리즈 메시지(CHANGELOG 섹션)를 작성한다. GitHub Releases가 제품 launcher의 control plane이고, PyPI는 같은 버전의 Python developer install 채널이다. 둘 다 릴리즈 시점의 같은 pyproject.toml version에서 나온다.
- 버전 결정 - 제품 릴리즈 버전은 무조건
0.0.x라인이다. 릴리즈 때는 끝자리x만 1씩 올린다.0.1.0,0.2.0,1.0.0처럼 minor/major를 올리는 버전은 금지한다.pyproject.toml의version이 릴리즈 버전이며(uv.lock동기화), 런처는launcher/codaro-launcher/Cargo.toml의version이다. 릴리즈 워크플로우는 태그가v{pyproject.version}과 일치하는지 검증하므로 태그도 반드시v0.0.x여야 한다. - CHANGELOG.md 섹션 작성 - Keep a Changelog 형식.
## {version} - {date}헤딩 + 한 줄 요약 +### Added/Changed/Fixed/Removed+### Verification(실행한 게이트/검증). 파일 단위로 무엇을 바꿨는지 구체적으로 적는다. 문구 나열이 아니라 실제 변경을 적는다. - 게이트 통과 -
tests/run.py preflight+ 릴리즈 표면 게이트(install-launcher-smoke,launcher-test등) 통과. - 태그 + 푸시 -
git tag vX.Y.Z && git push origin vX.Y.Z. 이 태그가.github/workflows/product-release.yml을 트리거하고, GitHub Release가 published 되면.github/workflows/publish.yaml이 PyPI Trusted Publisher 업로드를 수행한다. - 릴리즈 본문 = CHANGELOG 섹션 - 워크플로우가
docs/skills/ops/tools/extractChangelogSection.py --version {pyproject.version}로 해당 섹션을 뽑아 GitHub Release 본문으로 쓴다. CHANGELOG에 그 버전 섹션이 없으면 추출이 비-0으로 실패해 릴리즈가 막힌다 - "릴리즈 메시지 항상 작성"을 기계적으로 강제한다. GitHub 자동 생성 릴리즈 노트(generate_release_notes)는 켜지 않는다. 자동 "Full Changelog" 비교 링크가 잘못된 태그(v0.2.x등)를 이전 릴리즈로 잡을 수 있으므로, 비교 링크를 넣어야 한다면 직전0.0.x릴리즈에서 현재0.0.x릴리즈로 직접 지정한다. - 다운로드 검증 - 릴리즈 완료 후 문서의 모든 다운로드 URL이 200으로 떨어지는지 확인하고(아래 다운로드 링크 계약), 실제 런처 exe를 내려받아 사용자 경로(프로비저닝 → 네이티브 창 → 백엔드)가 동작하는지 확인한다.
- PyPI 배포는 GitHub Actions Trusted Publisher 기준을 유지한다. long-lived PyPI API token을 저장하지 않고, PyPI publisher 설정은 project
codaro, ownereddmpython, repositorycodaro, workflowpublish.yaml(또는 PyPI에publish.yml로 등록된 경우.github/workflows/publish.ymlalias)와 workflow 파일의 실제 값이 한 글자까지 일치해야 한다. PyPI publisher의 environment를 비워두면 workflow publish job에도environment:를 선언하지 않는다. PyPI 쪽 environment를 채우는 경우에만 workflow의environment:도 같은 값으로 맞춘다. - 릴리즈 자산: 런처 exe(고정 파일명) + sha256 + SPDX SBOM,
python-runtime-win-x64.zip(백엔드 의존성 사전 설치) + sha256, exactcodarowheel + sha256,release-manifest.json(wheel/runtime를 핀). 자산 파일명 규칙은 아래 다운로드 링크 계약을 따른다. - PyPI는 launcher 설치 경로가 아니다. launcher는 PyPI latest를 resolve하지 않고
release-manifest.json의 exact wheel URL과 sha256만 신뢰한다.
다운로드 링크 계약 - 항상 최신 릴리즈
README·랜딩·문서의 모든 "다운로드" 버튼/링크는 이 형태만 쓴다:
https://github.com/eddmpython/codaro/releases/latest/download/<자산명>
releases/latest는 GitHub가 draft/prerelease가 아닌 가장 최근 게시 릴리즈로 자동 해석한다. 따라서 이 URL은 언제나 최신 릴리즈의 자산을 받는다 - 새 버전을 낼 때마다 버튼을 손댈 필요가 없다.- 단, 끝의
<자산명>이 그 최신 릴리즈에 실제로 존재하는 정확한 파일명이어야 한다. 없으면 404 → GitHub가 릴리즈 페이지/오류로 리다이렉트해 "엉뚱한 데로 간다".
자산 파일명은 고정한다
"항상 최신 다운로드"가 깨지는 유일한 경로는 자산 파일명 변경이다. 그러므로:
- 런처 자산명은 한 번 정하면 릴리즈 전반에서 불변으로 유지한다. 게시된 자산명은
gh release view --json assets로 확인한다. - 파일명을 바꿔야 하면 순서를 지킨다: (1) 워크플로우가 새 이름으로 자산을 빌드/업로드 → (2) 그 릴리즈를 게시 → (3) 게시 확인 후에야 README·랜딩·문서 링크를 새 이름으로 갱신. 미게시 이름을 먼저 가리키면 즉시 404다. 순서를 뒤집지 않는다.
게시 후 다운로드 검증 (필수)
릴리즈 게시 직후, 두 층으로 확인한다.
(1) 링크 계약 - 자산명/200. 문서에 적힌 모든 다운로드 URL이 실제로 200으로 떨어지는지:
curl -sI -L -o /dev/null -w "%{http_code} %{url_effective}\n" \
"https://github.com/eddmpython/codaro/releases/latest/download/<자산명>"
200 + content-disposition: attachment; filename=<자산명>이면 통과. 404면 자산명이 릴리즈와 어긋난 것이니 링크를 게시 자산명으로 맞춘다.
(2) cross-env 수신 - 본문/무결성 (필수, 위 헤더 확인만으론 부족). curl -sI는 헤더만 본다 - 본문이 느려서 끊기거나 sha가 어긋나도 200은 떨어진다. 그래서 release-manifest.json이 가리키는 모든 자산을 끝까지 받아 무결성까지 본다:
uv run python -X utf8 docs/skills/ops/tools/verifyPublishedRelease.py \
"https://github.com/eddmpython/codaro/releases/latest/download/release-manifest.json" --strict
각 자산에 대해 200 + 실측 바이트 == Content-Length + 실측 sha256 == manifest sha256 + ranged 206을 확인한다. 게시 시 release-smoke 워크플로(release: published)가 이를 자동 실행하지만, 수동 릴리즈에서도 위 명령으로 직접 점검한다. 이 검증이 깨끗함/더러움 기준의 9번 항목이다.
사례 (2026-06-05)
런처에서 Failed to read response bytes for ... : error decoding response body: operation timed out - 사용자 환경에서 provision이 큰 zip 본문을 받다 멈췄다. 원인은 read_source_bytes http 경로의 timeout 미설정 + 메모리 통적재 + 재시도 0회였고, 자기 업데이트(self_update.rs)는 이미 스트리밍/timeout을 했는데 첫 설치 경로만 취약한 비대칭이었다. 모든 launcher 테스트가 file://만 써서 http 경로 커버리지가 0이라 어느 게이트도 못 잡았다. 근본 해결: download.rs(분절 timeout + resume + 재시도) + 다운로드 mock 테스트의 3-OS 매트릭스 + 본 cross-env 수신 게이트(verifyPublishedRelease.py)를 도입하고, 청결 기준에 9번을 추가했다.
사례 (2026-05-31)
README가 Codaro.exe(런처 자산을 그 이름으로 바꾸려던 다음 릴리즈용)를 가리켰으나, 게시된 최신 릴리즈는 v0.0.5이고 런처 자산명은 CodaroLauncher.exe였다. 미게시 이름이라 모든 다운로드 링크가 404 → 엉뚱한 페이지로 빠졌다. 링크를 게시 자산 CodaroLauncher.exe로 되돌려 즉시 복구했다. 근본 해결은 런처를 새 이름으로 빌드/업로드하는 릴리즈를 먼저 게시한 뒤 링크를 그 이름으로 옮기는 것이다.