Rust CLI 배포 자동화: Homebrew, Scoop, Shell Installer를 GitHub Actions로 한 번에 구축하기

Rust CLI 도구를 태그 하나로 6개 플랫폼에 자동 배포하는 방법. Homebrew tap, Scoop bucket, Shell installer를 GitHub Actions로 구축한 실전 경험을 공유합니다.

1. 문제 상황

Rust로 만든 CLI 도구의 v0.1.0 릴리스를 완료했습니다. 하지만 설치 방법이 두 가지뿐이었습니다:

# 방법 1: 소스 빌드 (Rust 툴체인 필요)
cargo install clavis

# 방법 2: GitHub Release에서 수동 다운로드
# → 바이너리 찾기 → 다운로드 → 압축 해제 → PATH에 추가...

사용자 입장에서 cargo install은 Rust가 설치되어 있어야 하고, 수동 다운로드는 번거롭습니다. 이상적인 설치 경험은 이렇습니다:

# macOS / Linux — 한 줄이면 끝
brew install uppinote20/tap/clavis

# 또는 curl 한 줄
curl -sSf https://example.com/install.sh | bash

# Windows — Scoop
scoop install clavis

이 글에서는 6개 플랫폼 크로스 컴파일 → 3개 패키지 매니저 자동 배포까지의 전체 파이프라인 구축 과정을 다룹니다.

2. 전체 아키텍처

최종 파이프라인은 다음과 같은 구조입니다:

git tag v0.2.0 && git push --tags
        │
        ▼
┌─ GitHub Actions Release Workflow ──────────────────┐
│                                                     │
│  build (6 targets in parallel)                      │
│  ├── aarch64-apple-darwin      (macOS ARM)    .tar.gz│
│  ├── x86_64-apple-darwin       (macOS Intel)  .tar.gz│
│  ├── x86_64-unknown-linux-gnu  (Linux x64)    .tar.gz│
│  ├── x86_64-unknown-linux-musl (Linux static) .tar.gz│
│  ├── x86_64-pc-windows-msvc    (Windows x64)  .zip  │
│  └── aarch64-pc-windows-msvc   (Windows ARM)  .zip  │
│                                                     │
│  release                                            │
│  └── GitHub Release에 12개 아티팩트 업로드           │
│                                                     │
│  update-homebrew                                    │
│  └── homebrew-tap 리포에 formula 자동 업데이트       │
└─────────────────────────────────────────────────────┘
        │
        ▼
  brew install / curl install.sh / scoop install

핵심 설계 원칙은 "태그 하나 push하면 모든 채널에 자동 배포" 입니다.

3. Release Workflow 구축

3.1 Cross-Compile Matrix 설계

GitHub Actions의 matrix strategy를 사용해 6개 타겟을 병렬 빌드합니다. Unix와 Windows의 패키징 형식이 다르기 때문에 archive 필드로 분기하는 것이 핵심입니다.

# .github/workflows/release.yml
name: Release

on:
  push:
    tags: ["v*"]

permissions:
  contents: write

env:
  BINARY_NAME: clavis

jobs:
  build:
    strategy:
      matrix:
        include:
          # macOS
          - target: aarch64-apple-darwin
            os: macos-latest
            archive: tar.gz        # ← Unix는 tar.gz
          - target: x86_64-apple-darwin
            os: macos-latest
            archive: tar.gz

          # Linux
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
            archive: tar.gz
          - target: x86_64-unknown-linux-musl
            os: ubuntu-latest
            archive: tar.gz

          # Windows
          - target: x86_64-pc-windows-msvc
            os: windows-latest
            archive: zip           # ← Windows는 zip
          - target: aarch64-pc-windows-msvc
            os: windows-latest
            archive: zip

    runs-on: ${{ matrix.os }}

archive 필드를 matrix에 포함시킨 이유는 패키징 단계에서 조건 분기를 깔끔하게 하기 위해서입니다. if: contains(matrix.target, 'windows') 같은 문자열 매칭보다 명시적이고 확장하기 쉽습니다.

3.2 플랫폼별 패키징

Unix와 Windows의 패키징은 완전히 다릅니다:

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Build
        run: cargo build --release --target ${{ matrix.target }}

      # Unix: tar + shasum
      - name: Package (Unix)
        if: matrix.archive == 'tar.gz'
        run: |
          cd target/${{ matrix.target }}/release
          tar czf ../../../${{ env.BINARY_NAME }}-${{ matrix.target }}.tar.gz \
            ${{ env.BINARY_NAME }}
          cd ../../..
          shasum -a 256 ${{ env.BINARY_NAME }}-${{ matrix.target }}.tar.gz \
            > ${{ env.BINARY_NAME }}-${{ matrix.target }}.tar.gz.sha256

      # Windows: PowerShell Compress-Archive + Get-FileHash
      - name: Package (Windows)
        if: matrix.archive == 'zip'
        shell: pwsh                # ← PowerShell 명시
        run: |
          Compress-Archive `
            -Path "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" `
            -DestinationPath "${{ env.BINARY_NAME }}-${{ matrix.target }}.zip"
          $hash = (Get-FileHash "${{ env.BINARY_NAME }}-${{ matrix.target }}.zip" `
            -Algorithm SHA256).Hash.ToLower()
          "$hash  ${{ env.BINARY_NAME }}-${{ matrix.target }}.zip" `
            | Out-File -Encoding ascii `
              "${{ env.BINARY_NAME }}-${{ matrix.target }}.zip.sha256"

여기서 주의할 점이 있습니다:

항목 Unix Windows
아카이브 형식 .tar.gz .zip
체크섬 도구 shasum -a 256 Get-FileHash -Algorithm SHA256
바이너리명 clavis clavis.exe
bash (기본) shell: pwsh 명시 필요

Windows의 Get-FileHash는 해시를 대문자로 반환하므로 .ToLower()를 호출해서 Unix의 shasum 출력과 형식을 맞춰야 합니다.

3.3 아티팩트 업로드 통합

matrix의 archive 필드 덕분에 아티팩트 경로를 동적으로 구성할 수 있습니다:

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ env.BINARY_NAME }}-${{ matrix.target }}
          path: |
            ${{ env.BINARY_NAME }}-${{ matrix.target }}.${{ matrix.archive }}
            ${{ env.BINARY_NAME }}-${{ matrix.target }}.${{ matrix.archive }}.sha256

${{ matrix.archive }}tar.gz 또는 zip으로 치환되므로, Unix/Windows 모두 하나의 step으로 처리됩니다.

4. Homebrew Tap 자동화

4.1 Formula 템플릿 설계

Homebrew formula는 플레이스홀더가 포함된 템플릿으로 관리합니다. 릴리스할 때마다 SHA256 해시를 자동으로 치환합니다.

# dist/homebrew/clavis.rb — 템플릿
class Clavis < Formula
  desc "Claude Code system administration tool — TUI & Web"
  homepage "https://github.com/uppinote20/clavis"
  version "{{VERSION}}"              # ← 릴리스 시 치환
  license "MIT"

  on_macos do
    if Hardware::CPU.arm?
      url "https://github.com/uppinote20/clavis/releases/download/v{{VERSION}}/clavis-aarch64-apple-darwin.tar.gz"
      sha256 "{{SHA256_AARCH64_APPLE_DARWIN}}"   # ← 자동 치환
    else
      url "https://github.com/uppinote20/clavis/releases/download/v{{VERSION}}/clavis-x86_64-apple-darwin.tar.gz"
      sha256 "{{SHA256_X86_64_APPLE_DARWIN}}"
    end
  end

  on_linux do
    url "https://github.com/uppinote20/clavis/releases/download/v{{VERSION}}/clavis-x86_64-unknown-linux-gnu.tar.gz"
    sha256 "{{SHA256_X86_64_UNKNOWN_LINUX_GNU}}"
  end

  def install
    bin.install "clavis"              # ← pre-built 바이너리 설치
  end

  test do
    assert_match version.to_s, shell_output("#{bin}/clavis --version")
  end
end

일반적인 Homebrew formula는 소스를 다운로드해서 빌드하지만, 여기서는 pre-built 바이너리를 직접 설치합니다. Rust 컴파일에 수 분이 걸리기 때문에 사용자 경험이 훨씬 좋습니다.

4.2 자동 업데이트 Job — OAuth 토큰 함정

처음에는 직관적으로 git clone → git push 방식을 사용했습니다:

# ❌ 실패하는 접근법
- name: Update Homebrew tap
  env:
    TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
  run: |
    git clone "https://x-access-token:${TAP_TOKEN}@github.com/user/homebrew-tap.git" tap
    cp clavis.rb tap/Formula/clavis.rb
    cd tap && git commit -am "update" && git push

이 방식은 GitHub의 gho_* OAuth 토큰에서 반드시 실패합니다:

remote: Invalid username or token.
Password authentication is not supported for Git operations.
fatal: Authentication failed

원인: gh auth login으로 받는 OAuth 토큰(gho_*)은 GitHub API 호출에는 작동하지만, git HTTPS push에는 사용할 수 없습니다. git push에는 PAT(Personal Access Token, ghp_*)이 필요합니다.

해결: GitHub Contents API를 사용하면 OAuth 토큰으로도 동작합니다:

# ✅ GitHub Contents API 사용
- name: Update Homebrew tap
  env:
    GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
    VERSION: ${{ steps.version.outputs.version }}
  run: |
    CONTENT=$(base64 -w 0 clavis.rb)

    # 기존 파일이 있으면 SHA 필요 (업데이트용)
    EXISTING_SHA=$(gh api \
      repos/uppinote20/homebrew-tap/contents/Formula/clavis.rb \
      --jq '.sha' 2>/dev/null || true)

    if [ -n "$EXISTING_SHA" ]; then
      gh api repos/uppinote20/homebrew-tap/contents/Formula/clavis.rb \
        --method PUT \
        -f "message=clavis ${VERSION}" \
        -f "content=${CONTENT}" \
        -f "sha=${EXISTING_SHA}"       # ← 업데이트 시 현재 SHA 필수
    else
      gh api repos/uppinote20/homebrew-tap/contents/Formula/clavis.rb \
        --method PUT \
        -f "message=clavis ${VERSION}" \
        -f "content=${CONTENT}"        # ← 신규 생성 시 SHA 불필요
    fi
방식 gho_* 호환 ghp_* 호환 비고
git clone + push PAT 필요
GitHub Contents API OAuth로도 동작

4.3 Pre-release 필터링

alpha, beta 같은 pre-release 태그가 Homebrew formula를 업데이트하면 안 됩니다. if 조건으로 간단히 필터링합니다:

  update-homebrew:
    needs: release
    runs-on: ubuntu-latest
    if: "!contains(github.ref_name, '-')"   # ← v0.2.0-beta.1 등 스킵

SemVer에서 -가 포함된 태그는 pre-release이므로, 이 한 줄로 안정 릴리스만 Homebrew에 반영됩니다.

5. Shell Installer 작성

5.1 기본 구조

curl | bash 패턴의 installer를 만듭니다. 핵심은 플랫폼 감지 → 다운로드 → 체크섬 검증 → 설치입니다.

#!/usr/bin/env bash
set -euo pipefail

REPO="uppinote20/clavis"
BINARY_NAME="clavis"
INSTALL_DIR="${CLAVIS_INSTALL_DIR:-$HOME/.local/bin}"
TMP_DIR=""   # ← 글로벌 선언 (이유는 후술)

cleanup() {
    if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
        rm -rf "$TMP_DIR"
    fi
}

5.2 플랫폼 자동 감지

uname -suname -m으로 OS와 아키텍처를 감지하고, Rust 타겟 triple로 매핑합니다:

detect_platform() {
    local os arch
    os=$(uname -s)
    arch=$(uname -m)

    case "$os" in
        Darwin)
            case "$arch" in
                arm64)  TARGET="aarch64-apple-darwin" ;;
                x86_64) TARGET="x86_64-apple-darwin" ;;
                *)      err "Unsupported macOS architecture: $arch" ;;
            esac ;;
        Linux)
            case "$arch" in
                x86_64)        TARGET="x86_64-unknown-linux-gnu" ;;
                aarch64|arm64) TARGET="aarch64-unknown-linux-gnu" ;;
                *)             err "Unsupported Linux architecture: $arch" ;;
            esac ;;
        *)
            err "Unsupported OS: $os" ;;
    esac
}
uname -s uname -m Rust Target
Darwin arm64 aarch64-apple-darwin
Darwin x86_64 x86_64-apple-darwin
Linux x86_64 x86_64-unknown-linux-gnu
Linux aarch64 aarch64-unknown-linux-gnu

5.3 SHA256 체크섬 검증

macOS는 shasum, Linux는 보통 sha256sum이 있으므로 양쪽을 모두 처리합니다:

echo "Verifying checksum..."
cd "$TMP_DIR"
if command -v sha256sum >/dev/null 2>&1; then
    sha256sum -c "$archive.sha256"         # Linux
elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256 -c "$archive.sha256"     # macOS
else
    echo "Warning: no checksum tool found, skipping verification"
fi

5.4 set -u와 trap의 함정

초기 버전에서 발생한 버그입니다. tmp_dirlocal 변수로 선언하고 trap에서 참조했더니:

# ❌ 버그 발생 코드
download_and_install() {
    local tmp_dir                         # ← 함수 스코프
    tmp_dir=$(mktemp -d)
    trap 'rm -rf "$tmp_dir"' EXIT         # ← trap은 스크립트 종료 시 실행
    # ...
}

main() {
    download_and_install
    echo "Done"
}

main
# 스크립트 종료 → trap 실행 → tmp_dir은 이미 스코프 밖
# → bash: tmp_dir: unbound variable

set -u (nounset)가 켜져 있으면, 함수가 끝난 후 local 변수는 unbound 상태가 됩니다. trap은 스크립트 종료 시 실행되므로, 이미 사라진 local 변수를 참조하게 됩니다.

# ✅ 수정: 글로벌 변수 + cleanup 함수
TMP_DIR=""                                # ← 글로벌 선언

cleanup() {
    if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
        rm -rf "$TMP_DIR"
    fi
}

download_and_install() {
    TMP_DIR=$(mktemp -d)                  # ← 글로벌에 할당
    trap cleanup EXIT                     # ← 함수 참조
    # ...
}

이 패턴은 set -euo pipefail을 사용하는 모든 셸 스크립트에 적용됩니다. trap에서 참조하는 변수는 반드시 글로벌이어야 합니다.

6. Scoop Manifest (Windows)

Windows 사용자를 위한 Scoop manifest는 JSON 형식입니다:

{
    "version": "{{VERSION}}",
    "description": "Claude Code system administration tool",
    "homepage": "https://github.com/uppinote20/clavis",
    "license": "MIT",
    "architecture": {
        "64bit": {
            "url": "https://github.com/.../clavis-x86_64-pc-windows-msvc.zip",
            "hash": "{{SHA256_X86_64_PC_WINDOWS_MSVC}}"
        },
        "arm64": {
            "url": "https://github.com/.../clavis-aarch64-pc-windows-msvc.zip",
            "hash": "{{SHA256_AARCH64_PC_WINDOWS_MSVC}}"
        }
    },
    "bin": "clavis.exe",
    "checkver": "github",
    "autoupdate": {
        "architecture": {
            "64bit": {
                "url": "https://github.com/.../clavis-x86_64-pc-windows-msvc.zip"
            },
            "arm64": {
                "url": "https://github.com/.../clavis-aarch64-pc-windows-msvc.zip"
            }
        },
        "hash": {
            "url": "$url.sha256",
            "regex": "^([a-fA-F0-9]{64})"  // ← .sha256 파일에서 해시 추출
        }
    }
}

autoupdate 블록이 핵심입니다. Scoop의 자동 업데이트 기능이 .sha256 파일에서 해시를 자동 추출할 수 있도록 정규식을 지정합니다. $url.sha256은 바이너리 URL 뒤에 .sha256를 붙인 경로를 의미합니다.

7. 버전 범프 자동화

npm 프로젝트에서는 npm version minor가 자동으로 package.json 수정 → 커밋 → 태그를 생성합니다. Rust에는 이 기능이 내장되어 있지 않으므로, 동일한 경험을 셸 스크립트로 구현합니다.

#!/usr/bin/env bash
# scripts/version-bump.sh
# Usage: ./scripts/version-bump.sh <patch|minor|major|x.y.z>

set -euo pipefail

CARGO_TOML="Cargo.toml"

# 현재 버전 파싱
CURRENT=$(grep -m1 '^version' "$CARGO_TOML" | sed 's/.*"\(.*\)"/\1/')
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"

# 새 버전 계산
case "$1" in
    patch) NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" ;;
    minor) NEW_VERSION="$MAJOR.$((MINOR + 1)).0" ;;
    major) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
    *.*.*)  NEW_VERSION="$1" ;;
esac

echo "$CURRENT → $NEW_VERSION"

# Cargo.toml 업데이트
sed -i '' "s/^version = \"$CURRENT\"/version = \"$NEW_VERSION\"/" "$CARGO_TOML"

# Cargo.lock 동기화
cargo generate-lockfile --quiet

# 빌드 + 테스트 + 린트 (실패 시 중단)
cargo build --quiet
cargo test --quiet
cargo clippy --quiet -- -D warnings

# 커밋 + 태그
git add Cargo.toml Cargo.lock
git commit -m "chore: bump version to $NEW_VERSION"
git tag "v$NEW_VERSION"

echo "v$NEW_VERSION tagged. Push with:"
echo "  git push origin main --tags"

사용법은 npm과 거의 동일합니다:

# npm 방식
npm version minor          # package.json 수정 → 커밋 → 태그

# 우리 방식
./scripts/version-bump.sh minor   # Cargo.toml 수정 → 빌드/테스트 → 커밋 → 태그
git push origin main --tags       # 릴리스 트리거

npm과의 차이점은 빌드와 테스트를 태그 전에 실행한다는 것입니다. Rust는 컴파일 타임 체크가 강력하므로, 깨진 코드가 태그되는 걸 사전에 방지할 수 있습니다.

8. 실제 릴리스 흐름 (End-to-End)

실제로 v0.2.0을 릴리스한 전체 흐름입니다:

# Step 1: 버전 범프 (빌드+테스트 자동 실행)
$ ./scripts/version-bump.sh minor
0.1.0 → 0.2.0
Building...
Testing...
test result: ok. 57 passed; 0 failed
Linting...
[main abc1234] chore: bump version to 0.2.0

v0.2.0 tagged. Push with:
  git push origin main --tags

# Step 2: push → 자동 릴리스
$ git push origin main --tags

# Step 3: 확인 (약 5분 후)
$ gh run view --json jobs --jq '.jobs[] | "\(.name): \(.conclusion)"'
build (aarch64-apple-darwin, ...):   success
build (x86_64-apple-darwin, ...):    success
build (x86_64-unknown-linux-gnu):    success
build (x86_64-unknown-linux-musl):   success
build (x86_64-pc-windows-msvc):      success
build (aarch64-pc-windows-msvc):     success
release:                             success
update-homebrew:                     success    # ← 자동 업데이트 성공!

# Step 4: Homebrew 확인
$ brew upgrade clavis
==> Upgrading clavis 0.1.0 -> 0.2.0
🍺 /opt/homebrew/Cellar/clavis/0.2.0

태그 push부터 brew upgrade까지 약 5분이면 완료됩니다.

9. 핵심 개념 정리

GitHub Actions 토큰 타입 비교

토큰 타입 접두사 API 호출 git push 발급 방법
OAuth (gh auth) gho_ gh auth login
Classic PAT ghp_ Settings → Tokens
Fine-grained PAT github_pat_ Settings → Tokens
GITHUB_TOKEN ✅ (현재 repo만) ✅ (현재 repo만) 자동 발급

크로스 레포 작업(다른 레포에 push)에서 OAuth 토큰을 사용할 때는 반드시 GitHub API를 사용해야 합니다.

플랫폼별 패키징 체크리스트

항목 macOS/Linux Windows
아카이브 형식 .tar.gz .zip
패키징 도구 tar czf Compress-Archive (PowerShell)
체크섬 도구 shasum -a 256 Get-FileHash
바이너리 확장자 없음 .exe
CI 셸 bash (기본) shell: pwsh 명시
패키지 매니저 Homebrew Scoop

10. 베스트 프랙티스

릴리스 파이프라인 체크리스트

  • [ ] matrix에 archive 필드를 추가해서 Unix/Windows 패키징을 명시적으로 분기하세요
  • [ ] Windows step에는 반드시 shell: pwsh를 명시하세요
  • [ ] SHA256 체크섬 파일을 바이너리와 함께 릴리스에 포함하세요
  • [ ] 크로스 레포 업데이트는 git push 대신 GitHub Contents API를 사용하세요
  • [ ] pre-release 태그는 패키지 매니저 업데이트에서 제외하세요
  • [ ] Homebrew formula는 pre-built 바이너리를 설치하세요 (소스 빌드 ❌)

셸 스크립트 체크리스트

  • [ ] set -euo pipefail을 항상 설정하세요
  • [ ] trap에서 참조하는 변수는 글로벌로 선언하세요
  • [ ] sha256sumshasum 양쪽을 지원하세요 (macOS vs Linux)
  • [ ] 설치 경로를 환경변수로 커스터마이즈 가능하게 하세요

11. FAQ

Q: Homebrew formula에서 소스 빌드 대신 pre-built 바이너리를 사용하는 이유는 무엇인가요?
A: Rust 컴파일은 수 분이 걸리고 Rust 툴체인이 설치되어 있어야 합니다. pre-built 바이너리는 다운로드만 하면 되므로 설치가 수 초면 끝납니다. brew install이 0초에 완료되는 것을 확인할 수 있습니다.

Q: gho_* OAuth 토큰으로 git push가 안 되는 이유는 무엇인가요?
A: GitHub는 2021년부터 password authentication을 비활성화했고, OAuth 토큰은 API 전용으로 설계되었습니다. git HTTPS push에는 PAT(Personal Access Token)을 사용하거나, GitHub Contents API를 통해 파일을 업데이트해야 합니다.

Q: set -u 환경에서 trap이 unbound variable 에러를 내는 이유는 무엇인가요?
A: local 변수는 함수가 끝나면 스코프에서 사라집니다. trap은 스크립트 종료 시 실행되므로, 이미 사라진 local 변수를 참조하면 set -u (nounset) 옵션에 의해 에러가 발생합니다. 해결책은 변수를 글로벌로 선언하는 것입니다.

Q: Windows ARM64 (aarch64-pc-windows-msvc) 크로스 컴파일이 windows-latest 러너에서 되나요?
A: 네, dtolnay/rust-toolchain으로 타겟을 추가하면 됩니다. windows-latest 러너에 MSVC ARM64 빌드 도구가 포함되어 있어 별도 설정 없이 크로스 컴파일이 가능합니다.

Q: Scoop manifest의 autoupdate는 어떻게 작동하나요?
A: scoop update를 실행하면 Scoop이 checkver 설정(여기서는 GitHub releases)에서 최신 버전을 확인하고, autoupdate 블록의 URL 패턴에 버전을 치환합니다. hash.url에 지정된 .sha256 파일에서 정규식으로 해시를 자동 추출합니다.

12. 참고 자료