CI는 테스트 명령을 한 번 적는 것보다, 변경할 때마다 같은 검사를 반복하게 만드는 데 의미가 있습니다. 로컬에서는 잘 되는데 동료 PC에서는 실패하는 문제를 빨리 찾을 수 있어요. 이 예제는 외부 Python 패키지 없이 테스트하고, 성공했을 때만 커밋의 ZIP을 만듭니다.
GitHub 저장소에 코드를 푸시할 수 있어야 합니다. 로컬 Gitea만 사용하는 경우 다음 편의 Jenkins 경로를 선택할 수 있습니다. 로컬 Python 테스트와 파일 생성은 확인했으며 GitHub 계정의 호스팅 runner에서 이 워크플로를 실제 실행한 기록은 없습니다.
Git·GitOps 전체 연재 · 17 / 24
이 글의 목차
1. 성공과 실패를 판단할 테스트를 만든다
5편의 greeting.py와 같은 디렉터리에 test_greeting.py를 저장합니다. 정상 입력뿐 아니라 공백 제거와 잘못된 입력도 검사합니다. 테스트가 너무 적으면 CI가 초록색이어도 실제 품질을 설명하지 못합니다.
import unittest
from greeting import greet
class GreetingTest(unittest.TestCase):
def test_greeting(self):
self.assertEqual(greet("Git"), "Hello, Git!")
def test_trims_spaces(self):
self.assertEqual(greet(" Rocky "), "Hello, Rocky!")
def test_rejects_empty_name(self):
with self.assertRaises(ValueError):
greet(" ")
if __name__ == "__main__":
unittest.main()
# Linux or Git Bash with a working python3 command:
python3 -m unittest discover -v
Windows PowerShell에서는 py -3 -m unittest discover -v를 사용할 수 있습니다. 세 테스트가 통과해야 합니다. 예제 묶음의 app/에는 코드와 테스트가 함께 있으며, 저장소 루트에 복사해 사용합니다.
2. 워크플로를 Git에 기록한다
저장소 루트의 .github/workflows/ci.yml에 아래 내용을 저장합니다. Actions는 이 경로의 YAML을 읽습니다. 앞에 임의의 상위 폴더를 하나 더 만들면 인식하지 못할 수 있어요.
name: python-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Test
run: python -m unittest discover -v
- name: Package
run: |
mkdir -p dist
git archive --format=zip --output=dist/app.zip HEAD
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: app-${{ github.sha }}
path: dist/app.zip
retention-days: 7
이벤트는 main의 push와 main으로 향하는 PR입니다. job은 하나의 runner에서 실행되는 작업 묶음이고, step은 그 안의 개별 명령입니다. 새 runner가 저장소를 자동으로 가지고 있는 것은 아니므로 checkout 단계가 필요합니다.
permissions: contents: read로 이 예제에 필요한 저장소 읽기 권한만 요청합니다. persist-credentials: false는 checkout 이후 Git 설정에 인증 정보를 남기지 않도록 합니다. 액션은 2026년 10월 18일 공식 릴리스에서 확인한 전체 커밋 SHA에 고정했습니다. 버전을 갱신할 때는 릴리스와 SHA의 관계를 검토합니다.
3. 푸시한 뒤 어느 커밋의 실행인지 확인한다
git add greeting.py test_greeting.py .github/workflows/ci.yml .gitignore .gitattributes
git diff --staged --check
git commit -m "Add Python CI and package artifact"
git push origin main
.gitignore와 .gitattributes가 없다면 13편의 규칙을 먼저 저장하거나 add 대상에서 제외합니다. 서버의 Actions 탭에서 실행 이벤트, 커밋 ID, Test 로그를 확인합니다. 브랜치에 보호 규칙이 있으면 main에 직접 푸시하는 대신 PR로 진행합니다.
테스트가 통과하면 dist/app.zip을 보관합니다. git archive는 해당 커밋의 추적 파일을 묶는 예제이며 Python 배포 패키지를 빌드하는 명령은 아닙니다. artifact 보관 기간은 7일로 두었으므로 장기 릴리스 저장소와 구분합니다.
4. 실패하는 변경도 시험한다
별도 브랜치에서 greet의 공백 제거를 없애거나 예상 문자열을 잘못 바꾸고 PR을 열어 봅니다. 테스트 step이 실패하면 뒤의 Package와 Upload가 실행되지 않아야 합니다. 다시 코드를 고쳐 푸시한 뒤 새 커밋의 검사가 성공하는지 확인합니다.
PR 검사를 위해 pull_request_target에 외부 PR 코드를 checkout하고 비밀 값을 전달하는 구성을 그대로 복사하지 않습니다. 신뢰하지 않는 코드가 실행되는 경로와 운영 배포 권한을 분리합니다. 자체 runner에서는 네트워크와 호스트에 접근할 수 있는 범위까지 확인해야 합니다.
기본 문법은 GitHub Actions 빠른 시작, 권한과 액션 고정 원칙은 GitHub Actions 보안 가이드를 참고했습니다. CI가 성공한 다음에야 어떤 결과물을 배포할지 결정하는 CD 단계로 넘어갑니다.
이전 글 · [Git·GitOps 16] Gitea 백업과 복원: 저장소만 복사하면 빠지는 데이터 · 다음 글 · [Git·GitOps 18] Gitea와 Jenkins 연결하기: 저장소 인증·Jenkinsfile·트리거 · 연재 목록과 예제 파일

![[Git·GitOps 24] Git·CI/CD·GitOps 운영 흐름 완성하기: 장애별 판단과 복구](https://blog.maruops.com/wp-content/uploads/2026/10/maruops-git-photo-12-768x510.webp)
![[Git·GitOps 23] 비밀 값 관리: Secret의 base64와 SOPS·age 암호화](https://blog.maruops.com/wp-content/uploads/2026/10/maruops-git-photo-2-768x512.webp)
![[Git·GitOps 22] 변경 감지와 복구: 자동 Sync·selfHeal·prune·Git revert](https://blog.maruops.com/wp-content/uploads/2026/10/maruops-git-photo-10-768x576.webp)
답글 남기기