Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,14 @@ local.properties

# Firebase (google-services.json에 API 키 포함 — 로컬/CI에 각자 배치)
google-services.json

# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output/
vendor/bundle/
.bundle/

# fastlane 서비스 계정 키 (절대 커밋 금지)
*service-account*.json
7 changes: 7 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
source "https://rubygems.org"

gem "fastlane"

# fastlane 플러그인 (fastlane/Pluginfile 에 정의)
plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile")
eval_gemfile(plugins_path) if File.exist?(plugins_path)
377 changes: 377 additions & 0 deletions Gemfile.lock

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ val naverLoginClientName = properties.getProperty("naver_login_client_name")
?: System.getenv("NAVER_LOGIN_CLIENT_NAME")
?: ""

// 릴리즈 서명(upload keystore) — local.properties 또는 CI 환경변수에서 읽는다. 키 파일/비밀번호는 git 커밋 금지.
val releaseStoreFile = properties.getProperty("keystore.file") ?: System.getenv("KEYSTORE_FILE")
val releaseStorePassword = properties.getProperty("keystore.password") ?: System.getenv("KEYSTORE_PASSWORD")
val releaseKeyAlias = properties.getProperty("key.alias") ?: System.getenv("KEY_ALIAS")
val releaseKeyPassword = properties.getProperty("key.password") ?: System.getenv("KEY_PASSWORD")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 4개 값이 모두 채워지고 keystore 파일이 실제로 존재할 때만 릴리즈 서명을 구성/연결한다.
// (일부만 채워진 채 signingConfig를 연결하면 AGP가 릴리즈 빌드에서 서명 검증 실패를 냄)
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.isNullOrBlank() &&
file(releaseStoreFile).isFile

android {
namespace = "com.moball.app"
compileSdk = libs.versions.compileSdk
Expand All @@ -45,7 +59,13 @@ android {

signingConfigs {
create("release") {
// TODO: Add signing config details
// keystore 정보가 갖춰졌을 때만 서명 설정. 없으면 릴리즈는 미서명으로 빌드된다.
if (hasReleaseSigning) {
storeFile = file(releaseStoreFile)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}

Expand Down Expand Up @@ -88,7 +108,10 @@ android {
)
buildConfigField("String", "BASE_URL", "\"${properties.getProperty("prod.base.url") ?: ""}\"")
buildConfigField("Boolean", "USE_MOCK_SERVER", "false")
signingConfig = signingConfigs.getByName("release")
// keystore가 완전히 갖춰졌을 때만 릴리즈 서명 연결. 없으면 미서명으로 빌드(디버그/CI에서 검증 실패 방지).
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions {
Expand Down
5 changes: 5 additions & 0 deletions fastlane/Appfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Google Play(supply) 인증용 서비스 계정 JSON — 환경변수 PLAY_SERVICE_ACCOUNT_JSON 로 경로 지정.
# (Firebase App Distribution 배포에는 불필요)
json_key_file(ENV["PLAY_SERVICE_ACCOUNT_JSON"]) if ENV["PLAY_SERVICE_ACCOUNT_JSON"]

package_name("com.moball.app")
81 changes: 81 additions & 0 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
require "json"

# 문서: https://docs.fastlane.tools
# 사용법은 fastlane/README.md 참고.

default_platform(:android)

# fastlane 실행 시 fastlane/README.md 자동 생성/덮어쓰기 방지 (직접 작성한 가이드 유지).
skip_docs

# google-services.json 에서 Firebase App ID를 읽어온다.
# (google-services.json 은 .gitignore 대상이라 값을 코드에 하드코딩하지 않는다.)
# CI 등에서 파일이 없으면 FIREBASE_APP_ID 환경변수로 대체 가능.
def firebase_app_id
return ENV["FIREBASE_APP_ID"] unless ENV["FIREBASE_APP_ID"].to_s.empty?

path = File.expand_path("../app/google-services.json", __dir__)
unless File.exist?(path)
UI.user_error!("google-services.json 을 찾을 수 없습니다. FIREBASE_APP_ID 환경변수를 설정하거나 파일을 배치하세요: #{path}")
end
JSON.parse(File.read(path))["client"][0]["client_info"]["mobilesdk_app_id"]
end

platform :android do
desc "정적 분석(ktlint) + 단위 테스트 실행"
lane :test do
gradle(task: "ktlintCheck")
gradle(task: "testDebugUnitTest")
end

desc "테스터에게 배포 (Firebase App Distribution)\n" \
"옵션 예) fastlane android beta notes:\"수정 내용\" groups:\"모여볼\""
lane :beta do |options|
# 배포 전 품질 게이트
test

# 빌드 — 현재는 debug 서명 APK로 배포한다(별도 upload keystore 불필요).
# 업로드 keystore를 붙이면 아래 build_type 을 "Release"로 바꾸면 된다.
gradle(task: "clean")
gradle(task: "assemble", build_type: "Debug")

# Firebase App Distribution 업로드
firebase_app_distribution(
app: firebase_app_id,
android_artifact_type: "APK",
android_artifact_path: lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH],
service_credentials_file: ENV["FIREBASE_SERVICE_ACCOUNT_JSON"],
groups: options[:groups] || "모여볼", # Firebase App Distribution 그룹 alias (콘솔의 alias와 정확히 일치해야 함)
release_notes: options[:notes] || last_git_commit[:message],
)

UI.success("Firebase App Distribution 업로드 완료 🚀")
end

desc "Google Play 업로드 (supply)\n" \
"옵션 예) fastlane android deploy track:internal\n" \
"track: internal(기본)/alpha/beta/production, status: completed(기본)/draft"
lane :deploy do |options|
# 배포 전 품질 게이트
test

# 릴리즈 서명된 AAB 빌드 (Play 는 APK 가 아니라 AAB)
# ⚠️ local.properties 에 keystore.* 가 설정돼 있어야 서명된다.
gradle(task: "clean")
gradle(task: "bundle", build_type: "Release")

# Play Console 업로드 (스토어 리스팅/스크린샷은 건드리지 않고 바이너리만 올린다)
# 인증(json_key)은 fastlane/Appfile 의 PLAY_SERVICE_ACCOUNT_JSON 에서 읽는다.
upload_to_play_store(
track: options[:track] || "internal",
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
release_status: options[:status] || "completed",
skip_upload_apk: true,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
)

UI.success("Google Play (#{options[:track] || "internal"} 트랙) 업로드 완료 🚀")
end
end
2 changes: 2 additions & 0 deletions fastlane/Pluginfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# fastlane 플러그인 목록. `bundle install` 시 함께 설치된다.
gem "fastlane-plugin-firebase_app_distribution"
139 changes: 139 additions & 0 deletions fastlane/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Fastlane — 모여볼 릴리즈 자동화

빌드·테스트·배포를 한 줄 명령으로 실행한다. 배포는 **Firebase App Distribution**(테스터 배포)을 사용한다.

## Lanes

| Lane | 설명 | 명령 |
|------|------|------|
| `test` | ktlint + 단위 테스트 | `bundle exec fastlane android test` |
| `beta` | 빌드 후 테스터에게 배포 (Firebase App Distribution) | `bundle exec fastlane android beta` |
| `deploy` | 릴리즈 AAB 빌드 후 Google Play 업로드 (supply) | `bundle exec fastlane android deploy` |

`beta` 옵션:

```bash
# 릴리즈 노트 / 대상 그룹 지정 (groups 미지정 시 기본값 "모여볼")
bundle exec fastlane android beta notes:"공유 기능 추가" groups:"모여볼"
```

## 최초 1회 세팅

### 1) fastlane 설치

시스템 gem 폴더 대신 **프로젝트 로컬(`vendor/bundle`)** 에 설치한다(권한 이슈 회피, `.gitignore` 처리됨).

```bash
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install # Gemfile + fastlane/Pluginfile 의 플러그인까지 설치
```

> **권한 오류**(`Permission denied @ rb_sysopen ... /opt/homebrew/.../plugins/rdoc_plugin.rb`)가 나면,
> 위 `bundle config set --local path 'vendor/bundle'` 를 먼저 실행한 뒤 `bundle install` 하면 된다.
> (Homebrew Ruby 전역 폴더에 쓰려다 막히는 문제 — 로컬 설치로 우회)

설치 확인:

```bash
bundle exec fastlane lanes
```

### 2) Firebase App Distribution 인증 (서비스 계정)

CI/자동화에서는 서비스 계정 JSON을 쓴다.

1. [Google Cloud Console](https://console.cloud.google.com) → 해당 Firebase 프로젝트 → **서비스 계정** 생성
2. 역할: **Firebase App Distribution Admin** 부여
3. JSON 키 다운로드 → 안전한 경로에 저장 (**절대 git에 커밋 금지**)
4. 환경변수로 경로 지정:

```bash
export FIREBASE_SERVICE_ACCOUNT_JSON="/절대/경로/service-account.json"
```

> 로컬에서 잠깐 테스트만 할 땐 `firebase login` 후 `FIREBASE_SERVICE_ACCOUNT_JSON` 없이 실행해도 된다(대화형 인증).

### 3) 테스터 그룹

Firebase 콘솔 → App Distribution → **테스터 및 그룹** 에서 그룹을 만들고 테스터 이메일을 추가한다.

> ⚠️ **`groups:` 값은 그룹의 표시 이름이 아니라 `alias`(별칭)와 정확히 일치해야 한다.**
> 현재 프로젝트 그룹 alias는 **`모여볼`** 이고, `Fastfile`의 기본값도 이걸로 설정돼 있다.
> (다른 그룹을 쓰려면 콘솔에서 alias를 확인해 `groups:"<alias>"` 로 지정)

## 참고

- **Firebase App ID**는 `app/google-services.json` 에서 자동으로 읽는다. 파일이 없는 환경(CI 등)에서는 `FIREBASE_APP_ID` 환경변수로 대체한다.
- 현재 `beta`는 **debug 서명 APK**를 배포한다(별도 upload keystore 불필요). 릴리즈 서명(Play 앱 서명 + upload keystore)이 준비되면 `Fastfile`의 `build_type`을 `"Release"`로 바꾼다.
- Google Play 스토어 직접 배포(`supply`)는 upload keystore + Play Console 서비스 계정이 준비되면 `Fastfile`의 주석 처리된 `deploy` 레인을 활성화한다. (#34 후속)

## Google Play 배포(`deploy`) 세팅

`deploy` 레인은 **릴리즈 서명된 AAB**를 빌드해 Google Play에 업로드한다(`supply`). 다음 2가지가 필요하다.

### A) 업로드 keystore (릴리즈 서명)

> ⚠️ 앱이 이미 Play에 출시돼 있으면 **최초 출시 때 서명한 그 upload keystore를 그대로** 써야 한다. 다른 키로 서명하면 Play가 업데이트를 거부한다. (새 키 생성 ❌)

`local.properties` 에 keystore 정보를 추가한다 (이 파일은 git에 안 올라감):

```properties
keystore.file=/절대/경로/moball-upload.jks
keystore.password=****
key.alias=****
key.password=****
```

> 값이 없으면 릴리즈는 **미서명**으로 빌드된다(=Play 업로드 불가). CI에서는 같은 이름의 환경변수(`KEYSTORE_FILE` 등)로 대체 가능.

### B) Play Console 서비스 계정 (업로드 권한)

Firebase 것과 **별개**다.

1. [Play Console](https://play.google.com/console) → **설정 → API 액세스** → Google Cloud 프로젝트 연결
2. 서비스 계정 생성 → JSON 키 다운로드
3. Play Console에서 그 서비스 계정에 **앱 접근 권한**(최소 "출시 관리자"/"버전 업로드") 부여
4. 환경변수로 경로 지정:

```bash
export PLAY_SERVICE_ACCOUNT_JSON="/절대/경로/play-service-account.json"
```

### 사용

```bash
# 내부 테스트 트랙(기본)에 업로드
bundle exec fastlane android deploy

# 트랙/상태 지정: track = internal(기본)/alpha/beta/production, status = completed(기본)/draft
bundle exec fastlane android deploy track:internal
bundle exec fastlane android deploy track:production status:draft
```

> ⚠️ **매 업로드마다 `versionCode`를 올려야 한다.** Play는 같은 versionCode 재업로드를 거부한다.
> 현재는 `gradle/libs.versions.toml` 의 `versionCode` 를 수동으로 증가시킨다.

## 문제 해결

- **`[!] Invalid request (Google::Apis::ClientError)`** — 업로드는 됐는데 "Distributing release"에서 실패하면, `groups:` 의 **그룹 alias가 콘솔과 불일치**하는 경우다. 콘솔에서 alias를 확인해 정확히 맞춘다. (현재 alias: `모여볼`)
- **`fastlane requires your locale to be set to UTF-8`** / 한글 릴리즈 노트 깨짐 — 로케일을 UTF-8로 설정한다:
```bash
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
```
- `fastlane/README.md` 는 `Fastfile` 상단의 `skip_docs` 로 자동 덮어쓰기를 막아둔 상태다(직접 작성한 가이드 유지).

## CI (GitHub Actions) 연동 예시

```yaml
- uses: ruby/setup-ruby@v1
with: { ruby-version: '3.3', bundler-cache: true }
- name: Distribute to testers
env:
FIREBASE_SERVICE_ACCOUNT_JSON: ${{ github.workspace }}/sa.json
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: |
echo "$GOOGLE_SERVICES_JSON" > app/google-services.json
echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}' > sa.json
bundle exec fastlane android beta
```
Loading