cd ~/gh-pm-lab source pm-lab/env.sh
1추적의 고리
요구사항 추적 매트릭스(Requirements Traceability Matrix, RTM)는 요구 하나가 어느 업무, 어느 변경, 어느 릴리스로 이어졌는지 표로 적은 것이다. 요구에서 릴리스로 따라가는 것을 순방향 추적, 릴리스의 변경에서 요구로 거슬러 가는 것을 역방향 추적이라고 한다. GitHub에서는 고리마다 연결을 만드는 기록이 정해져 있다.
| 고리 | 연결을 만드는 기록 | 읽는 필드 | Level 1 |
|---|---|---|---|
| 요구 → 업무 | 마일스톤, 완료 조건, 상위·하위 이슈 | gh issue list --milestone | 실습 2, 3, 13 |
| 업무 → PR | PR 본문의 닫는 키워드(Closes #3) | closingIssuesReferences | 실습 9 |
| PR → 리뷰 | 리뷰와 그 리뷰가 본 커밋 | reviews[].state, reviews[].commit.oid | 실습 9, 12 |
| PR → 커밋과 검사 | PR의 커밋, 마지막 커밋의 검사 결과 | commits, statusCheckRollup | 실습 9 |
| PR → 릴리스 | 병합 커밋이 태그에 포함되는지 | mergeCommit.oid, 릴리스 태그 | - |
| 릴리스 → 산출물 | 릴리스 파일과 산출물 증명 | gh attestation verify | - |
닫는 키워드 대신 Refs #1을 쓰면 PR 화면에는 링크가 생기지만 closingIssuesReferences에는 들어가지 않는다. 실습 18의 정책 문서 PR #18이 이 경우다. 추적 보고는 이런 PR을 연결이 빠진 것으로 표시하고 본문의 언급을 따로 적는다.
2커밋 규칙, 버전, 릴리스 노트
커밋 메시지 형식을 정해 두면 릴리스에 무엇이 들어갔는지 커밋 목록만으로 분류할 수 있다. Conventional Commits 1.0.0은 첫 줄을 유형(범위): 설명으로 쓴다. 실습 9의 docs: 사전신청 정책 추가 (LP-01)가 이 형식이다.
| 커밋 | 뜻 | 의미 있는 버전(SemVer 2.0.0) |
|---|---|---|
fix: ... | 결함 수정 | 수 버전(PATCH)을 올린다. 0.1.0 → 0.1.1 |
feat: ... | 기능 추가 | 부 버전(MINOR)을 올린다. 0.1.0 → 0.2.0 |
feat!: ... 또는 본문의 BREAKING CHANGE: | 호환되지 않는 변경 | 주 버전(MAJOR)을 올린다. 1.4.2 → 2.0.0 |
docs, test, ci, chore 등 | 제품 동작과 무관한 변경 | 버전 규칙은 팀이 정한다 |
SemVer는 주 버전 0(0.y.z)을 초기 개발 단계로 보고, 이 단계에서는 언제든 바뀔 수 있다고 정한다. Launchpad의 첫 릴리스는 v0.1.0이다. 릴리스는 태그를 만들고 GitHub의 릴리스 노트 자동 생성 기능으로 본문을 채운다.
gh release create v0.1.0 -R "$PM_REPO" --target main \ --title "v0.1.0 M1 비공개 베타" --generate-notes gh release view v0.1.0 -R "$PM_REPO" --json tagName,targetCommitish,publishedAt,url
--generate-notes는 GitHub의 릴리스 노트 API로 이전 릴리스 이후 병합된 PR의 제목, 작성자, 번호를 모아 본문을 만든다. 시작점이 될 태그를 직접 정하려면 --notes-start-tag를 쓴다. PR 라벨로 묶어 보이려면 기본 브랜치에 .github/release.yml을 둔다.
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: 기능
labels:
- kind:feature
- title: 결함 수정
labels:
- kind:fix
- title: 기타
labels:
- "*"
3릴리스 범위의 PR과 업무 모으기
릴리스에 들어간 PR은 병합 기간으로 고른다. M1은 Sprint 1 시작일(10월 12일)부터 릴리스일(11월 11일)까지다. 추적에 필요한 필드를 한 번에 받는다. 아래 화면은 Launchpad 저장소에 대한 명령을 gh 2.101.0과 가짜 API 서버로 재현한 것이며, 받은 두 파일은 키트의 samples/b-release-prs.json, samples/b-release-issues.json과 같다.
| 필드 | 담긴 것 | 추적에서 쓰는 곳 |
|---|---|---|
closingIssuesReferences | PR이 병합될 때 닫는 이슈의 번호와 주소 | 업무 연결 |
reviews | 리뷰마다 작성자, 상태(APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED), 제출 시각, 리뷰가 본 커밋(commit.oid) | 작성자가 아닌 사람의 승인, 마지막 커밋에 대한 승인 |
commits | 커밋마다 해시, 첫 줄, 작성자, 커밋 시각 | 마지막 커밋, 커밋 메시지 형식 |
statusCheckRollup | 마지막 커밋의 검사마다 이름, 상태, 결론(SUCCESS, FAILURE 등) | 검사 통과 여부 |
mergedBy, mergeCommit | 병합한 사람, 병합 커밋 해시 | 병합 기록, 태그 포함 여부 |
body | PR 본문 | 닫지 않는 언급(Refs #1) |
4추적 보고 만들기
release-trace.sh는 두 파일로 PR별 추적 표, 업무별 추적 표, 커밋 메시지 요약, 빈 곳 목록을 만든다. 빈 곳은 다섯 가지다. 연결 업무 없음, 다른 사람의 승인 없음, 마지막 커밋 승인 없음(승인이 마지막 커밋보다 앞의 커밋에 대한 것), 검사 없음, 검사 실패.
bash github-pm-starter-kit/release-trace.sh pm-lab/release-prs.json \ --issues pm-lab/release-issues.json --title v0.1.0 > pm-lab/trace-v0.1.0.md cat pm-lab/trace-v0.1.0.md
# 릴리스 추적 보고: v0.1.0 - PR 8건, 병합 2026-10-16 10:30 ~ 2026-11-11 11:20 (+09:00) - 연결된 업무 5건, 빈 곳이 있는 PR 5건 (검사 실패 1, 검사 없음 1, 다른 사람의 승인 없음 1, 마지막 커밋 승인 없음 1, 연결 업무 없음 2) ## PR별 추적 | PR | 제목 | 작성자 | 병합 (병합한 사람) | 연결 업무 | 승인 (마지막 커밋) | 검사 | 빈 곳 | | - | - | - | - | - | - | - | - | | #7 | [LP-01] 사전신청 정책 문서 | gildong | 10-16 10:30 (gildong) | #1 | dev-kim | - | **검사 없음** | | #18 | [LP-01] 재신청 정책 변경: 최신 신청으로 덮어쓰기 | gildong | 10-27 11:00 (gildong) | - (언급 #1, #3) | dev-kim | test 통과 | **연결 업무 없음** | | #9 | [LP-02] 랜딩 안내와 신청 버튼 | dev-lee | 10-28 09:30 (dev-lee) | #2 | gildong | test 통과 | - | | #20 | [LP-03] 이메일 사전신청 저장 | dev-kim | 11-02 16:10 (dev-kim) | #3 | dev-lee | test 통과, e2e 통과 | - | | #21 | [LP-04] 운영자 신규 신청 알림 | dev-lee | 11-03 13:00 (dev-kim) | #4 | dev-kim | test 통과, e2e 실패 | **검사 실패** | | #22 | [LP-03] 재신청 시 동의 시각 갱신 누락 수정 | dev-kim | 11-06 10:30 (dev-kim) | #3 | dev-lee (이전 커밋) | test 통과, e2e 통과 | **마지막 커밋 승인 없음** | | #25 | [LP-06] 베타 초대 메일 템플릿 | dev-lee | 11-11 10:00 (gildong) | #6 | gildong | test 통과, e2e 통과 | - | | #26 | [hotfix] 초대 메일의 신청 확인 링크 수정 | dev-kim | 11-11 11:20 (gildong) | - | - | test 통과, e2e 통과 | **연결 업무 없음, 다른 사람의 승인 없음** | ## 업무별 추적 | 업무 | 제목 | 상태 | PR | | - | - | - | - | | #1 | [LP-01] 사전신청 정책을 확정한다 | CLOSED completed | #7 (언급 #18) | | #2 | [LP-02] 랜딩 페이지에서 서비스 안내와 신청 버튼을 제공한다 | CLOSED completed | #9 | | #3 | [LP-03] 방문자가 이메일로 사전신청을 완료한다 | CLOSED completed | #20, #22 (언급 #18) | | #4 | [LP-04] 운영자가 새 신청을 알림으로 확인한다 | CLOSED completed | #21 | | #5 | [LP-05] 신청 흐름과 예외 상황을 QA로 검증한다 | CLOSED completed | **PR 없음** | | #6 | [LP-06] 비공개 베타 초대 준비를 마친다 | CLOSED completed | #25 | PR이 연결되지 않은 업무: #5. 코드 변경이 없는 업무인지, 연결을 빠뜨린 것인지 확인한다. ## 커밋 메시지 - 커밋 13개, Conventional Commits 형식 13개 (feat 6, fix 3, docs 2, test 2) - feat 커밋이 있다. 이전 릴리스보다 부 버전(MINOR)을 올린다. ## 빈 곳 - #7 [LP-01] 사전신청 정책 문서: 검사 없음 - #18 [LP-01] 재신청 정책 변경: 최신 신청으로 덮어쓰기: 연결 업무 없음. 본문의 언급(닫지 않음): #1, #3 - #21 [LP-04] 운영자 신규 신청 알림: 검사 실패. 검사: test 통과, e2e 실패 - #22 [LP-03] 재신청 시 동의 시각 갱신 누락 수정: 마지막 커밋 승인 없음 - #26 [hotfix] 초대 메일의 신청 확인 링크 수정: 연결 업무 없음, 다른 사람의 승인 없음
PR 8건 중 5건에 빈 곳이 있다. 업무 쪽에서 보면 6건 중 #5(LP-05)에만 PR이 없다. 커밋 13개는 모두 Conventional Commits 형식이고 feat가 6개이므로, 이 범위는 이전 릴리스보다 부 버전을 올리는 변경이다. 이전 릴리스가 없는 Launchpad는 0.1.0으로 시작했다.
5빈 곳 판정
빈 곳이 모두 통제 위반은 아니다. 하나씩 기록을 열어 원인을 확인하고, 수용할지, 기록을 보완할지, 통제를 고칠지 정한다.
| PR | 빈 곳 | 기록이 말하는 것 | 판정 |
|---|---|---|---|
| #7 | 검사 없음 | 10월 16일 병합. CI 워크플로와 규칙 세트는 10월 19일에 생겼다 | 수용. 통제가 생기기 전의 변경으로 보고서에 적는다 |
| #18 | 연결 업무 없음 | 본문에 Refs #1, #3. 이미 닫힌 LP-01을 다시 열지 않으려고 닫는 키워드를 피했다 (실습 18) | 수용. 업무별 표에 언급으로 나온다. 결정 기록 #3과 함께 묶어 둔다 |
| #21 | 검사 실패 | test는 통과, e2e는 실패. main 규칙 세트의 필수 검사는 test 하나다 | 통제 수정. 필수가 아닌 검사의 실패는 병합을 막지 않는다. e2e를 필수 검사에 넣을지 정한다 |
| #22 | 마지막 커밋 승인 없음 | dev-lee가 승인한 뒤 dev-kim이 커밋을 하나 더 했다 (아래 화면) | 통제 수정. B2 매트릭스의 꺼진 설정 두 개가 원인이다 |
| #26 | 연결 업무 없음, 다른 사람의 승인 없음 | 본문에 “관리자 우회로 병합한다”. 병합한 사람은 gildong(관리자) | 기록 보완. 예외 기록과 사후 리뷰가 있어야 한다 |
승인이 본 커밋(c12be61)과 마지막 커밋(4652729)이 다르다. 승인 시각(01:00Z, 한국 시각 10:00)과 마지막 커밋 시각(10:20)을 비교해도 같은 결론이지만, 커밋 시각은 작성자의 컴퓨터 시각이고 푸시 시각이 아니다. 해시를 비교하는 쪽이 정확하다. B2의 dismiss_stale_reviews_on_push가 켜져 있었다면 이 승인은 새 커밋이 올라온 순간 무효가 되어 병합이 막혔다.
LP-05에 PR이 없는 것은 QA 업무가 테스트 항목표로 완료되기 때문이다(실습 26의 QA 업무 규칙과 같다). 추적 보고에는 업무 이슈의 검증 근거 댓글 주소를 대신 적는다.
6증적 보존
추적 보고는 지금 GitHub에 남아 있는 기록으로 만든다. 기록마다 남는 기간이 다르므로, 감사 시점까지 필요한 증적은 따로 보존한다.
| 증적 | GitHub에 남는 기간 | 보존 방법 |
|---|---|---|
| 이슈, PR, 리뷰, 댓글, 커밋 | 저장소가 있는 동안. 지우거나 저장소를 삭제하면 사라진다 | 저장소 삭제 권한을 관리자 한두 명으로 줄인다 (B2) |
| 검사 실행 로그와 Actions 산출물 | 기본 90일. 저장소 설정으로 바꿀 수 있고, 공개 저장소는 최대 90일, 비공개 저장소는 최대 400일 | 추적 보고에 검사 결과를 옮겨 적는다. 필요한 산출물은 릴리스 파일로 올린다 |
| 저장소 이벤트 API | 최근 90일, 최대 300건 (실습 18) | pm-data 브랜치의 스냅숏과 보고서로 대신한다 |
| 조직 감사 로그 | Enterprise Cloud에서 최근 180일, Git 사건은 7일 (엔터프라이즈 감사 로그 문서 기준) | 감사 로그 스트리밍이나 월별 내보내기 |
| 릴리스 추적 보고 | 릴리스가 있는 동안 | 릴리스 파일로 올린다 |
gh release upload v0.1.0 pm-lab/trace-v0.1.0.md -R "$PM_REPO" gh release view v0.1.0 -R "$PM_REPO" --json assets --jq '.assets[].name'
규칙 세트를 누가 언제 바꿨는지는 조직 감사 로그에 남는다. 감사 로그 REST API는 GitHub Enterprise Cloud 조직에서만 쓸 수 있고, 조직 소유자가 read:audit_log 범위의 토큰으로 조회한다. 개인 계정의 실습 저장소에서는 실행할 수 없으므로 명령과 확인할 필드만 적는다.
gh api "orgs/ORG/audit-log?phrase=action:repository_ruleset&per_page=100" \ --jq '.[] | [(.["@timestamp"] / 1000 | todate), .action, .actor, .repo] | @tsv' gh api "orgs/ORG/audit-log?phrase=action:protected_branch&per_page=100" \ --jq '.[] | [(.["@timestamp"] / 1000 | todate), .action, .actor, .repo] | @tsv'
감사 로그의 시각(@timestamp)은 밀리초 단위 epoch이므로 1000으로 나눠 날짜로 바꾼다. 감사 로그가 없는 저장소에서는 규칙 세트의 updated_at을 매주 기록해 바뀐 날을 하루 단위로 찾는다.
팀의 증적 보존 규칙은 세 줄이면 된다. 무엇을(추적 보고, 통제 매트릭스, 변경 대장), 어디에(릴리스 파일, pm-data 브랜치), 얼마나(감사 주기보다 길게, 예를 들어 다음 연간 감사가 끝날 때까지) 남기는가.
7산출물 증명
추적 보고는 “어떤 PR이 main에 들어갔는가”까지 보여 준다. 사용자가 내려받은 파일이 그 main에서 만들어졌는지는 따로 확인해야 한다. 산출물 증명(artifact attestation)은 GitHub Actions가 빌드한 파일의 해시와 빌드한 워크플로, 저장소, 커밋을 서명한 기록이다. 형식은 SLSA 출처 증명(provenance)이며, GitHub 문서는 산출물 증명만으로 SLSA v1.0 Build Level 2를, 재사용 워크플로로 빌드를 격리하면 Build Level 3을 충족한다고 설명한다. 공개 저장소는 모든 요금제에서, 비공개 저장소는 Enterprise Cloud에서 쓸 수 있다.
on:
release:
types: [published]
permissions:
contents: write
id-token: write
attestations: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 배포 파일 만들기
run: |
mkdir -p dist
tar czf "dist/launchpad-${GITHUB_REF_NAME}.tar.gz" site/
- uses: actions/attest-build-provenance@v2
with:
subject-path: dist/launchpad-*.tar.gz
- name: 릴리스에 올리기
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" dist/launchpad-*.tar.gz -R "$GITHUB_REPOSITORY"
id-token: write는 서명 인증서를 받는 데, attestations: write는 증명을 저장하는 데 필요하다. 증명 액션의 주 버전은 저장소의 안내를 보고 고른다. 내려받은 파일은 아래처럼 확인한다.
gh release download v0.1.0 -R "$PM_REPO" -p 'launchpad-*.tar.gz' -D pm-lab/ gh attestation verify pm-lab/launchpad-v0.1.0.tar.gz --repo "$PM_REPO" \ --signer-workflow "$PM_REPO/.github/workflows/release.yml" \ --source-ref refs/tags/v0.1.0 --deny-self-hosted-runners
| 옵션 | 확인하는 것 |
|---|---|
--repo 또는 --owner | 증명이 이 저장소(또는 소유자)의 것이다. 둘 중 하나는 반드시 준다 |
--signer-workflow | 서명한 워크플로 파일이 정한 파일이다. 다른 워크플로가 만든 파일을 거른다 |
--source-ref | 빌드한 git ref가 릴리스 태그다 |
--deny-self-hosted-runners | 자체 호스팅 러너에서 만든 증명을 거부한다 |
--predicate-type | 증명의 종류. 기본값은 https://slsa.dev/provenance/v1 |
--format json | 검증된 증명과 인증서 내용을 JSON으로 내보낸다. 정책 검사 도구에 넘길 때 쓴다 |
85분 질문 연습
짝이 추적 보고에서 PR 하나를 고르고, 다른 사람이 보고서와 명령만으로 세 질문에 답한다. 예를 들어 #21이면 다음과 같다.
- 어떤 요구에서 왔는가: #4(LP-04 운영자가 새 신청을 알림으로 확인한다), 마일스톤 M1.
- 누가 검토하고 승인했는가: 작성자 dev-lee, 승인 dev-kim(마지막 커밋에 대한 승인), 병합 dev-kim.
- 어떤 검사를 통과했는가:
test통과,e2e실패. 필수 검사는test뿐이어서 병합이 막히지 않았다.
답하는 데 걸린 시간을 재고, 5분을 넘긴 질문이 있으면 어느 고리가 빠져서인지 적는다.
막히면
| 증상 | 확인과 해결 |
|---|---|
오류: PR이 없다 | 검색 기간에 병합된 PR이 없다. --search "merged:시작..끝"의 날짜를 실습 저장소의 기간으로 고친다. 날짜는 UTC 기준이다 |
--json에 다음 필드가 빠졌다 | 3단계의 --json 목록을 그대로 쓴다. 필드 이름은 대소문자를 구분한다 |
| 모든 PR이 “검사 없음”이다 | 저장소에 CI 워크플로가 없다. 실습 저장소라면 정상이다. 검사가 있는데도 그렇다면 gh pr checks 번호로 확인한다 |
| 승인이 있는데 “다른 사람의 승인 없음”이다 | 승인한 사람이 PR 작성자와 같다. 혼자 실습하면 이렇게 나온다 |
gh release create가 태그를 묻는다 | 첫 인자로 태그 이름(v0.1.0)을 준다. --target은 태그가 없을 때 만들 커밋의 브랜치다 |
gh attestation verify가 No attestations found for subject로 끝난다 | 증명을 만드는 워크플로가 돌지 않았거나, 비공개 저장소에서 Enterprise Cloud가 아니다. 워크플로 실행 기록과 저장소 공개 여부를 확인한다 |
- 추적 고리 여섯 개마다 연결을 만드는 GitHub 기록과 필드를 말할 수 있다.
- 릴리스 범위의 PR과 업무를 JSON으로 받았다.
release-trace.sh로 추적 보고를 만들고, 빈 곳마다 수용·기록 보완·통제 수정 중 하나로 판정했다.- PR #22에서 승인한 커밋과 마지막 커밋이 다르다는 것을 해시로 확인했다.
- 증적마다 GitHub에 남는 기간과 보존 방법을 적은 증적 보존 규칙을 만들었다.
- 추적 보고를 릴리스 파일로 올렸다.
마치며...
추적 보고는 릴리스의 변경 하나하나를 업무, 리뷰, 커밋, 검사와 연결하고, 연결이 끊긴 곳을 목록으로 보여 준다. Launchpad v0.1.0의 PR 8건 중 5건에 빈 곳이 있었고, 그중 #22는 B2의 매트릭스에서 약함으로 나온 설정에서, #21은 필수 검사 목록에서, #26은 우회 병합에서 나왔다. 릴리스마다 사람이 이 보고를 열어 보는 방식은 릴리스 사이에 생긴 위반을 늦게 찾는다. B4에서는 같은 점검을 PR 단위 이벤트 로그와 규칙 파일로 바꿔 매주 전수로 돌리고, 위반을 점검 이슈로 자동 등록한다.