- 복잡한 CI를 GitHub Actions로 되돌리는 과정에서 merge queue, 여러 러너, Rust 빌드, Docker 이미지, 통합 테스트가 맞물리며 설정보다 디버깅 비용이 더 크게 드러남
main에 들어가는 변경은 모두 테스트를 통과해야 하고, 포맷·미사용 의존성·린트 같은 사소한 오류는 자동 수정되며, CI 산출물이 릴리스 산출물과 같아야 함
- merge queue 전후 검증을 모두 강제하려면 두 단계의 잡 이름을 동일하게 맞춰야 했고, 그렇지 않으면 체크가 멈추거나 실패한 변경이 병합될 수 있음
GITHUB_TOKEN, workflow permissions, 커스텀 토큰, fork·self-hosted runner 예외가 얽혀 보안 모델을 이해하기 어렵고 실수 여지가 커짐
- Docker 컨테이너 실행, YAML 워크플로, 제한적인 로컬 테스트 때문에 개발 속도가 느려졌지만, 새 CI 스크립트는 병합 시간을 크게 줄임
GitHub Actions로 돌아온 복잡한 CI
- 지난 2주 동안 CI 스크립트를 GitHub Actions로 다시 작성함
- CI 구성은 이번이 세 번째 개편임
- 처음은 GitHub Actions
- 이후 Earthly로 이동
- Earthly가 중단되어 다시 GitHub Actions로 돌아옴
- 현재 CI는 merge queue, 여러 러너, Rust 빌드, Docker 이미지, 무거운 통합 테스트를 함께 다룸
- 러너는 self-hosted, blacksmith.sh, GitHub-hosted를 함께 사용함
- PR 하나를 병합할 때 여러 병렬 러너에서 약 1시간의 CI 시간이 소모됨
CI가 만족해야 하는 조건
main에 들어가는 모든 변경은 모든 테스트를 통과해야 함
- 포맷, 미사용 의존성, 린트 문제 같은 사소한 실수는 실패로 끝나기보다 자동 수정되어야 함
- CI에서 테스트한 산출물이 실제 릴리스 산출물과 같아야 함
- 개발자 경험을 위해 CI는 빠르게 끝나야 함
- GitHub Actions는 이 조건들을 기술적으로 지원하지만, 설정 과정에는 숨은 함정과 일관성 없는 동작, 어려운 디버깅이 따라옴
Merge queue와 상태 체크
- 깨끗한
main 브랜치를 유지하는 핵심은 GitHub의 merge queue임
- merge queue는 CI 실행 전에 PR을
main 위로 rebase함
- 필요한 CI 실행은 두 단계로 나뉨
- 큐에 들어가기 전에 CI를 실행해 사소한 문제를 자동 수정함
- 큐 안에서 다시 CI를 실행해 최종 병합 상태를 검증함
- GitHub Actions에서 두 실행을 모두 필수로 만들려면 두 단계의 잡 이름을 동일하게 지정해야 함
- 다른 방식은 상태 체크가 큐 진입 전에 대기되어 잡이 시작되지 않거나, merge queue 안에서 실패해야 할 잡이 실패해도 병합되는 결과로 이어질 수 있음
이해하기 어려운 GitHub Actions 보안 모델
- 최근 인기 GitHub Action이 침해된 사건 이후 “의존성을 해시에 고정하라”는 대응이 나왔지만, 댓글에서는 거의 아무도 그렇게 하지 않는다는 반응이 이어짐
- GitHub Actions에는 기본 토큰인
GITHUB_TOKEN이 있음
- 이 토큰은 기본 권한으로 초기화됨
- 기본 권한은 저장소 설정의 Actions → General → Workflow Permissions에서 설정할 수 있음
GITHUB_TOKEN 기본 권한이 제한적이면 필요한 action과 명령 실행을 위해 권한을 올려야 하고, 기본 권한이 허용적이면 워크플로 파일에서 일부 권한을 제거할 수 있음
- 더 나은 기본값은 권한 없음에서 시작해 필요한 권한만 사용자가 추가하는 방식임
- 권한 종류는 많고, GitHub 전문가가 아니면 각각이 무엇을 보호하는지 파악하기 어려움
토큰과 권한 예외
softprops/action-gh-release로 GitHub 릴리스를 자동 생성할 때 커스텀 토큰 CI_RELEASE를 사용함
- name: Release on GitHub
if: env.version_exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ env.CURRENT_VERSION }}
generate_release_notes: true
make_latest: true
token: ${{ secrets.CI_RELEASE }}
- 기본 토큰으로도 릴리스 자체는 완료되지만, 릴리스 이후 워크플로가 트리거되지 않음
- 별도 표시가 없어 같은 문제를 겪은 사람이 남긴 이슈를 찾아야 원인을 알 수 있음
- 워크플로 YAML 안에서 권한을 올릴 수도 있음
- 보호하려는 코드 안에서 권한을 올리는 구조가 낯설게 느껴짐
- GitHub 문서에 따르면
permissions 키로 fork 저장소의 읽기 권한을 추가·제거할 수 있지만, 보통 쓰기 권한은 부여할 수 없음
- 예외는 관리자가 GitHub Actions 설정에서 Send write tokens to workflows from pull requests 옵션을 선택한 경우임
- 예외와 함정이 많아 GitHub Actions 보안 모델은 강력하면서도 공격 표면과 실수 가능성을 함께 키움
Self-hosted runner의 불확실성
- GitHub 문서는 공개 저장소에서 self-hosted runner 사용을 권장하지 않음
- 공개 저장소의 fork가 PR을 통해 self-hosted runner 머신에서 위험한 코드를 실행할 수 있기 때문임
- GitHub에는 외부 기여자의 PR 실행을 승인하도록 요구하는 self-hosted runner 설정도 있음
- 이 설정과 self-hosted runner를 함께 쓰면 안전한지에 대한 명확한 답은 문서에 없고, 인터넷에서도 합의가 없음
- 복잡도가 높아 100% 확신하기 어려운 상태로 남아 있음
Docker와 GitHub Actions의 충돌
- GitHub Actions는 잡을 컨테이너 안에서 실행할 수 있음
- 매번 의존성을 설치하지 않고 dev container에 미리 패키징할 수 있다는 장점이 있음
- 실제 사용에서는 파일 권한 문제가 반복됨
- 컨테이너가 한 사용자로 파일을 빌드하지만 GitHub runner는 다른 uid/gid로 실행할 수 있음
- 그 결과 컨테이너 내부 파일이나 GitHub workspace, 임시 호스트 디렉터리에 접근하지 못할 수 있음
$HOME 디렉터리도 어긋날 수 있음
- dev container는
/home/ubuntu에 도구를 설치할 수 있음
- GitHub Actions 안에서는
$HOME이 /github/home이 될 수 있음
$HOME 아래 파일에 의존하는 도구는 필요한 파일을 찾지 못할 수 있음
- 호스트 시스템과 상호작용하는 action도 깨질 수 있음
- GitHub 캐시는 10GB로 제한되어 blacksmith의 sticky disk action으로 캐싱용 NVMe 드라이브를 마운트함
- 컨테이너 안에서는 동작하지 않았고, blacksmith.sh의 수정 이후 해결됨
container 필드 자체에도 제한이 있음
- entrypoint를 override할 수 없음
- 일부 step만 컨테이너 안에서 실행하고 나머지는 밖에서 실행하는 방식도 불가능함
YAML 워크플로 개발과 디버깅
- GitHub Actions 로직은 YAML로 작성되며, 복잡해질수록 실수하기 쉬움
- RustRover의 GitHub YAML linter 검사가 도움이 되었지만, 더 나은 정적 검사가 필요함
- 로컬에서는 실제 CI 동작을 충분히 시험하기 어려움
- act는 알려진 도구지만 CI에서 하려는 작업 중 작은 부분만 지원함
- 가장 나은 디버깅 방식은 동일한 저장소를 하나 더 만들고, CI가 기대대로 동작할 때까지
git commit -a -m "wip" && git push test-ci branch를 반복하는 것이었음
워크플로 분리와 산출물 재사용
- 매번 전체 CI 파이프라인을 돌리지 않기 위해 개별 workflow를 작게 유지함
- 각 workflow 끝에서 산출물을 업로드하고, 이후 workflow가 이를 다운로드해 처음부터 다시 빌드하지 않도록 구성함
- 이전 실행의 산출물을 내려받아 workflow를 격리 테스트할 수 있음
- 다만 이전 실행에서 내려받을 때
download-artifact action에 토큰을 제공해야 함
- 이 토큰은 기본 토큰이어도 되지만, 왜 여전히 명시해야 하는지는 해결되지 않은 의문으로 남음
- 메인 workflow 파일은 다른 YAML 파일을 호출하는 체인이 됨
jobs:
invoke-build-rust:
name: Build Rust
uses: ./.github/workflows/build-rust.yml
invoke-build-java:
name: Build Java
uses: ./.github/workflows/build-java.yml
invoke-tests-unit:
name: Unit Tests
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/test-unit.yml
invoke-tests-adapter:
name: Adapter Tests
needs: [invoke-build-rust]
uses: ./.github/workflows/test-adapters.yml
secrets: inherit
invoke-build-docker:
name: Build Docker
needs: [invoke-build-rust, invoke-build-java]
uses: ./.github/workflows/build-docker.yml
invoke-tests-integration:
name: Integration Tests
needs: [invoke-build-docker]
uses: ./.github/workflows/test-integration.yml
invoke-tests-java:
name: Java Tests
needs: [invoke-build-java]
uses: ./.github/workflows/test-java.yml
- 일부 잡에는
secrets: inherit가 필요함
- workflow가 다른 workflow를 호출할 때 secret은 기본적으로 공유되지 않음
- 전체 CI 파이프라인에서는 실패하지만 개별 step 실행에서는 동작하는 문제의 원인이었음
빨라진 병합, 여전히 비싼 디버깅
- 새 CI 스크립트는 병합 시간을 크게 줄였고 결과에는 만족함
- 다만 그 상태에 도달하기까지 너무 많은 시간이 들었고, 문제가 생겼을 때 디버깅이 더 쉬워져야 함