콘텐츠로 건너뛰기
Codex

소스는 비공개로 두고 다운로드만 공개하는 저장소 구조

TECH NOTE · AUTOMATION OPS

소스는 비공개로 두고 배포만 공개하려면 저장소를 나눕니다. Pages 카탈로그와 Releases ZIP을 함께 쓰는 구조입니다.

코드는 못 열고 배포는 해야 한다

만들던 데스크톱 앱을 사람들이 받아 볼 수 있게 해야 했다. 그런데 소스는 아직 공개할 상태가 아니었다.

요구가 셋이었다.

  • 앱을 누구나 내려받을 수 있어야 한다
  • 소스 저장소는 비공개로 유지한다
  • 앱이 “새 버전 있음”을 스스로 알 수 있어야 한다

세 번째 때문에 단순한 파일 호스팅으로는 부족했다. 앱이 읽을 수 있는 버전 정보가 어딘가 공개돼 있어야 한다.

저장소를 둘로 쪼갠다

답은 저장소를 나누는 것이었다.

저장소 공개 역할
pet 비공개 소스 코드
pet-releases 공개 릴리스 ZIP + 다운로드 카탈로그

흐름은 이렇다.

앱 ──fetch──▶ https://bluecafe.github.io/pet-releases/download/latest.json
                downloads.*.url ──▶
              https://github.com/bluecafe/pet-releases/releases/download/vX.Y.Z/*.zip

앱은 GitHub Pages에 올라간 JSON 하나만 읽는다. 그 안에 실제 파일 주소가 들어 있다.

Pages와 Releases를 나눠 쓰는 게 요점이다. Pages는 작은 JSON을 자주 갱신하기 좋고, Releases는 수십 MB짜리 ZIP을 올리기 좋다. 둘 다 공개 저장소에 딸려 오는 기능이라 별도 호스팅이 필요 없다.

공개 저장소의 Pages 설정은 이게 전부다.

Source: Deploy from a branch
Branch: main
Folder: /docs

앱은 주소 하나만 안다

앱 쪽에는 기본 URL 하나만 박는다.

Catalog:   https://bluecafe.github.io/pet-releases/download/
Manifest:  https://bluecafe.github.io/pet-releases/download/latest.json
Release:   https://github.com/bluecafe/pet-releases/releases/tag/v0.1.0
Windows:   …/releases/download/v0.1.0/DesktopAIPet-windows-x64.zip
macOS:     …/releases/download/v0.1.0/DesktopAIPet-macos-arm64.zip

환경변수로 덮어쓸 수 있게 해 뒀다.

PET_UPDATE_URL 미설정 시 기본값:
  https://bluecafe.github.io/pet-releases/download/

이게 있어야 로컬에서 카탈로그를 띄워 놓고 테스트할 수 있다. 실제 배포 주소를 바라보게 두면 개발 중에 진짜 업데이트 안내가 뜬다.

배포는 비공개 쪽에서 명령 하나로

두 저장소를 손으로 왔다 갔다 하면 실수가 난다. ZIP은 올렸는데 카탈로그를 안 고치거나, 반대이거나.

그래서 비공개 저장소의 스크립트가 양쪽을 다 처리한다.

# 형제 폴더로 두는 걸 권장
#   Projects/pet           (비공개 소스)
#   Projects/pet-releases  (공개 카탈로그)

cd apps/desktop-tauri
npm run release:preflight
# 빌드…
npm run release:publish-download -- \
  --windows-zip ../../dist-windows/DesktopAIPet-windows-x64.zip \
  --macos-zip ../../dist-macos/DesktopAIPet-macos-arm64.zip \
  --upload-release \
  --push-catalog
  • --upload-release → 공개 저장소 태그에 ZIP 업로드
  • --push-catalog → 형제 폴더의 카탈로그를 커밋·푸시

플래그를 나눈 이유는 부분 실행이 필요해서다. ZIP은 이미 올렸는데 카탈로그 문구만 고치는 경우가 흔하다.

체크섬을 같이 싣는다

카탈로그에 파일 주소만 넣으면 받는 쪽이 무결성을 확인할 방법이 없다. 그래서 발행 시점에 해시를 계산해 함께 기록한다.

function sha256File(path) {
  const hash = createHash("sha256");
  hash.update(readFileSync(path));
  return hash.digest("hex");
}

const sha256 = sha256File(abs);
console.log(
  `registered ${key}: ${url} (${sizeBytes} bytes, sha256=${sha256.slice(0, 12)}…)`,
);

크기와 해시가 카탈로그에 들어가면 두 가지가 된다. 받는 사람이 검증할 수 있고, 다음 배포 때 파일이 실제로 바뀌었는지 스크립트가 안다.

로그에 해시 앞 12자만 찍는 것도 의도다. 전체를 찍으면 줄이 길어져 다른 출력이 안 보인다. 사람이 눈으로 비교하는 용도로는 12자면 충분하다.

미서명 빌드의 함정

공개 배포를 하면 곧바로 마주치는 문제가 있다. 서명하지 않은 macOS 앱은 인터넷에서 받으면 “손상되었습니다” 로 뜬다.

실제로 손상된 게 아니라 격리 속성이 붙은 것이다.

xattr -cr ~/Downloads/DesktopAIPet.app && open ~/Downloads/DesktopAIPet.app

여기서 한 가지 알아 둘 게 있다. 앱을 압축 폴더 밖으로 옮길 필요가 없다. 처음에는 안내에 “다른 폴더로 옮긴 뒤”라고 적었는데, 실제로는 압축 푼 자리의 .app 경로에 그대로 걸면 된다.

xattr -cr ~/Downloads/DesktopAIPet-macos-arm64/DesktopAIPet.app

우클릭 후 열기만으로는 부족한 경우도 있다. 다운로드 페이지에 이 명령을 그대로 적어 두는 편이 낫다.

이 구조의 값과 한계

값어치는 분리 자체다. 소스 공개 여부와 배포 공개 여부가 독립적이 된다. 나중에 소스를 열어도 배포 구조는 그대로 두면 되고, 반대로 소스를 계속 닫아 둬도 배포는 막힘없이 돈다.

한계도 분명하다.

  • 저장소가 둘이라 두 곳이 어긋날 수 있다. 스크립트로 묶는 게 필수다
  • 공개 저장소의 커밋 이력에 배포 흔적이 남는다. 버전 번호와 시점이 드러난다
  • GitHub Pages는 정적이라 다운로드 수 같은 통계가 안 잡힌다
  • 파일이 아주 크면 Releases 용량 정책을 확인해야 한다

세 번째가 필요하면 Releases의 다운로드 카운트 API를 쓰거나, 별도 리다이렉트를 두게 된다. 그 시점에는 이 구조가 더 이상 단순하지 않다.

정리

  • 비공개 소스와 공개 배포는 저장소를 나누면 같이 성립한다
  • 공개 저장소에서 Pages는 작은 카탈로그(JSON), Releases는 큰 파일(ZIP)을 맡는다
  • 앱에는 카탈로그 주소 하나만 넣고 환경변수로 덮어쓸 수 있게 둔다. 로컬 테스트에 필요하다
  • 배포 스크립트가 양쪽을 함께 갱신한다. 나눠서 하면 어긋난다
  • 플래그로 부분 실행을 허용한다. 카탈로그만 고치는 경우가 흔하다
  • 발행 시점에 크기와 SHA-256을 카탈로그에 싣는다
  • 미서명 macOS 앱은 격리 속성 때문에 “손상됨”으로 뜬다. 압축 폴더 밖으로 옮길 필요는 없다
  • 한계: 두 저장소가 어긋날 수 있고, 배포 시점이 공개되고, 통계가 안 잡힌다

별도 서버 없이 저장소 기능만으로 되는 게 이 구조의 전부다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다