b3os-github-workflow · git:20260801.d45e0f2 · 2026-08-01 · sha256 762a161cd52fb8fd

b3os-github-workflow git:20260801.d45e0f2B

Immutable. This exact content is served forever at /api/v1/blob/762a161cd52fb8fd.

---
name: b3os-github-workflow
description: 팀원이 코드 변경을 GitHub PR로 올려 승인·머지까지 가는 절차. 브랜치·worktree 격리, 커밋 신원(팀원 이름 + 팀 계정), PR 작성 계정, 검증 근거를 PR에 남기기, tier별 승인 요청, 머지 후 실측 확인. 팀원이 git-추적 파일을 고쳐 main에 반영해야 할 때 사용. 승인·안전 정책 자체는 TEAM-OS가 정본이고 여기는 절차만 다룬다.
---
# GitHub 워크플로 — 변경을 PR로 올려 머지까지

**PR·문서는 인과관계와 사실로 쓴다. 회고와 리뷰는 저장소 밖에서 한다. 간결하게, 짧은 문장으로.**

b3os 인프라(소스·config·registry·릴리스)를 고치면 `b3os-infra-safety` 를 먼저 보고 이 절차를 얹는다.

## 무엇을 쓰나 — 저장소에 남는 글은 사실과 인과만

저장소에 들어가는 것은 전부 공개물이다. 용도가 정해져 있다.

| 어디 | 무엇을 |
|---|---|
| README·가이드 | 하는 방법 + 확인 방법 |
| PR 본문 | 무엇이 문제인가(사실) → 왜 그렇게 되나(인과) → 어떻게 고쳤나 → 검증 결과 |
| 커밋 메시지 | 무엇을 바꿨고 왜 그게 맞는가 |
| 코드 주석 | 이 코드가 무엇을 하는가, 왜 이 조건이 필요한가 |
| **메모리·SHARED.md** | **회고·리뷰는 전부 여기.** 밖에 안 나간다 |

**빼는 것은 회고성 서술이다** — 사고 경위, "오늘 우리가", 작업 과정의 날짜, 그 일에 관계없는 사람 이름.

절차가 요구하는 신원은 그대로 쓴다 — 커밋 author, PR 본문의 `Submitted-by`, 코드에서 참조하는 버전·날짜.

## 설정에서 읽을 값

아래 값은 **이 문서에 적지 않는다.** 환경마다 다르고, 문서에 박히면 그 자체가 공격 지점 안내가 된다.

| 값 | 어디서 읽나 |
|---|---|
| 팀원 작업용 GitHub 계정 | 설정 `github_team_account` |
| 승인용 GitHub 계정 | 설정 `github_approver_account` |

이 단계를 건너뛰면 PR 이 승인 계정으로 만들어져 머지가 막힌다.

**읽는 법:**

```bash
S=$(curl -fsS http://127.0.0.1:7878/team/api/settings)
TEAM_ACCOUNT=$(printf '%s' "$S" | python3 -c 'import json,sys;print(json.load(sys.stdin)["github_team_account"])')
[ -n "$TEAM_ACCOUNT" ] || { echo "설정이 비어 있다 — 중단"; exit 1; }
```

**비어 있으면 팀장에게 물어서 채운다.** 값을 지어내지 않는다. 물어볼 것은 둘이다 —
**PR 을 올릴 계정**과 **승인할 계정**. 두 계정은 서로 달라야 리뷰 요건이 성립한다.

**쓰는 법:**

```bash
curl -X PUT -H 'Content-Type: application/json' \
  -d '{"github_team_account":"<팀 작업 계정>","github_approver_account":"<승인 계정>","merge_approvers_normal":"<승인 가능한 팀원>"}' \
  http://127.0.0.1:7878/team/api/settings
# 성공하면 반드시 다시 읽어 확인한다 — 쓰기 성공이 반영을 뜻하지 않는다
curl -fsS http://127.0.0.1:7878/team/api/settings | python3 -m json.tool | grep github
```

**세 값은 한 번에 검증되고 한 번에 저장된다** — 하나가 틀리면 **아무것도 안 바뀐다**
(반쯤 바뀐 보안 설정이 제일 위험하다). 이름에 한글·`@` 를 쓰면 **쓰는 시점에 400** 이다 — 판정기가 못 읽는 값을 넣어두면 **머지할 때 알 수 없는 이유로 막힌다.**

**API 응답에 그 키가 없으면 아직 이 변경이 배포되기 전이다.** 그때 한해 DB 에서 직접 읽어 진행할 수 있다
(그 기능을 처음 올리는 부트스트랩 상황).
**배포 후에는 API 만 쓴다** — DB 직접 읽기는 정상 경로가 아니다.
| 커밋 신원 이메일 | 설정 `github_team_commit_email` |
| tier별 승인자 | 설정 (`merge_approvers_*`) · 판정은 `approvals.ts` |
| 저장소·조직 | `git remote` 실측 |

**설정이 비어 있으면 진행하지 않는다.** 기본값으로 때우면 팀장 개인 계정으로 나가고, 그게 이 절차가 막으려는 바로 그 일이다.

## 핵심 원칙 — 절차는 "확인되는 형태"로

이 스킬의 모든 단계에는 **확인 명령**이 붙어 있다. 이유가 있다.

**안내가 시키는 것과 실제로 벌어지는 일은 어긋날 수 있고, 어긋나도 아무 표시가 없다.**
커밋 메시지에 "테스트 추가"라고 적혀도 리포에 테스트가 없을 수 있다.

그래서 이 절차는 **믿지 않고 확인한다.** 확인 명령이 기대와 다르게 나오면 **그 자리에서 멈춘다**(fail-closed). 조용히 진행하는 것보다 시끄럽게 멈추는 게 항상 낫다.

## 절차

### ① 격리 — 브랜치와 worktree

한 과제 = 한 브랜치. 공유 main 워킹트리에 직접 쓰지 않는다. 상세는 `b3os-infra-safety` ①②.

```bash
git worktree add <경로> -b <브랜치> origin/main
```

### ② 커밋 신원 — 팀원 이름 + 팀 계정 (★조용히 틀리는 지점★)

커밋에 찍히는 이름은 **누가 push 하느냐와 무관한 설정값**이다. 안 걸어주면 **전역 설정이 그대로 쓰이고, 팀장 개인 이름으로 나간다.** 경고는 없다.

**권장 — 설정하지 말고 커밋마다 명시한다.** 그러면 경합 자체가 없다.

```bash
git -c user.name="<팀원 이름>" -c user.email="$(팀 계정 커밋 이메일 조회)" commit -m "..."
```

`git -c` 는 **그 명령 하나에만** 적용된다. 저장소 설정을 쓰지 않으니 남의 값을 덮지도, 남에게 밀리지도 않는다.
아래 `git config` 방식은 그렇게 못 하는 도구를 쓸 때 쓴다.

**이 값은 worktree 별로 갈리지 않는다.** worktree 는 저장소의 `.git/config` 하나를 같이 쓴다
(`extensions.worktreeConfig` 를 켜지 않는 한). 즉 **다른 팀원이 자기 worktree 에서 설정하면 내 것도 바뀐다.**
실제로 그렇게 두 번 어긋났다 — 그래서 **설정하고 끝내지 말고 커밋 직전에 다시 확인한다.**
확인은 **틀린 것을 잡을 뿐 경합을 막지는 않는다.** 두 사람이 같은 시각에 쓰면 나중에 쓴 값이 남는다.

전역(`--global`)은 건드리지 않는다:

```bash
git config user.name  "<팀원 이름>"
git config user.email "$(팀 계정 커밋 이메일 조회)"
```

**확인 — 이걸 통과하지 못하면 커밋하지 않는다:**

```bash
git config user.name    # 비어 있거나 내 이름이 아니면 즉시 중단
git config user.email   # 팀 계정 주소여야 한다. 팀장 개인 주소면 즉시 중단
```

**이름도 본다.** 이메일만 확인하면 이름이 틀려도 통과한다 —
이메일은 팀 계정 하나로 공통이라 늘 맞고, 갈리는 것은 이름이다.
그리고 위에서 적었듯 그 값은 공용이라 **내가 설정한 뒤에도 남이 덮을 수 있다.**

첫 커밋 후 실제로 찍힌 값을 다시 본다. **둘 다 기대값과 같아야 한다:**

```bash
git log -1 --format='%an <%ae>'   # <내 이름> <팀 계정 주소>
```

### ③ 구현과 검증

- 검증은 **baseline 실패 수와 mutation 실패 수를 분리해서** 기록한다. 범위(어떤 테스트를 돌렸는지)도 같이 적는다. 범위를 밝히지 않은 green은 좁혀서 만든 green과 구분되지 않는다.
- 수정이 **실제로 고정되었는지**는 되돌려서 확인한다. 고친 부분을 원복했을 때 테스트가 실패해야 진짜 고정이다. 실패하지 않으면 그 수정은 아무도 지켜주지 않는다.
- 새 테스트를 추가했다면 **그 파일이 정말 커밋에 들어갔는지** 본다. 무시 규칙에 걸려 조용히 빠질 수 있다.

```bash
git show --stat HEAD    # 의도한 파일이 전부 있는지
```

### ④ push

push 인증이 어느 계정으로 나가든 **승인 게이트에는 영향이 없다.** 게이트가 보는 것은 **PR 작성자**다(⑤).

### ⑤ PR 작성 — 팀 계정으로

PR 작성자가 승인자와 **달라야** 리뷰 요건이 성립한다. 그래서 PR은 팀원 작업용 계정으로 만든다. 계정이 갈리면 **승인이 정상적으로 성립해서 게이트를 우회할 필요가 없어진다**(실측 확인).

**★한 줄로 쓰지 않는다.★** 토큰 조회를 그대로 환경변수에 밀어넣는 형태는 **조용히 실패한다** — 조회가 실패하거나 빈 값을 뱉으면 `gh` 는 빈 변수를 **무시하고 기본 계정으로 나가며, 에러도 내지 않는다**(실측: 빈 값·조회 실패 모두 기본 계정으로 작성됨). 이 스킬의 목적이 기록 정확화인데, 실패하면 정확히 그 목적이 조용히 무너진다.

**조회 → 검사 → 사용을 분리한다:**

```bash
TEAM_ACCOUNT="$(설정 조회)"
[ -n "$TEAM_ACCOUNT" ] || { echo "팀 계정 설정이 비어 있다 — 중단"; exit 1; }

TOKEN="$(gh auth token --user "$TEAM_ACCOUNT")" || { echo "토큰 조회 실패 — 중단"; exit 1; }
[ -n "$TOKEN" ] || { echo "토큰이 비어 있다 — 중단"; exit 1; }

GH_TOKEN="$TOKEN" gh pr create --base main --fill
```

**확인 — 작성자가 팀 계정으로 찍혔는지. 다르면 되돌린다:**

```bash
AUTHOR="$(gh pr view <번호> --json author --jq .author.login)"
[ "$AUTHOR" = "$TEAM_ACCOUNT" ] || echo "작성자가 $AUTHOR 다 — 잘못된 계정으로 생성됐다"
```

작성자가 다르면 **그 PR을 닫고 다시 만든다.** 이미 생성된 PR의 작성자는 바꿀 수 없고, 그대로 두면 기록이 틀린 채 남는다. 위 검사를 "참고"로 흘리지 않는다 — 틀렸으면 되돌리는 것까지가 이 단계다.

> ⚠️ 토큰을 화면에 출력하거나 파일·로그에 남기지 않는다. 변수에 담아 그 변수만 넘긴다.
>
> ⚠️ `GH_TOKEN` 은 `gh` 명령에만 적용된다. **일반 `git push` 는 이 변수를 쓰지 않는다** — push 계정을 바꿀 목적으로 이 변수를 쓰면 조용히 다른 계정으로 나간다. push 계정은 승인 게이트에 영향이 없으니(④) 바꾸려 하지 않는 게 맞다.

### ⑤-b PR 본문 — ★핵심 몇 줄을 맨 앞에★

**근거를 촘촘히 쓴 것과 읽고 판단할 수 있게 쓴 것은 다르다.** 배경 → 원인 → 근거 → 결론 순으로 쓰면
읽는 사람이 **결론까지 스크롤해야 한다.** 상세를 지우라는 게 아니라 **순서를 바꾸라는 것이다.**

```markdown
## 핵심 3줄
1. 무엇이 문제인가 (한 문장)
2. 언제 터지나 / 누가 영향받나
3. 어떻게 했나 + 규모(줄 수)

## 무엇을 바꿨나
| 문제 | 고친 방법 |
|---|---|

---
(이하 상세: 실측 근거 · 왜 이 크기인가 · 검증 · 뮤턴트)

Submitted-by: <내 이름>
```

핵심 줄에는 **판단에 필요한 것만** 넣는다:

| 넣는다 | 안 넣는다 |
|---|---|
| **언제 터지나**(발생 빈도·실제 발생 여부) | 조사 과정·시도했다 만 것 |
| **누가 영향받나**(범위·라이브인가) | 코드 세부 구현 |
| **변경 규모**(실제 코드 줄 수) | 배경 설명 |

**같은 원칙이 적용되는 곳** — 팀장님 보고(결론·판단 요청을 맨 위) · 팀원 리뷰 요청("봐줄 것 N개"를 먼저) ·
커밋 메시지(제목 한 줄이 그 역할).

### 고친 부분만 쓴다

작성 기준은 위 **"무엇을 쓰나"** 절과 같다. PR 본문에서 자주 걸리는 것만 다시 적는다.

| 쓴다 (사실) | 안 쓴다 |
|---|---|
| 무엇이 잘못 동작했나 | "처음엔 ~로 갔다가 되돌렸다" |
| 어떻게 고쳤나 — 방법·판단 근거 | "내가 ~를 안 물어본 게 문제였다" |
| 어떻게 확인했나 — 검증·실측 | 시도했다 만 것·중간에 버린 안 |
| 범위를 그 크기로 정한 근거 | 그 근거에 도달한 과정 |

**무엇이 잘못 동작했는지는 "수정한 이유" 에 자연스럽게 들어간다.** 그건 사실이라 쓴다.
빼는 것은 작업 과정에 대한 회고다.

범위 축소처럼 **결과에 영향을 준 판단은 근거만 남긴다** — "발생 0회로 실측되어 복구 로직은 제외" 는 쓴다. 그 근거에 도달한 과정은 안 쓴다.

### ⑥ 검증 근거를 PR에 남긴다

승인 도장은 "봤다"는 표시일 뿐이고, 실제로 지켜주는 것은 **근거**다. 팀 대화에만 남기면 저장소에는 아무 기록이 없다.

PR 본문이나 코멘트에 이 형태로 남긴다:

```text
검증 범위:  <실행한 테스트 범위>
baseline:   <실패 수>
mutation:   <되돌린 것> → <실패 수>
미검증:     <확인하지 않은 범위 — 반드시 적는다>
```

**미검증 항목을 비워두지 않는다.** 전부 확인했다는 주장이 사실인 경우는 드물고, 빈 칸은 "없다"가 아니라 "안 적었다"로 읽힌다.

### ⑦ 승인 요청

tier 판정과 승인자는 **TEAM-OS 정책과 설정을 따른다**(이 문서에서 정하지 않는다).

- tier를 **스스로 낮게 표시하지 않는다.** 애매하면 높은 쪽으로 올린다. 잘못 표시하면 봐야 할 사람을 건너뛰게 되고, 그건 악의가 아니라 판단 착오로도 일어난다.
- 작성자는 자기 PR을 승인하지 않는다. 계정이 갈려 있어도 마찬가지다 — **계정이 아니라 사람(팀원) 기준**이다.

### ⑧ 머지 후 확인

```bash
gh pr view <번호> --json state,mergedAt --jq '"\(.state) \(.mergedAt)"'
git fetch origin && git log origin/main --oneline -1
```

머지됐다고 **라이브에 반영된 것은 아니다.** 실행 중인 프로세스는 이미 메모리에 올린 옛 코드로 계속 돈다. 반영이 필요하면 그건 별도 작업이고 **승인 게이트 대상**이다(TEAM-OS §4).

## ★공개 저장소가 된 뒤 — 달라지는 것★

이 저장소는 공개다. **내 저장소가 아니게 됐다** — 남이 clone·fork 해간 사본이 있다.

### 히스토리를 바꾸지 않는다

**`main` 에 force-push 는 하지 않는다.** 남의 clone 이 깨지고, 이미 퍼진 건 어차피 안 지워진다
(fork 네트워크 · `refs/pull/*` · 미러 · 아카이브에 남는다).

잘못 올라간 것은 **지우지 말고 되돌린다**:

```bash
git revert <문제커밋>      # 히스토리를 고치지 않고 앞으로 덧붙여 취소
```

### 예외는 하나 — 시크릿이 올라갔을 때

그때도 순서가 있다. **지우는 게 1순위가 아니다.**

| 순서 | 무엇 | 왜 |
|---|---|---|
| 1 | **키를 폐기·재발급** | 이미 복제됐다고 가정한다. 이게 실제 방어다 |
| 2 | GitHub 에 캐시 제거 요청 | force-push 해도 fork·PR 참조에는 남는다 |
| 3 | 재작성 + force-push | `--force-with-lease` 로 (남의 push 를 덮지 않는다) |
| 4 | 사용자 공지 | "이 시점에 히스토리가 바뀌었으니 다시 clone 하세요" |

**force-push 직전에 원격 tip 이 준비 시점과 같은지 확인한다.**
재작성본은 **만든 시점에 고정**된다 — 그 사이 머지가 들어오면 덮어쓴다.

### 태그는 옮기지 않는다

이미 받아간 사람과 내용이 달라진다. 고칠 게 있으면 **새 버전을 낸다.**

### 브랜치별로 다르다

| 대상 | force-push |
|---|---|
| `main` | **금지** (시크릿 유출 예외만) |
| 작업 브랜치 | 자유 — rebase 후 `--force-with-lease` 는 일상 |

### 외부 기여가 들어오기 시작하면

- **fork 에서 온 PR 은 시크릿에 접근하지 못한다** — 그걸 전제로 워크플로를 짠다
- 리뷰는 **코드만이 아니라 워크플로 변경도** 본다(`.github/workflows/` 수정은 실행 권한 변경이다)
- 우리 내부 관행(계정 분리·승인자 표기)을 외부 기여자에게 요구하지 않는다 — **그건 우리 규율이다**

### ★PR 본문 마지막 줄에 올린 사람을 쓴다★

승인과 ★같은 이유★ 다 — PR 작성 계정이 공유라 **GitHub 만 봐서는 누가 올린 건지 알 수 없다.** 작성자 필드에는 팀 계정만 남았다. 승인 쪽에는 규칙이 있었는데 ★작성 쪽에만 없었다.★

본문 **마지막 줄**에 정확히 이 모양으로:

```
Submitted-by: <팀원 ID>
```

**왜 산문이 아니라 이 모양인가** — `Approved-by` 와 같다. 아래 절에 적힌 실측 결함(인용문·부정문·
할일·조사·역할어·기술 용어·이스케이프 개행)이 그대로 재현된다. 사람이 읽는 문장은 본문 위쪽에 쓰고,
**기계가 읽는 한 줄은 정해진 모양으로 맨 끝에** 둔다.

승인까지 붙으면 한 PR 에 두 줄이 남는다 — **누가 올렸고 누가 승인했는지**가 저장소만으로 확인된다:

```
Submitted-by: <팀원 ID>
```
(승인 코멘트에) `Approved-by: <승인자 ID>`

발견자·검증자 크레딧(`발견 = <팀원 ID>(교차검증)`)은 **이것과 별개**다. 그건 사람이 읽는 정보라 본문 안에 자유롭게 쓴다.

### 리뷰 승인은 본문 첫 줄에 이름을 쓴다

우리 승인은 계정이 공유라 **기록만으로는 누가 했는지 구분되지 않는다.** 그래서 승인 본문에
**기계가 읽는 한 줄**을 남긴다 — 마지막 줄에 정확히 이 모양으로:

```
Approved-by: <승인자 ID>
```

**왜 산문이 아니라 이 모양인가**

원래 규칙은 *"본문 첫 줄에 이름을 쓴다"* 였고, 그걸 나중에 파싱하려 하자 결함이 쏟아졌다 — 인용문(`> <이름> 승인합니다`)·부정문(`<이름> 은 승인 안 했지만`)·할일(`- <이름> 승인 필요`)· 조사(`<이름>이`)·역할어(`<역할> 승인 후`)·기술 용어(`direct-to-<역할>` → **<역할> 이 승인한 것으로**)·이스케이프 개행.

**전부 "사람이 쓴 문장에서 기계가 쓸 값을 되찾으려다" 생긴 것이다.**
승인자는 그 순간 자기가 누군지 안다 — **그때 박으면 판정이 정확 일치가 되고 저 결함이 통째로 사라진다.**

이름은 **`merge_approvers_normal` 에 있는 사람**이어야 한다. 사람 설명은 그 위에 자유롭게 쓰면 된다.

> ### ⚠️ 이 줄이 **하지 않는 것** — 반드시 같이 읽어라
>
> **이 줄은 "누가 봤는지"를 기록할 뿐, 신원을 증명하지 않는다.**
> 승인 계정은 공유이므로 **그 계정을 쓰는 사람은 누구나 다른 사람의 이름을 적을 수 있다.**
> 즉 **모호함은 없애지만 위조는 막지 못한다.**
>
> 위조까지 막으려면 **팀원별 GitHub 계정**뿐이고, 그건 별도 결정이다(팀 결정: 지금은 **기록** 목적).
>
> **또 하나 — 여러 명이 봐도 기록에는 한 명만 남는다.**
> 승인 계정이 공유라 같은 계정의 **최종 리뷰 하나**만 유효하다. 한 사람이 승인한 뒤 다른 사람이 또 승인하면
> **기록에는 나중 사람만 남고 앞사람은 사라진다.** 계정이 둘뿐이라 **원리적으로 2명까지만 표현된다** —
> 나중에 *"3명이 봤다"* 의 근거로 쓰면 **틀린다.**
>
> **이 문단을 지우지 마라.** 한계가 안 적히면 다음 사람은 이걸 "인증"으로 읽는다 —
> *주석은 "차단한다"인데 구현은 안 하고, 테스트 이름이 검사보다 많이 주장하는* 형태를 우리는 하루에 여러 번 겪었다.

### 이 게이트는 **자발적이다**

`release-preflight.sh --mode merge` 는 **사람이 돌려야** 걸린다. CI 필수 검사로 강제돼 있지 않다.
**안 돌리면 그냥 안 걸린다.** 강제할지는 별도 결정이다 — 나중에 *"왜 안 막혔지"* 가 나오면 답은 여기다.

### 리뷰 요청 후 push 했으면 알린다

`dismiss_stale_reviews` 가 켜져 있어 **리뷰어의 승인이 조용히 폐기된다.** 통지가 가지 않는다. 요청해놓고 커밋을 더 얹었으면 **한 줄 알린다.**

## 하지 않는 것

- **우회 경로를 문서에 적지 않는다.** "어느 계정이 무엇을 건너뛸 수 있는지", "어떤 동작이 검사를 안 타는지"는 공개 저장소에 남기지 않는다. 계정 이름보다 이쪽이 위험하다 — 이름은 커밋에 이미 남지만, 약점 지도는 새 정보다.
- **정책을 여기 복제하지 않는다.** TEAM-OS를 참조한다.
- **확인 단계를 건너뛰지 않는다.** 건너뛴 확인은 "통과"가 아니라 "모름"이다.

## 한계 — 정직하게

계정을 나눠도 **실질적으로 분리되는 것은 아니다.** 팀원 계정과 승인 계정이 같은 기기에서 같은 팀원들에 의해 쓰인다. 이 절차가 주는 것은 **누가 무엇을 했는지가 저장소에 정확히 남는 것**이고, 우회 방지는 규율에 의존한다. 이 문서를 "분리됐다"로 읽으면 안 된다.

## 관련

- `TEAM-OS.md` §4(승인·검증 게이트) · §12(동시 작업)
- `skills/b3os-infra-safety/SKILL.md` — b3os 자체를 고칠 때
- `skills/b3os-bwf/SKILL.md` — 과제 워크플로 전체