트랙 BB3180분

추적성과 감사 증적

Launchpad의 M1 비공개 베타는 11월 11일 v0.1.0으로 릴리스되었다. 감사인이 그중 PR 하나를 골라 “이 변경은 어떤 요구에서 왔고, 누가 검토하고 승인했고, 어떤 검사를 통과했는가”를 물으면, 답은 업무 이슈, PR, 리뷰, 검사 실행, 태그에 흩어져 있다. 추적성(traceability)은 이 기록들이 서로 가리키게 연결해 두는 성질이다. 연결이 하나라도 빠진 변경은 기록만으로 설명할 수 없다. 이 모듈은 릴리스 하나의 PR을 모아 추적 보고를 만들고, 빈 곳을 찾아 원인을 판정하고, 증적을 어디에 얼마나 남길지 정한다.

진행
설명 50분 (1~2단계), 실습 110분 (3~7단계), 정리 20분 (8단계)
결과
릴리스 추적 보고 pm-lab/trace-v0.1.0.md, 빈 곳 판정 표, 증적 보존 규칙
명령
gh pr list --json, gh issue list --json, release-trace.sh, gh release create --generate-notes, gh release upload, gh attestation verify
cd ~/gh-pm-lab
source pm-lab/env.sh

1추적의 고리

요구사항 추적 매트릭스(Requirements Traceability Matrix, RTM)는 요구 하나가 어느 업무, 어느 변경, 어느 릴리스로 이어졌는지 표로 적은 것이다. 요구에서 릴리스로 따라가는 것을 순방향 추적, 릴리스의 변경에서 요구로 거슬러 가는 것을 역방향 추적이라고 한다. GitHub에서는 고리마다 연결을 만드는 기록이 정해져 있다.

고리연결을 만드는 기록읽는 필드Level 1
요구 → 업무마일스톤, 완료 조건, 상위·하위 이슈gh issue list --milestone실습 2, 3, 13
업무 → PRPR 본문의 닫는 키워드(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과 같다.

필드담긴 것추적에서 쓰는 곳
closingIssuesReferencesPR이 병합될 때 닫는 이슈의 번호와 주소업무 연결
reviews리뷰마다 작성자, 상태(APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED), 제출 시각, 리뷰가 본 커밋(commit.oid)작성자가 아닌 사람의 승인, 마지막 커밋에 대한 승인
commits커밋마다 해시, 첫 줄, 작성자, 커밋 시각마지막 커밋, 커밋 메시지 형식
statusCheckRollup마지막 커밋의 검사마다 이름, 상태, 결론(SUCCESS, FAILURE 등)검사 통과 여부
mergedBy, mergeCommit병합한 사람, 병합 커밋 해시병합 기록, 태그 포함 여부
bodyPR 본문닫지 않는 언급(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이면 다음과 같다.

  1. 어떤 요구에서 왔는가: #4(LP-04 운영자가 새 신청을 알림으로 확인한다), 마일스톤 M1.
  2. 누가 검토하고 승인했는가: 작성자 dev-lee, 승인 dev-kim(마지막 커밋에 대한 승인), 병합 dev-kim.
  3. 어떤 검사를 통과했는가: 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 단위 이벤트 로그와 규칙 파일로 바꿔 매주 전수로 돌리고, 위반을 점검 이슈로 자동 등록한다.