CI/CD와 GitHub Actions 입문

목표: “PR 올리면 CI가 돌아요”, “체크가 빨간불이라 머지가 막혔어요”, “main에 푸시하면 자동 배포돼요”, “secrets에 토큰 넣어 두세요” 같은 말을 듣고 무엇이 언제, 어느 컴퓨터에서, 어떤 권한으로 실행되는지 떠올리고, 간단한 워크플로 파일을 직접 써 볼 수 있기.

커밋·브랜치·PR이 낯설면 Git 입문을, 터미널·환경 변수·YAML이 낯설면 컴퓨터 기본 도구와 개발 환경 입문을 먼저 읽는 편이 좋다.


한 줄로 먼저

CI/CD는 코드를 올릴 때마다 사람이 손으로 하던 확인과 배포를 기계가 정해진 순서대로 대신하게 하는 습관이고, GitHub Actions는 그 일을 GitHub 안에서 YAML 파일 하나로 시키는 도구다.

약자풀이한 줄 뜻
CIContinuous Integration, 지속적 통합푸시·PR마다 린트·테스트·빌드를 자동으로 돌려 깨진 코드가 합쳐지지 않게 한다
CDContinuous Delivery, 지속적 전달언제든 배포할 수 있는 결과물을 자동으로 만들어 두고, 배포 버튼은 사람이 누른다
CDContinuous Deployment, 지속적 배포검사를 통과하면 사람 손 없이 실제 서비스까지 내보낸다
git push / PR 열기
  ↓ event: GitHub가 "푸시가 일어났다"를 감지
.github/workflows/ci.yml 을 읽음
  ↓
runner(GitHub가 빌려주는 깨끗한 Linux VM)가 켜짐
  ↓ step을 위에서부터 차례로 실행
checkout → Python 설치 → 패키지 설치 → ruff → pytest
  ↓
초록 ✓ (머지 가능) 또는 빨강 ✗ (로그 보고 고치기)
  ↓ main에 머지되면
배포 workflow → GitHub Pages / 서버 / 패키지 저장소

0. 왜 필요한가: “제 컴퓨터에서는 통과했는데요”

사람이 직접 확인하면 이런 일이 생긴다.

  • 테스트 돌리는 것을 깜빡하고 푸시한다.
  • 내 노트북에 우연히 깔려 있던 패키지 덕분에 나만 통과한다.
  • 두 사람이 각자 고친 코드는 따로 보면 멀쩡한데, 합치니 깨진다.
  • 배포 절차가 한 사람 머릿속에만 있다.

CI/CD는 세 가지로 푼다.

  1. 매번 같은 절차. 사람은 잊어도 기계는 잊지 않는다.
  2. 깨끗한 환경. runner는 매번 새로 켜진 컴퓨터다. requirements.txt에 빠진 패키지가 있으면 바로 드러난다.
  3. 작게, 일찍 발견. 변경 하나마다 확인하므로 “방금 바꾼 그것”이 원인이라는 것을 바로 안다.

통합(integration) 은 여러 사람의 변경을 본선 브랜치(main)에 합치는 일이다. 자주, 조금씩 합치고, 합칠 때마다 자동 검사를 거치는 것이 CI의 핵심이다.


1. 지속적 전달과 지속적 배포

둘 다 CD로 줄여 써서 헷갈린다. 차이는 마지막 운영 배포에 사람이 끼어 있느냐 하나다.

                         빌드 → 테스트 → 스테이징 배포 → [운영 배포]
지속적 전달 (Delivery)     자동     자동        자동          사람이 승인
지속적 배포 (Deployment)   자동     자동        자동          자동

지속적 배포는 승인까지 테스트에 맡기므로 테스트가 그만큼 튼튼해야 한다. 개인 블로그처럼 틀려도 피해가 작은 곳은 보통 지속적 배포를 쓴다.

파이프라인 단계

CI/CD 흐름 전체를 파이프라인(pipeline) 이라고 부른다. 앞 단계가 실패하면 뒤 단계는 보통 실행하지 않는다.

단계하는 일Python 연구 코드의 예
1. 가져오기저장소 코드를 runner에 받기actions/checkout
2. 환경 준비언어·패키지 설치actions/setup-python, pip install -r requirements.txt
3. 린트문법 실수·스타일 위반 찾기ruff check .
4. 테스트기대대로 동작하는지 확인pytest
5. 빌드배포할 결과물 만들기정적 사이트 HTML, .whl, Docker 이미지
6. 배포결과물을 실제 장소로GitHub Pages, PyPI, 서버

린트를 앞에 두는 이유는 빠르고 싸기 때문이다. 판정 기준은 하나다. 명령의 종료 코드(exit code) 가 0이면 성공, 아니면 실패다. pytest는 테스트가 하나라도 실패하면 0이 아닌 값으로 끝나므로 그 step이 빨간불이 된다.


2. GitHub Actions의 구조

단어한 줄 정의
workflow자동화 하나를 적은 YAML 파일. 저장소 루트의 .github/workflows/*.yml
event (trigger)workflow를 깨우는 사건. 푸시, PR, 정해진 시각, 수동 버튼
jobrunner 하나에서 실행되는 작업 묶음. job끼리는 기본적으로 동시에 돈다
stepjob 안의 한 단계. 위에서부터 순서대로 실행되고, 같은 job의 step끼리는 파일을 공유한다
runnerjob을 실제로 실행하는 컴퓨터
workflow (ci.yml)
├── on: push, pull_request          ← 언제
└── jobs:
    ├── lint  (runner A)            ← 서로 다른 컴퓨터
    │   ├── step: checkout
    │   └── step: ruff check
    └── test  (runner B, needs: lint)
        ├── step: checkout
        ├── step: setup-python
        └── step: pytest

비유하면 workflow는 요리 대회 진행표다. job은 조리대, step은 그 조리대의 조리 순서, runner는 조리대를 맡은 요리사다. 조리대가 다르면 재료(파일)를 그냥 넘겨받을 수 없다(6절의 artifact).

가장 작은 workflow는 이렇다.

# .github/workflows/hello.yml
name: Hello
on: push
jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Hello from $(uname -a)"

푸시하면 저장소의 Actions 탭에 실행 기록이 생긴다. GitHub는 루트의 .github/workflows/만 읽는다.

runner: 누구의 컴퓨터인가

구분GitHub-hosted runnerself-hosted runner
정체GitHub가 job마다 새로 띄우는 VM내가 등록한 내 컴퓨터·서버
지정runs-on: ubuntu-latest, macos-latest, windows-latestruns-on: [self-hosted, linux, gpu] 같은 라벨
상태job이 끝나면 버려짐이전 실행의 파일이 남을 수 있음
GPU기본 러너에는 없음연구실 GPU 서버를 등록하면 가능

ubuntu-latest의 latest는 Docker 태그처럼 가리키는 대상이 바뀐다(Docker 입문). 고정하려면 ubuntu-24.04처럼 적는다.


3. 언제 실행할까: on:

트리거실행 시점자주 쓰는 곳
push브랜치·태그에 커밋이 올라올 때main 머지 뒤 배포
pull_requestPR이 열리거나 커밋이 추가될 때머지 전 테스트
schedulecron 식으로 정한 시각매일 밤 평가, 주간 데이터 수집
workflow_dispatchActions 탭의 Run workflow 버튼수동 배포, 일회성 실험
repository_dispatch외부에서 API로 신호를 보낼 때다른 저장소가 빌드를 깨울 때 (9절)
on:
  push:
    branches: [main]
    paths: ["src/**", "tests/**"]
  pull_request:
  schedule:
    - cron: "0 18 * * *"     # 매일 18:00 UTC = 한국 시간 다음 날 03:00
  workflow_dispatch:
  • paths를 쓰면 그 파일이 바뀐 커밋에만 돈다. 문서만 고쳤는데 무거운 테스트가 도는 일을 막는다.
  • cron은 UTC 기준이다. 한국 시간(UTC+9)에서 9시간을 빼서 적는다. 다섯 칸은 분 시 일 월 요일이다.
  • schedule은 기본 브랜치의 workflow 파일로만 돈다. 정시 시작이 보장되지 않고, 바쁠 때는 늦어지거나 건너뛸 수 있다. 공개 저장소에 오래(공식 문서 기준 60일) 활동이 없으면 예약 실행이 자동으로 꺼진다.

4. job과 step 자세히

uses:와 run:

  • uses: 남이 만든 action(재사용 가능한 작업 꾸러미)을 불러 쓴다. 형식은 소유자/저장소@버전, 입력은 with:로 넘긴다.
  • run: 셸 명령을 직접 실행한다. Linux runner에서는 기본적으로 bash다. run: |의 |는 “여러 줄을 그대로 한 덩어리로”라는 YAML 문법이다.
steps:
  - uses: actions/checkout@v4          # 저장소 코드 받기
  - uses: actions/setup-python@v5
    with:
      python-version: "3.12"
  - run: |
      python -m pip install --upgrade pip
      pip install -r requirements.txt
  - run: pytest -q

actions/checkout을 빼먹는 실수가 가장 흔하다. runner는 빈 컴퓨터로 시작하므로 이게 없으면 내 코드가 없다.

같은 job의 step은 같은 폴더를 쓰지만, run: 하나하나는 새 셸 프로세스다. 앞 step의 cd나 export는 다음 step으로 이어지지 않는다. 폴더는 working-directory:로, 환경 변수는 env:나 $GITHUB_ENV 파일로 넘긴다.

needs:와 if:

job은 기본적으로 동시에 돈다. 순서가 필요하면 needs:를 쓴다.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: echo lint
  test:
    needs: lint                # lint가 성공해야 시작
    runs-on: ubuntu-latest
    steps:
      - run: echo test
  deploy:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main'   # main일 때만
    runs-on: ubuntu-latest
    steps:
      - run: echo deploy

matrix: 여러 환경을 한 번에

    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        python-version: ["3.10", "3.11", "3.12"]
    runs-on: ${{ matrix.os }}

모든 조합(2 × 3 = 6개)이 각각 별도 job으로 동시에 돈다. fail-fast: false면 하나가 실패해도 나머지를 끝까지 돌려, 어느 조합에서만 깨지는지 보여 준다.

Python 버전은 반드시 따옴표로 감싼다. YAML은 따옴표 없는 3.10을 숫자 3.1로 읽는다.

${{ }} 표현식

${{ ... }}는 셸이 아니라 GitHub가 실행 전에 미리 끼워 넣는 값이다. github.ref(브랜치), github.sha(커밋 해시), github.event_name, runner.os, matrix.*, secrets.*, hashFiles('requirements.txt')(파일 내용 해시) 등을 쓴다.


5. 환경 변수, secrets, GITHUB_TOKEN

환경 변수와 secrets

env:는 workflow, job, step 어느 수준에도 적을 수 있고, 좁은 쪽이 넓은 쪽을 덮어쓴다. 노출되면 안 되는 값(API 키, 배포 토큰)은 YAML에 쓰지 않는다. 저장소 Settings → Secrets and variables → Actions에 등록하고 이름으로 부른다.

      - run: python upload.py
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
  • 등록한 secret은 로그에 찍히면 ***로 가려진다(마스킹). 하지만 base64로 바꾸거나 잘라서 찍으면 안 가려질 수 있다. 마스킹은 방패가 아니라 안전벨트다.
  • fork에서 온 PR의 pull_request 실행에는 secrets가 전달되지 않는다(GITHUB_TOKEN은 읽기 권한으로만 온다). 낯선 사람이 “secret을 출력하는 코드”를 PR로 보내는 공격을 막기 위해서다.
  • 비밀이 아닌 설정값은 같은 화면의 Variables에 두고 ${{ vars.XXX }}로 읽는다.

GITHUB_TOKEN과 permissions:

GitHub는 job마다 GITHUB_TOKEN 이라는 임시 토큰을 자동으로 만든다. 그 저장소 하나에만 쓸 수 있고 job이 끝나면 만료된다. PR 댓글, Pages 배포 같은 일을 별도 토큰 없이 하는 이유다. 권한은 permissions:로 필요한 것만 연다.

permissions:
  contents: read        # 코드 읽기
  pull-requests: write  # PR에 댓글

permissions:를 적으면 적지 않은 항목은 권한 없음이 된다. 기본 권한은 저장소·조직 설정에 따라 다르므로 명시하는 습관이 안전하다.

GITHUB_TOKEN으로 일으킨 event는 다른 workflow를 새로 깨우지 않는다(workflow_dispatch, repository_dispatch는 예외). 무한 연쇄를 막는 장치다. 다른 저장소를 건드리려면 PAT(Personal Access Token, 개인 접근 토큰) 나 GitHub App 토큰을 secret으로 넣어 쓴다.


6. artifact와 cache

구분artifactcache
목적결과물을 남기거나 다른 job에 넘기기같은 준비 작업을 다음 실행에서 건너뛰기
예테스트 리포트, 빌드된 사이트, 그림, 로그pip 다운로드, Hugging Face 모델 캐시
도구actions/upload-artifact, actions/download-artifactactions/cache, setup-python의 cache:

job마다 컴퓨터가 다르므로, 한 job이 만든 파일을 다른 job에서 쓰려면 artifact로 올렸다 내려받는다.

      - uses: actions/upload-artifact@v4      # build job 안에서
        with:
          name: figures
          path: figures/
      # ...
      - uses: actions/download-artifact@v4    # needs: build 인 다른 job 안에서
        with:
          name: figures
          path: figures/

artifact는 보관 기간이 지나면 지워진다(retention-days:로 조절). 오래 남길 실험 결과는 다른 곳에 둔다.

pip 캐시는 actions/setup-python에 cache: pip 한 줄이면 된다. 의존성 파일(requirements.txt 등)이 그대로면 이전 다운로드를 재사용한다. 그런 파일이 없으면 오히려 오류가 난다. 다른 폴더는 actions/cache로 직접 캐시한다.

      - uses: actions/cache@v4
        with:
          path: ~/.cache/huggingface
          key: hf-${{ runner.os }}-${{ hashFiles('scripts/download_models.py') }}
          restore-keys: |
            hf-${{ runner.os }}-

key가 정확히 같으면 그대로 복원하고, 없으면 restore-keys로 앞부분이 같은 최근 캐시를 가져온다. 캐시는 없어도 결과가 같아야 한다. 속도용이지 정답 보관함이 아니다.


7. 예제 1: Python 프로젝트에 ruff + pytest

src/, tests/, requirements.txt가 있는 연구 코드 저장소를 가정한다.

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
permissions:
  contents: read
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install ruff
      - run: ruff check .
      - run: ruff format --check .
 
  test:
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
      - run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt pytest
      - run: pytest -q
  • on: main에 푸시될 때와 모든 PR에서 돈다.
  • permissions: 읽기만 한다. 아무것도 쓰지 않는 workflow의 최소 권한이다.
  • concurrency: 같은 브랜치에 연달아 푸시하면 이전 실행을 취소하고 최신 것만 돌린다.
  • lint는 몇 초면 끝난다. 실패하면 test는 시작하지 않는다.
  • ruff format --check는 고치지 않고 “포맷 어긋난 파일이 있으면 실패”만 한다. 로컬에서 ruff format . 후 다시 푸시한다.

로컬에서 같은 명령을 먼저 돌려 보고 푸시하면, CI는 놓친 것을 잡는 안전망이 된다.


8. 예제 2: 정적 사이트를 GitHub Pages로 배포

논문 프로젝트 페이지를 MkDocs로 만든다고 하자. requirements.txt에 mkdocs-material이 있고, 저장소 Settings → Pages → Source를 GitHub Actions로 바꿔 두었다고 가정한다.

# .github/workflows/pages.yml
name: Deploy site to Pages
 
on:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
  pages: write
  id-token: write
 
concurrency:
  group: pages
  cancel-in-progress: false
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements.txt
      - run: mkdocs build --site-dir _site
      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: _site
 
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4
  • 빌드와 배포를 나눈다. build가 HTML을 Pages 전용 artifact로 올리고, deploy가 그것을 공개한다.
  • pages: write는 Pages에 올릴 권한, id-token: write는 배포 단계가 “나는 이 저장소의 정식 workflow다”라고 증명하는 토큰(OIDC)을 받을 권한이다. 빠지면 권한 오류가 난다.
  • environment: github-pages에는 배포 기록이 남고, 승인자를 걸면 1절의 “지속적 전달”이 된다.
  • cancel-in-progress: false는 진행 중인 배포를 중간에 끊지 않겠다는 뜻이다.
  • Jekyll, Hugo, Quartz여도 구조는 같다. build job의 빌드 명령만 바뀐다.

9. 실제 예: 이 블로그도 이렇게 배포된다

이 노트가 올라가는 블로그는 Quartz로 만든 정적 사이트이고 GitHub Pages로 공개된다. 그런데 노트(Markdown) 저장소와 사이트 빌드 저장소가 따로 있다. 노트 저장소에는 “블로그 저장소야, 다시 빌드해”라고 신호만 보내는 workflow가 있다. 아래는 실제 파일이다(대상 저장소 소유자 이름만 <owner>로 바꿨다).

name: Trigger Blog Build
 
on:
  push:
    branches:
      - master  # 또는 main
    paths:
      - '**.md'
      - '**.png'
      - '**.jpg'
      - '**.jpeg'
      - '**.gif'
      - '**.webp'
      - '**.svg'
  workflow_dispatch:  # 수동 실행 옵션
 
jobs:
  trigger-blog-rebuild:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger blog repository rebuild
        run: |
          curl -X POST \
            -H "Accept: application/vnd.github.v3+json" \
            -H "Authorization: token ${{ secrets.GH_TOKEN }}" \
            https://api.github.com/repos/<owner>/blog/dispatches \
            -d '{"event_type":"content-updated"}'
  1. 위치. 파일은 vault의 Public/.github/workflows/에 있다. GitHub는 루트의 .github/workflows/만 읽으므로 Public/이 노트 저장소의 루트라는 뜻으로 보인다.
  2. branches와 paths. master에 Markdown이나 이미지가 바뀐 커밋이 올라올 때만 돈다. Public/_tools/의 Python 스크립트만 고친 커밋은 블로그를 다시 빌드하지 않는다. **는 “모든 하위 폴더 포함”이다.
  3. checkout이 없는 job. 코드를 볼 필요 없이 HTTP 요청 하나만 보내면 되기 때문이다.
  4. repository_dispatch 신호. curl로 GitHub API의 /dispatches에 event_type: content-updated를 보낸다. 받는 쪽 workflow는 아마 on: repository_dispatch: types: [content-updated]로 기다렸다가 Quartz 빌드와 Pages 배포(8절 구조)를 할 것이다. 받는 쪽 파일은 이 vault에 없어 직접 확인하지는 못했다.
  5. 왜 GITHUB_TOKEN이 아니라 secrets.GH_TOKEN인가. GITHUB_TOKEN은 자기 저장소 전용이다. 다른 저장소에 신호를 보내려면 그 저장소에 권한이 있는 PAT가 필요하다. 블로그 저장소 하나만 다루는 fine-grained token으로 만드는 편이 안전하다.

Authorization: token과 vnd.github.v3+json은 예전부터 쓰던 형식이다. 지금 공식 문서 예시는 Bearer와 application/vnd.github+json이지만, 이 형식도 동작한다.

[노트 저장소] .md 푸시 → Trigger Blog Build → curl POST /dispatches (PAT)
                                                     ↓
[블로그 저장소] on: repository_dispatch → Quartz 빌드 → deploy-pages → 블로그 갱신

10. 상태 배지와 브랜치 보호

README 위의 “CI passing” 그림이 상태 배지(status badge) 다. 실행 화면의 … 메뉴 Create status badge가 이 줄을 만들어 준다.

![CI](https://github.com/OWNER/REPO/actions/workflows/ci.yml/badge.svg)

CI가 빨간불이어도 머지 버튼은 기본적으로 눌린다. 막으려면 Settings의 branch protection rule이나 ruleset으로 main에 규칙을 건다.

  • Require a pull request before merging: main에 직접 푸시하지 못하고 PR을 거쳐야 한다.
  • Require status checks to pass: 지정한 job(예: lint, test (3.12))이 성공해야 머지된다. 이것이 required checks다. 그 job이 한 번은 실행돼야 목록에서 고를 수 있다.
  • matrix job은 조합마다 이름이 생긴다. 이름이 바뀌면 required check가 “대기 중”으로 영원히 남을 수 있다.

11. 디버깅

Actions 탭 → 실패한 실행 → 빨간 job → 빨간 step을 펼친다. 맨 끝의 Process completed with exit code 1.은 결과일 뿐이고, 원인은 그 위의 첫 에러 메시지다.

증상흔한 원인
No such file or directoryactions/checkout 빠짐, 또는 상대 경로 기준 폴더가 다름
ModuleNotFoundErrorrequirements.txt에 패키지가 빠짐
Python 3.1을 찾음python-version: 3.10에 따옴표를 안 씀
Resource not accessible by integrationpermissions:에 필요한 권한이 없음
secret이 빈 값fork PR이거나 이름 오타
workflow가 아예 안 뜸파일이 루트 .github/workflows/에 없음, 또는 YAML 문법 오류
내 Mac에선 되는데 CI만 실패macOS ↔ Ubuntu 차이, 파일 이름 대소문자, 난수 시드
  • 재실행: 실행 화면의 Re-run jobs로 실패한 job만 다시 돌린다. 이때 Enable debug logging을 켜면 자세한 로그가 나온다.
  • 로컬 실행: 오픈소스 도구 act가 Docker로 runner를 흉내 낸다. 공식 도구가 아니라서 실제 runner와 완전히 같지는 않다.
brew install act && act -j test --container-architecture linux/amd64   # Apple Silicon에서 test job만

12. 비용과 제한

숫자는 자주 바뀌므로 구조만 적는다. 정확한 값은 GitHub billing 문서에서 확인한다.

  • 공개 저장소는 표준 GitHub-hosted runner를 무료로 쓴다.
  • 비공개 저장소는 플랜별 월 무료 사용량(실행 시간, 저장 공간)이 있고, 넘으면 결제가 필요하다. macOS·Windows runner는 사용량을 더 빨리 소모한다.
  • self-hosted runner는 GitHub 실행 시간을 쓰지 않는다. 대신 관리와 보안이 내 몫이다.
  • job 하나의 실행 시간에는 상한이 있다(공식 문서 기준 6시간). 큰 모델 학습은 CI에서 할 일이 아니다.

13. 보안

CI는 내 저장소의 권한과 비밀을 들고 코드를 실행하는 곳이다.

  • 서드파티 action은 커밋 SHA로 고정한다. @v1 같은 태그는 주인이 나중에 다른 커밋으로 옮길 수 있다. 그 action이 해킹당하면 내 workflow가 악성 코드를 실행한다. 실제로 유명 서드파티 action의 태그가 바꿔치기되어 secret이 로그로 샌 사고가 있었다. uses: some-org/some-action@<40자리-커밋-SHA> # v2.3.1처럼 쓰고, 업데이트는 Dependabot(package-ecosystem: "github-actions")에 맡길 수 있다.
  • pull_request_target은 피한다. 이름은 비슷하지만, fork PR이어도 기준 저장소의 권한과 secrets를 가진 채로 돈다. 그 안에서 PR 코드를 checkout해 실행하면 낯선 사람이 내 secret과 쓰기 권한을 쓰게 된다. 테스트는 pull_request로 돌린다.
  • secret을 출력하지 않는다. echo ${{ secrets.X }}나 환경 변수 전체 출력을 남기지 않는다. 노출이 의심되면 즉시 재발급한다.
  • 남이 쓴 글자를 run:에 바로 넣지 않는다. PR 제목·이슈 본문은 ${{ }}로 셸 명령 안에 그대로 끼워져서, 숨겨 둔 명령이 실행될 수 있다(script injection). 환경 변수로 거쳐 넘긴다.
      - run: echo "$PR_TITLE"          # "${{ github.event.pull_request.title }}"를 직접 넣지 않기
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
  • self-hosted runner는 공개 저장소에 붙이지 않는다. 누구나 PR을 열 수 있으므로 낯선 코드가 연구실 서버에서 돌 수 있다. GitHub 문서도 비공개 저장소에서만 쓰라고 권한다(원격 GPU 서버 사용 입문).

14. 연구 코드에서의 활용

쓰임무엇을 자동화하나트리거
스모크 테스트작은 데이터·작은 모델로 학습 루프가 1 step이라도 도는지pull_request
지표 함수 테스트정확도·F1 같은 평가 함수가 손계산 값과 같은지push, pull_request
논문·프로젝트 사이트데모 페이지·문서를 GitHub Pages로 배포main push
정기 실행새 논문 목록 수집, API 기반 소규모 평가, 링크 검사schedule
수동 실험입력값을 받아 작은 평가 돌리기workflow_dispatch
  • GPU 학습은 CI에서 하지 않는다. 기본 runner에는 GPU가 없다. CI는 CPU로 몇 초 만에 끝나는 확인만 하고, 학습은 서버에서 한다(GPU와 CUDA 입문).
  • 테스트는 결정적으로. 시드를 고정하고 외부 API는 가짜 응답(mock)으로 바꾼다. 돌릴 때마다 결과가 바뀌는 테스트(flaky test)는 CI를 못 믿게 만든다.
  • Mac에서 개발하고 Ubuntu에서 검사하는 것 자체가 이득이다. macOS는 기본적으로 파일 이름 대소문자를 구분하지 않지만 Linux는 구분한다. Data/와 data/를 섞은 실수를 CI가 잡아 준다.
  • 컨테이너를 여러 서버에 배포·운영하는 다음 단계는 Kubernetes 입문에서 이어진다.

15. 아주 짧은 암기

  • CI = 푸시마다 자동 린트·테스트·빌드. 깨진 코드가 main에 들어가지 않게.
  • 지속적 전달은 사람이, 지속적 배포는 기계가 배포 버튼을 누른다.
  • workflow는 루트 .github/workflows/*.yml.
  • job은 병렬·각자 다른 컴퓨터, step은 순서대로·같은 컴퓨터. 순서는 needs:.
  • uses:는 남의 action, run:은 내 셸 명령. 첫 step은 거의 항상 actions/checkout.
  • matrix의 Python 버전은 따옴표로. cron은 UTC.
  • job 사이 전달은 artifact, 다음 실행 가속은 cache.
  • 비밀은 secrets, 권한은 permissions:로 최소만. fork PR에는 secrets가 안 간다.
  • GITHUB_TOKEN은 자기 저장소 전용. 다른 저장소는 PAT.
  • 서드파티 action은 SHA 고정, pull_request_target은 피하기.

입문자가 조심할 점

  • YAML 들여쓰기 한 칸이 workflow 전체를 망친다. 탭 대신 공백을 쓴다.
  • “CI 통과 = 코드가 맞다”가 아니다. CI는 테스트가 확인하는 것만 확인한다. 테스트가 없으면 초록불은 의미가 없다.
  • secret을 코드·로그·artifact에 남기지 않는다. .env를 artifact로 올리는 실수가 흔하다.
  • 배포 workflow는 main에서만. PR에서 배포가 돌면 검토 안 된 코드가 공개된다.
  • action 버전은 올라간다. 이 노트의 @v4, @v5는 작성 시점 기준이다. 각 action 저장소 README에서 최신 메이저 버전을 확인한다.
  • 비공개 저장소는 사용량을 본다. 큰 matrix와 macOS runner는 무료 사용량을 빨리 쓴다.
  • 예약 실행은 “대략 그 시각”이다. 마감처럼 정확한 시각이 중요한 일은 schedule에 맡기지 않는다.

출처