Github Action 실습Github Action 실습

Github Action 실습

생성 일시
Aug 22, 2026 06:38 AM
최종 편집 일시
Last updated September 6, 2026
태그
GitHub Actions
작성자
@양혜민
Date
GitHubGitHubGitHub - kintoun-secops/secops-actions-lab

0단계. 준비단계

  1. 해당 레포를 fork하기
  1. git clone 후 uv sync로 환경 맞추기 (uv 설치는 공식 문서 참고)
  1. 워크플로를 채우기 전에 아래 로컬에서 먼저 돌리기로 각 도구를 손으로 한 번씩 돌려보기
notion imagenotion image
22개 패키지 설치 성공, .venv 가상환경 완성, ruff==0.9.10, mypy==1.15.0 등 문서에서 말한 버전도 일치

이것저것 도구 시도해보기

워크플로를 채우기 전, 도구를 로컬에서 먼저 실행해보고 실제로 어떤 문제를 잡아내는지 확인해보았다.
명령어
결과
ruff check
코드 스타일 + 보안 문제 13건 발견
ruff format
포맷 안 맞는 파일 1개
mypy
타입 오류 2건
pytest
테스트 실패 1건
pip-audit
의존성 CVE 18건

1단계. 워크플로 채우기

TODO로 비워진 부분은 크게 두 종류였다.
  • 도구 실행: uv run mypy app/ 같은 실제 명령어 한 줄만 넣으면 되는 부분
  • 이벤트 분기: on:, concurrency, if: 조건을 채우는 부분
다 설명하기에는 내용이 너무 길어져서 중요한 부분 2개 정도만 골라서 자세하게 설명하겠다.

01 - ruff.yml

이 워크플로는 Ruff로 코드 스타일과 보안 이슈를 검사하는 부분이다.
name: "01 - Ruff (format + lint + security)" on: workflow_call: workflow_dispatch: permissions: contents: read pull-requests: write # reviewdog 인라인 코멘트용 jobs: ruff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --frozen - uses: reviewdog/action-setup@v1 with: reviewdog_version: latest - name: Ruff lint -> reviewdog (PR 인라인 리뷰) if: github.event_name == 'pull_request' env: REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | uv run ruff check app/ --output-format=rdjson | reviewdog -f=rdjson -reporter=github-pr-review -level=warning - name: Run Ruff (formatter) run: uv run ruff format --check . - name: Run Ruff (linter) run: uv run ruff check app/
1. 두 가지 트리거만 남겨두기
on: #언제 시작될지 workflow_call: workflow_dispatch:
이 파일 자체에서는 pull_request나 push 같은 트리거를 직접 걸지 않았다. 대신 workflow_call로 열어두고, 07-ci-summary.yml이 이 파일을 불러 쓰는 구조가 된다. workflow_dispatch는 이 워크플로 하나만 따로 테스트해보고 싶을 때를 위해 남겨둔 것이라고 생각하면 된다.
2. 검사는 두 부분으로 나누기
- name: Run Ruff (formatter) run: uv run ruff format --check . - name: Run Ruff (linter) run: uv run ruff check app/
Ruff 하나가 두 가지 일을 한다.
  • formatter: 스타일이 통일돼 있는지만 본다. 자동으로 고쳐주지 않고 -check으로 검사만 하는 이유는자동 수정이 diff와 커밋 이력을 지저분하게 만들기 때문이다.
  • linter: 진짜 문제를 찾는 친구라고 생각하면 된다. SQL 인젝션, 안전하지 않은 pickle 사용 같은 보안 패턴까지 걸러낸다.
이 두 과정이 실제로 merge를 막는 게이트 역할을 한다.
3. if 조건 채우기
if: github.event_name == 'pull_request'
reviewdog가 PR에 인라인 코멘트를 다는 경우) main에 직접 push하거나 예약 실행(schedule)일 때는 애초에 코멘트를 달 PR이 없다. 조건 없이 두면 이 구간은 매번 토큰만 쓰고 아무 일도 하지 않게 된다.

07- CI summary (orchestrator)

01~06이 도구 하나씩을 맡았다면, 07은 그 도구들을 한 실행 안에 모아 최종 결과를 만드는 역할을 한다.
name: "07 - CI summary (orchestrator)" on: workflow_dispatch: pull_request: push: branches: [main] schedule: - cron: '30 20 * * 0' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: {} jobs: changes: runs-on: ubuntu-latest permissions: contents: read outputs: code: ${{ steps.filter.outputs.code || 'true' }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: filter env: BASE: ${{ github.event.pull_request.base.sha || github.event.before }} run: | if [ "$GITHUB_EVENT_NAME" != "pull_request" ] && [ "$GITHUB_EVENT_NAME" != "push" ]; then echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 fi if [ -z "$BASE" ] || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 fi changed=$(git diff --name-only "$BASE" HEAD) code_changed=$(echo "$changed" | grep -Ev '\.md$|^LICENSE$|^\.gitignore$|^_solution/' || true) [ -n "$code_changed" ] && echo "code=true" >> "$GITHUB_OUTPUT" || echo "code=false" >> "$GITHUB_OUTPUT" ruff: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/01-ruff.yml permissions: { contents: read, pull-requests: write } mypy: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/02-mypy.yml permissions: { contents: read } pytest: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/03-pytest.yml permissions: { contents: read, pull-requests: write } pip-audit: needs: changes if: needs.changes.outputs.code == 'true' uses: ./.github/workflows/04-pip-audit.yml permissions: { contents: read } semgrep: needs: changes if: needs.changes.outputs.code == 'true' uses: ./.github/workflows/05-semgrep.yml permissions: { contents: read, security-events: write } gitleaks: needs: changes if: needs.changes.outputs.code == 'true' && github.event_name != 'schedule' uses: ./.github/workflows/06-gitleaks.yml permissions: { contents: read } secrets: inherit summary: runs-on: ubuntu-latest needs: [changes, ruff, mypy, pytest, pip-audit, semgrep, gitleaks] if: always() permissions: {} steps: - name: 결과 표를 잡 요약에 기록 env: CHANGES: ${{ needs.changes.result }} CODE: ${{ needs.changes.outputs.code }} RUFF: ${{ needs.ruff.result }} MYPY: ${{ needs.mypy.result }} PYTEST: ${{ needs.pytest.result }} PIP_AUDIT: ${{ needs['pip-audit'].result }} SEMGREP: ${{ needs.semgrep.result }} GITLEAKS: ${{ needs.gitleaks.result }} run: | fail=0 row() { case "$2" in success) mark="통과" ;; skipped) mark="건너뜀" ;; *) mark="실패/취소"; fail=1 ;; esac printf '| %s | %s |\n' "$1" "$mark" } { echo "## 보안 CI 요약" echo "| 도구 | 결과 |" echo "|---|---|" row "Ruff" "$RUFF" row "mypy" "$MYPY" row "pytest" "$PYTEST" row "pip-audit" "$PIP_AUDIT" row "Semgrep" "$SEMGREP" row "gitleaks" "$GITLEAKS" } >> "$GITHUB_STEP_SUMMARY" [ "$CHANGES" != "success" ] && fail=1 [ "$fail" -ne 0 ] && exit 1 exit 0
1. 트리거 3개 추가하기
기존에 있던 workflow_dispatch에 pull_request, push(main), schedule(주 1회) 세 가지를 추가했다.
  • pull_request: 인라인 리뷰와 커버리지 코멘트가 여기 붙는다.
  • push (main): merge된 상태를 전부 스캔한다.
  • schedule: 코드가 그대로여도 새 CVE가 공개되거나 Semgrep 룰이 갱신되면, 어제 정상이던 커밋이 오늘 오류가 될 수 있어서 매주 한 번씩 다시 돌린다.
2. 도구마다 다른 조건 걸기
  • 코드가 바뀌었는가
  • 예약 실행인가(schedule)
ruff·mypy·pytest·gitleaks는 코드가 그대로면 결과도 그대로라, 예약 실행에서 다시 돌 필요가 없다. 그래서 "코드가 바뀌었고, 예약 실행이 아닐 때"라는 조건을 걸게 된다.
반면 pip-audit과 semgrep은 코드가 그대로여도 결과가 달라질 수 있다. 새 CVE가 공개되거나 룰이 갱신되면 어제와 같은 커밋이 오늘 다른 결과를 낼 수 있어서, 조건을 "코드가 바뀌었을 때" 하나만 걸어 예약 실행에도 돌게 했다.
정리하면, 01~06이 도구 하나를 어떻게 돌릴지를 다뤘다면 07은 그 도구들을 언제, 어떤 조건으로 묶을지를 다루는 파일이라고 보면 된다.
최종 상태 확인
일부러 심어둔 버그를 아직 고치지 않은 상태로 PR을 열어서, 도구들이 실제로 문제를 잡아내는지 확인했다.
notion imagenotion image
  • 6 failing: pytest, ruff, semgrep, summary, mypy, pip-audit→ 버그·보안 이슈를 잡아냄
  • 3 successful: changes, gitleaks, Code scanning results → 정상적으로 통과
여기서 눈여겨볼 부분은 summary도 함께 failing으로 뜬다는 점이다. summary는 여섯 도구의 결과를 모아 최종 판정을 내리는 잡이라, 하나라도 실패하면 summary 도 실패로 끝난다.
반면 gitleaks는 통과했는데, 이건 fork 히스토리 구조상 이번 PR의 새 커밋에는 시크릿 파일이 포함되지 않아서 애초에 스캔 대상이 안되었다.
번외) gitleaks가 못 잡은 이유?
나머지 도구는 다 정상적으로 오류를 잡아내는데, gitleaks만 가짜 시크릿 파일(leaky_settings.py, fake_deploy_key.pem)을 잡아내지 못해서 원인을 찾아봤다.
찾아본 결과,
  1. fork할 때 시크릿 파일이 들어간 커밋까지 전체 히스토리를 그대로 가져옴
  1. 그 위에 워크플로 파일만 고친 새 커밋 5~6개를 추가했고, 시크릿 파일 자체는 건드리지 않음.
  1. gitleaks-action은 PR 스캔 시 브랜치에 새로 추가하는 커밋만 검사함. 즉, 시크릿 파일이 든 옛날 커밋은 과거 상태로 취급돼서 검사대상에 해당하지 않았음
workflow가 잘못 짜인 게 아니라, 내가 실습자료를 fork하는 과정에서 구조상 생겼던 문제였다.

2단계. 브랜치 보호 설정

summary 체크를 필수 상태 체크로 등록하고, 실제로 게이트 역할을 하는지 검증했다.

Ruleset 만들기

Settings → Rulesets → New branch ruleset 순서로 들어가서 설정
  • Enforcement status: Active
  • Branch targeting criteria: Include default branch
  • Require status checks to pass: summary 선택
notion imagenotion image
여기서 핵심은 개별 도구(ruff, mypy 등)가 아니라 summary 하나만 필수 체크로 건다는 점이다.

문서 전용 PR로 검증하기

test/docs-only 브랜치를 만들고 README만 수정해서 commit, push한 뒤 PR을 열어봤다.
notion imagenotion image
  • mypy, pip-audit, pytest, ruff, semgrep , gitleaks→ Skipped
  • changes, summary → Successful
summary 체크에 Required가 붙어있음 → 브랜치 보호 룰셋이 정상 작동하고 있다는 증거이다.
정리하면,
  1. 도구들이 안 돌아도, summary만 통과하면 merge 가능
  1. 코드가 실제로 바뀌었는데 도구가 하나라도 실패하면 summary도 fail되면서 merge 불가

3단계. 수리하기

일부러 심어둔 버그들을 도구가 잡아내는지, 그리고 실제로 고칠 수 있는지 하나씩 확인했다.

(1) pytest: 가격 계산 로직

assert order_total(10) == 9000 E assert 10000 == 9000
10개를 주문하면 개당 100원 할인이 들어가야 하는 로직이다. app/price_logic.py의 조건을 if quantity >= 10:으로 고치니 통과했다.
notion imagenotion image

(2) mypy: 리턴 타입 오류

app/type_confusion.py 12, 15번째 줄에서 리턴 타입이 맞지 않았다. int를 float로, 문자열 "100"을 숫자 100으로 바꿔서 해결했다.
notion imagenotion image
notion imagenotion image
코드 오류 수리 후, 재확인해보면 정상적으로 통과하는 것을 확인했다.

(3) ruff: 린트 + 보안

가장 오류가 많았던 부분이다.
파일
문제
해결
insecure_hash.py
hashlib.md5 사용 (S324)
sha256으로 교체
lint_playground.py
typing.List deprecated, 미사용 import, 중첩 if, 가변 기본 인자(B006), == None(E711)
list로 교체, import 정리, if 병합, None 기본값 후 함수 내 초기화
shell_injection.py
subprocess.run(..., shell=True) (S602)
문자열 조합 대신 리스트 인자 + shell=False
sqli_fstring.py
f-string으로 SQL 조립 (S608)
파라미터 바인딩으로 교체
unsafe_pickle.py
pickle.loads 사용 (S301)
json으로 교체

(4) Semgrep: SQL Injection (taint)

처음엔 f-string 조립을 그대로 두고 값만 살짝 바꿔봤는데 계속 오류가 났다. 값이 아니라 파라미터 바인딩(? placeholder)으로 완전히 바꾸면 해결이 되는 문제이다.
query = "SELECT * FROM users WHERE name LIKE ?" like_pattern = "%" + q + "%" cur.execute(query, (like_pattern,))

(5) pip-audit: 의존성 CVE

flask, jinja2, requests, werkzeug 버전을 전부 최신 버전으로 올렸다.
원래 18건이던 CVE가 1건으로 줄었다.
notion imagenotion image