- 목표: 배포와 되돌리기를 **값 하나**로 하고, 새 릴리스를 일부 직원에게만 먼저 내보내고(카나리), 나쁜 카나리를 보고 끄고, 내보낼지 되돌릴지를 판단하는 점검을 n8n 워크플로우로 만들고, **점검을 지난 릴리스만 배포되게** 한다.
```mermaid
flowchart LR
E1["예제 1<br/>배포 · 되돌리기"] --> E2["예제 2<br/>카나리<br/>같은 사람은 같은 쪽"] --> E3["예제 3<br/>나쁜 카나리를<br/>보고 끄기"] --> T1["과제 1<br/>my-release-check<br/>promote · rollback"] --> T2["과제 2<br/>점검 기록 ·<br/>지난 것만 배포"]
```
> 배포 · 되돌리기는 **사람이** `flow.py release`로 합니다. 테스트 파일은 `before`로 바꾸고 `after`로 처음 상태(`--reset`)로 되돌립니다. 끝나면 `uv run tools/flow.py release helpdesk --reset`.
**왜 무작위로 고르지 않나.** 요청마다 무작위로 고르면 같은 사람이 질문할 때마다 다른 릴리스의 답을 받습니다. 대화가 이어질 때 말투가 바뀌고, 문제가 생겼을 때 누가 어느 릴리스를 썼는지 찾기 어렵습니다. 그래서 이름 · 사번처럼 **바뀌지 않는 값**으로 나눕니다. 이것을 고정 배정(sticky bucketing)이라고 합니다.
**오늘 직원의 bucket.**
| 직원 | bucket | 50% 카나리일 때 |
| --- | --- | --- |
| 최유진 | 0 | 카나리 |
| 정우성 | 42 | 카나리 |
| 한지훈 | 52 | 운영 |
| 이준호 | 56 | 운영 |
| 박서연 | 57 | 운영 |
| 김민지 | 80 | 운영 |
`flow.py release helpdesk`가 카나리일 때 대상 직원을 보여 줍니다. 워크플로우와 `flow.py`가 **같은 규칙**을 써야 둘의 말이 맞습니다.
**이 예제에서는 아무것도 만들지 않습니다.** 예제 2까지 만든 헬프데스크를 그대로 두고, 누가 잘못된 카나리를 켰을 때 **알아채고 끄는 연습**만 합니다.
**상황.** 운영 릴리스(active)는 1.0.0입니다. 누군가 1.2.0을 50% 카나리로 내보냈습니다. 1.2.0은 지시문 p3("답을 한 문장으로 짧게", [AGENTS.md](AGENTS.md) 2절)를 씁니다. 답이 짧아지면서 해결 방법(프린터 **대기열** 비우기)이 빠집니다. HTTP는 200이라 오류로 보이지 않습니다. **답의 내용을 봐야 알 수 있는 나쁜 릴리스**입니다.
**명령마다 하는 일.**
| 순서 | 명령 | 하는 일 |
| --- | --- | --- |
| 1 | `test cases/lab03-watch.json -v` | 테스트 파일 맨 위 `before`가 `reset` 뒤 **카나리 1.2.0 50%를 켭니다** (사고 상황 만들기). 이어서 여섯 직원이 같은 질문 "프린터 출력이 안 돼요"를 보내고, 답에 `대기열`이 있는지 봅니다 |
| 2 | `release helpdesk` | 지금 상태(active · canary)와 카나리 대상 직원을 봅니다. 1번에서 ✘가 난 직원과 맞는지 확인합니다 |
| 3 | `release helpdesk --no-canary` | 카나리를 끕니다. 명령 한 줄 |
| 4 | `test cases/lab03-watch.json --no-before -v` | 같은 테스트를 다시 돌립니다. **`--no-before`가 없으면 `before`가 카나리를 다시 켜서** 끈 효과를 잴 수 없습니다 |
| 5 | `release helpdesk --reset` | 다음 과제를 위해 처음 상태로 |
**누가 ✘가 나나.** 예제 2의 규칙(bucket < 50이면 카나리)대로 최유진(0) · 정우성(42)만 1.2.0을 받습니다. 1번 출력에서 두 사람의 `version.channel`은 `canary`, `version.release`는 `1.2.0`이고, 나머지 넷은 `stable` · `1.0.0`입니다. 같은 질문을 했는데 **카나리 쪽만** 해결 낱말이 없습니다. 이렇게 운영 쪽과 나란히 비교해 차이를 찾는 것이 "카나리를 본다"의 뜻입니다.
**끈 뒤.** 카나리가 없으면 여섯 명 모두 active 1.0.0을 받습니다. 4번 출력에서 모두의 `version.channel`이 `stable`인지 봅니다.
**피해 반경(blast radius).** 전원에게 바로 배포했다면 여섯 명 모두 "포털에서 확인하세요"를 받았을 것입니다. 50% 카나리라서 둘만 받았고, 끄는 데는 명령 한 줄입니다. 카나리 비율은 처음에 작게(1~5%) 시작해 늘리는 것이 보통입니다. 오늘은 여섯 명이라 50%로 둘만 걸리게 했습니다.
**무엇을 보나.**
| 볼 것 | 오늘 | 운영에서 |
| --- | --- | --- |
| 답의 내용 | 해결 낱말이 있는가 (`lab03-watch`) | 샘플 답 읽기, 사용자 평가(👍👎) |
| 실패 · 오류 | HTTP 상태 | 오류율 대시보드 (77일차) |
| 시간 | 케이스 시간 | p95 (72일차) |
| 누가 받았나 | `version.channel` | 로그의 `version`으로 나눠 세기 |
**기대 결과.** 켠 상태: 카나리 둘(최유진 · 정우성) ✘, 운영 넷 ✔ (4/6). 끈 뒤 6/6.
`--no-canary` 뒤 `release helpdesk`를 봅니다. active는 그대로 1.0.0입니다. 카나리를 끄는 것은 "되돌리기"가 아니라 **아직 배포하지 않은 것을 거두는 일**입니다. 1.2.0 릴리스 자체는 데이터 API에 남아 있습니다 (왜 지우지 않을까요?).
</details>
## 과제 1 · 릴리스 점검 워크플로우 (13분)
> "게이트를 매번 노트북에서 돌리니 사람마다 결과가 달라요. n8n에 '이 릴리스 내보내도 돼?'를 물으면 답해 주는 점검을 만들어 주세요. 운영 중인 사람들에게는 영향이 없어야 합니다."
**만들 것**: `my-work/release-check.json` (`my-release-check`). 릴리스를 받아, 정해 둔 질문 6개를 **그 릴리스로 고정해** 헬프데스크에 물어보고, 답을 검사해 `promote` 또는 `rollback`.
| 조건 | 값 |
| --- | --- |
| 계약 | [AGENTS.md](AGENTS.md) 3절 릴리스 점검 |
| 질문 · 기대 | Lab 02 과제 1의 `my-golden`에서 6개 (질문 · 사용자 · 기대 낱말) |
| 판단 | 6개 모두 통과면 `promote`, 하나라도 실패면 `rollback`, 실패한 질문 목록 |
| 기대 결과 | `cases/lab03-challenge.json` (1.1.0 → promote, 1.2.0 → rollback, 릴리스 없음 → 400) |
**먼저 생각해 보기 (3분)**: 점검이 헬프데스크를 부를 때 왜 `release`를 **고정**해야 할까요? 고정하지 않으면 무엇을 점검하게 되나요?
### 이해하기
**노트북의 게이트를 서버로.**`flow.py gate`는 내 노트북에서 돕니다. 사람마다 다른 버전의 테스트 파일, 다른 기준, 다른 시각에 돌리면 결과가 다릅니다. 점검을 **한 곳(n8n)에** 두면 누가 물어도 같은 질문 · 같은 기준으로 답합니다. 회사에서 CI 서버가 테스트를 도는 것과 같은 이유입니다.
**고정해서 물어야 합니다.** 점검이 `release` 없이 헬프데스크를 부르면 **운영 중인 1.0.0**이 답합니다. 1.2.0을 점검한다면서 1.0.0의 답을 채점하게 됩니다. 그래서 Judge는 답의 `version.release`가 점검하는 릴리스와 **같은지**도 봅니다. 다르면 고정이 먹지 않은 것이고, 그 질문은 실패로 셉니다.
**1 → N → 1.** Code가 질문 6개를 **항목 6개**로 돌려주면 다음 HTTP 노드가 항목마다 한 번씩 부릅니다 (66일차). 그 뒤 Code에서 `$input.all()`(답 6개)과 `$('Golden questions').all()`(질문 6개)을 같은 순서로 맞춰 채점하고 하나로 모읍니다.
**판단은 코드가.** "답에 기대 낱말이 있는가"는 글자 검사라 LLM이 아니라 Code가 합니다. LLM이 채점하면 채점도 흔들립니다 (LLM 채점은 79일차).
Code가 질문 6개를 **항목 6개**로 돌려주면, 다음 HTTP 노드가 항목마다 한 번씩 부릅니다 (66일차 1 → N → 1). 내 웹훅 주소는 n8n 안에서도 `http://localhost:5678/webhook/my-helpdesk` (AGENTS.md 5절 "HTTP로 내 웹훅 부르기").
</details>
<details><summary>▶ 2단계 · 검사는 코드가</summary>
답에 기대 낱말이 있는지, 답의 `version.release`가 점검한 릴리스와 같은지. 같지 않다면? (고정이 안 먹은 것)
</details>
<details><summary>▶ 3단계 · 운영에서는</summary>
새 릴리스를 데이터 API에 올리면 이 점검을 자동으로 부르고, `promote`면 카나리 → 배포, `rollback`이면 멈추고 알림. 오늘은 사람이 결과를 보고 `flow.py release`를 합니다. 과제 2에서 "점검을 지난 것만 배포"를 데이터 API가 지키게 합니다.
| 기대 결과 | `cases/lab03-challenge2.json` 5개 (정책을 켜고 1.2.0 · 1.1.0 점검 → 1.2.0 배포 거부 · 1.1.0 배포, 점검 안 한 2.0.0 카나리 거부) |
### 이해하기
**판단과 실행 사이의 틈.** 과제 1의 점검은 `rollback`이라고 **말할** 뿐입니다. 배포는 사람이 `flow.py release`로 하니, 사람이 점검을 안 보거나 잘못 보면 그대로 나갑니다. 판단을 **기록**하고, 배포하는 쪽이 그 기록을 **확인**하게 하면 틈이 닫힙니다.
**누가 무엇을.** 워크플로우는 **기록만** 하고 배포는 하지 않습니다 (AGENTS.md 9절: 워크플로우 안에서 `/releases`에 POST 하지 않는다). 판단하는 쪽과 배포하는 쪽을 나누면, 점검 워크플로우가 고장 나거나 속아도 배포까지 저절로 일어나지는 않습니다. 73일차 "판별 LLM에는 Tool을 주지 않는다"와 같은 생각입니다(최소 권한).
**정책은 데이터 API가 지킵니다.** "점검을 지난 것만 배포"를 사람의 약속이나 문서로 두지 않고, 배포를 받는 곳(데이터 API)이 **거절**하게 합니다. 이것을 **코드로 된 정책(policy as code)**이라고 합니다. 카나리도 같은 정책을 받습니다. 점검 안 한 2.0.0은 카나리로도 못 나갑니다.
**기록이 실패해도 판단은 보냅니다.** 기록 노드가 실패했다고 점검 결과까지 500이면, 사람이 판단을 못 봅니다. 기록 노드는 `onError: continueRegularOutput`으로 두고 `recorded`로 성공 여부만 알립니다 (73일차 감사 로그와 같은 모양).
**`flow.py gate`도 기록합니다.** Lab 02에서 `gate --set release=...`를 돌릴 때마다 `/gates/helpdesk`에 한 줄씩 남았습니다 (`source: flow.py gate`). 그래서 예제 1의 1.1.0 배포는 정책이 켜져 있어도 됩니다(Lab 02 과제 1에서 PASS 기록이 있으면). 테스트의 `before`는 기록을 비우고 시작합니다.
**테스트 케이스 (`lab03-challenge2`, `before`: 기록 비우기 · reset · 정책 켜기, `after`: reset).**