Published on

Git 서브모듈로 CI 깨질 때 권한·핀 고정 처방

Authors

서브모듈은 로컬에서는 잘 되는데 CI에서만 갑자기 깨지는 대표적인 지뢰입니다. 이유는 단순합니다. 로컬은 이미 인증이 되어 있고(SSH 에이전트, 키체인, 토큰 캐시), 서브모듈 디렉터리에 이전에 받아둔 오브젝트가 남아있기도 합니다. 반면 CI는 매번 깨끗한 러너에서 시작하고, 인증 경로가 조금만 어긋나도 git submodule update 단계에서 바로 실패합니다.

이 글에서는 CI에서 서브모듈 때문에 터지는 케이스를 크게 두 축으로 나눠 다룹니다.

  • 권한/인증 문제: 서브모듈 저장소에 접근할 토큰/키가 없다
  • 핀 고정(pinning) 문제: 서브모듈이 가리키는 커밋이 변하거나(강제 푸시), 브랜치를 추적하도록 설정되어 재현성이 깨진다

그리고 GitHub Actions 기준으로, 재현 가능한 빌드(Deterministic build)를 만들기 위한 설정과 운영 규칙까지 정리합니다.

CI에서 서브모듈이 깨지는 전형적인 증상

1) repository not found 또는 Permission denied

가장 흔한 형태입니다.

  • 서브모듈이 프라이빗인데 CI 토큰이 접근 권한이 없음
  • .gitmodules 에 SSH URL이 들어있는데 CI는 HTTPS 토큰 기반으로만 체크아웃함
  • 반대로 HTTPS URL인데 사내 정책상 SSH로만 접근 가능

에러는 대개 아래처럼 나옵니다.

  • fatal: repository '...' not found
  • fatal: could not read Username for 'https://github.com': No such device or address
  • Permission denied (publickey)

2) reference is not a tree / 특정 커밋을 못 찾음

서브모듈은 “부모 레포가 특정 커밋 해시를 가리키는” 구조입니다. 따라서 서브모듈 저장소에서 해당 커밋이 사라지면(강제 푸시, 히스토리 재작성, GC로 오브젝트 유실, 잘못된 미러링) CI는 재현 불가능해집니다.

  • fatal: reference is not a tree: <sha> 같은 형태가 대표적입니다. 여기서 <sha> 같은 부등호 표기는 MDX에서 JSX로 오인될 수 있으니 문서에는 &lt;sha&gt; 또는 인라인 코드로 써야 합니다.

3) --remote 사용으로 빌드가 매번 달라짐

git submodule update --remote 는 서브모듈이 추적하는 브랜치의 최신 커밋을 가져옵니다. CI에서 이 옵션을 쓰면, 같은 부모 커밋으로도 서브모듈이 바뀌어 빌드가 달라질 수 있습니다.

  • 오늘은 되는데 내일은 깨짐
  • 태그/릴리스가 재현되지 않음

이건 “권한”이 아니라 “핀 고정 실패”에 가깝습니다.

원인 1: 권한/인증 설계가 CI와 맞지 않음

서브모듈이 프라이빗이면, 메인 레포를 체크아웃하는 토큰이 서브모듈까지 접근 가능해야 합니다. GitHub Actions에서는 기본 GITHUB_TOKEN 이 레포 단위로 권한이 제한될 수 있고, 조직 정책에 따라 다른 프라이빗 레포에는 접근이 막힙니다.

체크리스트

  • 서브모듈이 같은 조직/같은 엔터프라이즈에 있는가
  • Actions 실행 주체(토큰)가 서브모듈 레포에 read 권한이 있는가
  • .gitmodules 의 URL 스킴(SSH/HTTPS)이 CI 인증 방식과 맞는가

권장 패턴 A: HTTPS + PAT(또는 GitHub App)로 읽기 전용

가장 단순하고 운영 난이도가 낮습니다.

  • PAT는 최소 권한(서브모듈 레포 read)만 부여
  • Actions secret으로 저장
  • 체크아웃 단계에서 서브모듈까지 포함
name: ci
on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout (with submodules)
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0
          token: ${{ secrets.SUBMODULE_READ_TOKEN }}

      - name: Verify submodule status
        run: |
          git submodule status --recursive

포인트는 actions/checkouttoken 을 명시하는 것입니다. 기본 GITHUB_TOKEN 으로는 다른 프라이빗 레포 서브모듈을 못 읽는 경우가 많습니다.

권장 패턴 B: SSH Deploy Key로 서브모듈 접근

서브모듈 레포에 Deploy Key를 등록하고, CI에서 SSH 키를 주입합니다.

- name: Setup SSH for submodules
  run: |
    mkdir -p ~/.ssh
    echo "${{ secrets.SUBMODULE_SSH_KEY }}" > ~/.ssh/id_rsa
    chmod 600 ~/.ssh/id_rsa
    ssh-keyscan -t rsa github.com >> ~/.ssh/known_hosts

- name: Checkout
  uses: actions/checkout@v4
  with:
    submodules: recursive
    fetch-depth: 0

주의할 점:

  • .gitmodules 가 SSH URL(예: git@github.com:org/repo.git)이어야 합니다.
  • 여러 서브모듈이 있고 레포마다 키가 다르면 관리가 복잡해집니다.

OIDC로 해결할 수 있나?

OIDC는 주로 클라우드(AWS/GCP/Azure) 권한 위임에 강점이 있고, Git 서브모듈 접근 자체는 GitHub 레포 권한 문제입니다. 다만 “CI가 어떤 자격으로 무엇에 접근하는가”를 정리하는 관점에서 OIDC 기반 권한 설계가 필요한 팀도 많습니다. 관련해서는 GitHub Actions OIDC 401 권한 오류 해결 가이드도 함께 참고하면 좋습니다.

원인 2: 핀 고정(pinning)이 안 되어 재현성이 깨짐

서브모듈은 원래 “부모 레포가 서브모듈 커밋을 고정”하는 구조라서, 정상 운영이면 재현성이 좋습니다. 그런데 아래 중 하나라도 해당하면 핀 고정이 무너집니다.

  • CI에서 git submodule update --remote 를 사용
  • 서브모듈 레포에서 강제 푸시로 커밋이 사라짐
  • 브랜치 기반으로만 의존성을 관리하고 태그/릴리스 규칙이 없음

절대 피해야 할 CI 패턴: --remote

예를 들어 아래는 “빌드 때마다 최신을 땡겨오는” 설정이라 재현성이 떨어집니다.

git submodule update --init --recursive --remote

이 옵션은 “부모 레포가 고정한 커밋”을 무시하고, 서브모듈이 추적하는 브랜치의 최신으로 이동시킬 수 있습니다.

권장하는 방식은 아래입니다.

git submodule update --init --recursive

그리고 서브모듈 버전 업데이트가 필요하면, 개발자가 로컬에서 서브모듈 커밋을 업데이트한 뒤 부모 레포에 그 변경을 커밋으로 남겨야 합니다.

서브모듈 커밋 업데이트 절차(운영 규칙)

  1. 서브모듈 디렉터리에서 원하는 커밋(또는 태그)로 이동
  2. 부모 레포에서 서브모듈 포인터 변경을 커밋
  3. PR 리뷰 시 서브모듈 변경 커밋이 의도한 것인지 확인
# 1) 서브모듈 초기화
git submodule update --init --recursive

# 2) 서브모듈로 이동 후 태그로 고정
cd path/to/submodule
git fetch --tags
git checkout v1.4.2

# 3) 부모 레포에서 포인터 변경 커밋
cd ../..
git add path/to/submodule
git commit -m "chore: bump submodule to v1.4.2"

팁:

  • 서브모듈 레포는 태그를 “불변(immutable)”로 운영하세요. 태그를 재지정하면 결국 브랜치 추적과 다를 바 없어집니다.
  • 강제 푸시가 필요한 레포라면, 서브모듈로 쓰기엔 구조적으로 부적합합니다.

reference is not a tree 를 만났을 때 즉시 확인할 것

  • 서브모듈 레포에서 해당 커밋이 실제로 존재하는가
  • 미러/프록시를 쓰는 경우 미러가 오브젝트를 제대로 보존하는가
  • 히스토리 재작성(rewrite) 정책이 있는가

만약 실수로 rebase/force push 후 커밋을 잃었다면, 복구는 reflog 가 마지막 희망인 경우가 많습니다. 관련 내용은 Git rebase 후 히스토리 꼬임 복구 - reflog를 참고하세요.

GitHub Actions에서 안정적으로 서브모듈 체크아웃하기

여기서는 “권한 문제 없이, 핀 고정이 유지되며, 디버깅이 쉬운” 형태를 목표로 합니다.

기본형 워크플로 예시

name: build
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository with submodules
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          submodules: recursive
          token: ${{ secrets.SUBMODULE_READ_TOKEN }}

      - name: Print submodule SHAs (for debugging)
        run: |
          git rev-parse HEAD
          git submodule status --recursive
          git config --file .gitmodules --get-regexp url || true

      - name: Ensure submodules are pinned (no remote updates)
        run: |
          # CI에서 --remote 같은 업데이트를 하지 않음을 보장
          git submodule update --init --recursive

      - name: Build
        run: |
          ./ci/build.sh

핵심은 다음입니다.

  • fetch-depth: 0 : 얕은 클론은 서브모듈/태그/특정 커밋 조회에서 예상치 못한 문제를 만들 수 있습니다.
  • submodules: recursive : 중첩 서브모듈까지 포함해야 하는 프로젝트가 많습니다.
  • token 명시: 서브모듈이 프라이빗이면 필수에 가깝습니다.
  • 디버깅 출력: 실패 시 어떤 URL/어떤 SHA에서 터졌는지 로그만으로 재현 가능하게 합니다.

.gitmodules URL 스킴 통일하기(HTTPS 권장)

팀/CI 표준이 HTTPS 토큰 기반이라면 .gitmodules 도 HTTPS로 통일하는 게 사고가 적습니다.

[submodule "libs/foo"]
  path = libs/foo
  url = https://github.com/acme/foo.git

이미 SSH로 박혀있다면 일괄 변경 후 커밋하세요.

git config -f .gitmodules submodule.libs/foo.url https://github.com/acme/foo.git
git add .gitmodules
git commit -m "chore: switch submodule URL to https"

보안 관점: 토큰 권한은 최소화하고 노출을 막기

서브모듈 읽기 토큰은 아래 원칙을 추천합니다.

  • 최소 권한: 필요한 레포에 read
  • 범위 최소화: 조직 전체 권한 토큰 지양
  • 로그 노출 방지: set -x 같은 쉘 디버그 옵션으로 토큰이 찍히지 않도록 주의

또한 PR 빌드에서 포크(fork)로부터 온 워크플로는 시크릿이 주입되지 않는 것이 일반적입니다. 이 경우 “포크 PR에서는 서브모듈을 가져올 수 없어 실패”가 정상 동작일 수 있습니다. 해결책은 정책 선택입니다.

  • 포크 PR은 서브모듈이 필요 없는 최소 테스트만 수행
  • 또는 서브모듈을 퍼블릭으로 전환/미러 제공
  • 또는 pull_request_target 를 쓰되, 체크아웃/스크립트 실행 순서와 신뢰 경계를 매우 엄격히 설계

팀 운영 팁: CI를 깨는 서브모듈 변경을 예방하는 규칙

1) 서브모듈 변경을 감지하는 PR 체크

서브모듈 포인터 변경은 일반 코드 변경보다 영향이 큽니다. PR에서 아래를 자동 체크하면 좋습니다.

  • 서브모듈 디렉터리의 gitlink 변경 여부
  • 변경된 서브모듈 커밋이 태그/릴리스 커밋인지

간단히는 CI에서 git diff --submodule 로 확인할 수 있습니다.

git diff --submodule=log origin/main...HEAD

2) 서브모듈 레포는 히스토리 불변 정책

서브모듈 레포에서 rebase/force push를 허용하면, 언젠가 CI가 “사라진 커밋”을 찾다가 터집니다.

  • 기본 브랜치 보호
  • 강제 푸시 금지
  • 태그 재지정 금지

3) 서브모듈 대신 대안을 검토할 타이밍

서브모듈은 “레포 간 결합을 느슨하게” 만들기도 하지만, 반대로 “권한/배포/릴리스 체계”가 성숙하지 않으면 CI 불안정의 원인이 됩니다.

아래가 자주 반복되면 대안을 고려하세요.

  • 프라이빗 서브모듈 권한 이슈가 계속 발생
  • 여러 서브모듈을 조합한 빌드가 복잡
  • 릴리스 버전 고정이 어렵고 재현성이 중요

대안 예:

  • 패키지 레지스트리(npm, PyPI, Maven 등)로 배포하고 버전 의존성으로 관리
  • 모노레포로 통합
  • Git subtree(권한은 단순해지지만 히스토리/동기화 전략이 필요)

자주 쓰는 트러블슈팅 커맨드 모음

CI 로그에서 빠르게 원인을 좁히는 데 도움이 됩니다.

# 서브모듈 상태(현재 포인터 SHA) 확인
git submodule status --recursive

# .gitmodules에 정의된 URL 확인
git config --file .gitmodules --get-regexp url

# 서브모듈을 깨끗하게 다시 동기화
git submodule sync --recursive
git submodule update --init --recursive --force

# 특정 서브모듈만 갱신/재시도
git submodule update --init --recursive path/to/submodule

sync.gitmodules 를 바꿨는데 CI가 예전 URL을 계속 물고 있는 상황에서 특히 유용합니다.

마무리: CI 안정화의 핵심은 “권한 정리 + 커밋 불변성”

서브모듈로 CI가 깨질 때 대부분은 두 가지 중 하나입니다.

  • CI가 서브모듈에 접근할 자격 증명이 없다(권한 문제)
  • 서브모듈이 가리키는 커밋이 더 이상 재현 불가능하다(핀 고정 붕괴)

해결은 의외로 정석적입니다.

  • CI에서 서브모듈을 체크아웃할 수 있는 읽기 전용 자격을 명확히 제공
  • .gitmodules 의 URL 스킴을 CI 인증 방식과 통일
  • CI에서 --remote 같은 “가변 업데이트”를 제거
  • 서브모듈 레포는 강제 푸시/태그 재지정을 금지해 커밋 불변성을 지키기

이 원칙만 지켜도 “로컬에서는 되는데 CI에서만 깨지는” 서브모듈 이슈의 대부분은 사라집니다.