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
127 changes: 127 additions & 0 deletions .github/workflows/appstore-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
name: App Store Release

on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
version:
description: "출시 버전 (예: 1.0.0)"
required: true
type: string
release_notes:
description: "이번 버전 변경사항 (비워두면 metadata/ko/release_notes.txt 사용)"
required: false
type: string

concurrency:
group: appstore-release
cancel-in-progress: false

jobs:
# ──────────────────────────────────────────────────────────
# Validate (메타데이터·버전 사전 검증 — 빌드 전에 실패시킨다)
# ──────────────────────────────────────────────────────────
validate:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.resolve.outputs.version }}
steps:
- uses: actions/checkout@v4

# 태그 실행이면 태그명(v1.0.0)에서, 수동 실행이면 입력값에서 버전을 확정한다
- name: Resolve version
id: resolve
# 입력값은 셸에 직접 보간하지 않고 env 로 넘긴다 (스크립트 인젝션 방지)
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ "$EVENT_NAME" = "push" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="$INPUT_VERSION"
fi

if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::버전 형식이 올바르지 않습니다: $VERSION (예: 1.0.0)"
exit 1
fi

echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "🚀 배포 버전: $VERSION"

- name: Setup Ruby & fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true

- name: Verify metadata
# 심사 담당자 연락처는 개인정보라 저장소에 두지 않고 Secrets 에서 주입한다
env:
APP_REVIEW_FIRST_NAME: ${{ secrets.APP_REVIEW_FIRST_NAME }}
APP_REVIEW_LAST_NAME: ${{ secrets.APP_REVIEW_LAST_NAME }}
APP_REVIEW_PHONE_NUMBER: ${{ secrets.APP_REVIEW_PHONE_NUMBER }}
APP_REVIEW_EMAIL: ${{ secrets.APP_REVIEW_EMAIL }}
run: bundle exec fastlane verify_metadata

# ──────────────────────────────────────────────────────────
# Release (TestFlight 검증 빌드 선택 → 메타데이터 업로드 → 심사 제출)
# ──────────────────────────────────────────────────────────
release:
needs: validate
# 새 아카이브를 만들지 않고 기존 TestFlight 빌드를 선택해 제출하므로 macOS·서명이 필요 없다
runs-on: ubuntu-latest
# deliver 가 매달려도 러너를 오래 점유하지 않도록 상한을 둔다
# (concurrency 가 cancel-in-progress: false 라 멈춘 잡이 후속 배포를 전부 막는다)
timeout-minutes: 20
env:
# Appfile / Fastfile
APP_IDENTIFIER: ${{ secrets.APP_IDENTIFIER }}
APPLE_ID: ${{ secrets.APPLE_ID }}
TEAM_ID: ${{ secrets.TEAM_ID }}
# App Store Connect API (Fastfile 변수명)
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
# deliver 가 대화형 확인을 요구하지 않도록 (CI 에서 입력 불가)
FASTLANE_SKIP_UPDATE_CHECK: "1"
# App Review 담당자 연락처 (개인정보라 metadata 파일이 아닌 Secrets 로만 관리)
APP_REVIEW_FIRST_NAME: ${{ secrets.APP_REVIEW_FIRST_NAME }}
APP_REVIEW_LAST_NAME: ${{ secrets.APP_REVIEW_LAST_NAME }}
APP_REVIEW_PHONE_NUMBER: ${{ secrets.APP_REVIEW_PHONE_NUMBER }}
APP_REVIEW_EMAIL: ${{ secrets.APP_REVIEW_EMAIL }}
steps:
- uses: actions/checkout@v4

- name: Setup Ruby & fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true

# 수동 실행에서 변경사항을 넘겼으면 이번 배포에 한해 릴리즈 노트를 덮어쓴다 (커밋하지 않음)
- name: Override release notes
if: github.event_name == 'workflow_dispatch' && inputs.release_notes != ''
env:
RELEASE_NOTES: ${{ inputs.release_notes }}
run: |
printf '%s\n' "$RELEASE_NOTES" > fastlane/metadata/ko/release_notes.txt
echo "릴리즈 노트를 입력값으로 교체했습니다."

- name: Submit to App Store
env:
RELEASE_VERSION: ${{ needs.validate.outputs.version }}
run: bundle exec fastlane release version:"$RELEASE_VERSION"

# 심사 반려·업로드 실패 원인 추적용
- name: Upload fastlane logs
if: always()
uses: actions/upload-artifact@v4
with:
name: fastlane-logs-${{ needs.validate.outputs.version }}
path: fastlane/report.xml
retention-days: 14
if-no-files-found: ignore
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ Carthage/Build/
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
# App Store 등록용 스크린샷은 snapshot 자동 생성물이 아니라 카피가 얹힌 마케팅 이미지라
# 재생성이 불가능하다. CI 러너가 체크아웃만으로 업로드할 수 있도록 예외로 추적한다
!fastlane/screenshots/ko/*.png
fastlane/test_output

# Compiled binaries
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
| `xcodebuild -workspace Bangawo.xcworkspace -scheme Bangawo build` | 빌드 |
| `xcodebuild -workspace Bangawo.xcworkspace -scheme Bangawo test` | 테스트 |
| `fastlane match_development` | 개발용 인증서·프로파일 로컬 설치 |
| `fastlane verify_metadata` | App Store 메타데이터 사전 검증 |
| `fastlane release version:1.0.0` | App Store 메타데이터 업로드 + 심사 제출 |

> 파일 생성/삭제, `Project.swift` 수정, 의존성 추가/제거 시 반드시 `./tuisttool generate` 실행.
> Tuist는 glob으로 소스를 수집하므로, generate 없이는 Xcode에 반영되지 않는다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,19 @@ public extension TermClause {
TermClause(
id: 1,
title: "개인정보처리방침",
urlString: "https://kimhyeji-dev.notion.site/48c9a7dac7408215a43d01ddf14942f2?source=copy_link",
urlString: "https://lightning-weight-c0c.notion.site/3a619ccc6d3a80089f59dcb19af2b673?source=copy_link",
isRequired: true
),
TermClause(
id: 2,
title: "이용약관",
urlString: "https://kimhyeji-dev.notion.site/a1c9a7dac7408391befe0108a872fe6c?source=copy_link",
urlString: "https://lightning-weight-c0c.notion.site/3a619ccc6d3a80d6af97f6503a959b1b?source=copy_link",
isRequired: true
),
TermClause(
id: 3,
title: "마케팅 정보 수신 동의",
urlString: "https://kimhyeji-dev.notion.site/3a39a7dac74080ce9580f3a0aa14739f?source=copy_link",
urlString: "https://lightning-weight-c0c.notion.site/3a619ccc6d3a80f0b334dde67ec0e5b0?source=copy_link",
isRequired: false
)
]
Expand Down
100 changes: 100 additions & 0 deletions docs/app-store-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# App Store 배포 자동화

TestFlight 업로드(`beta`)와 별개로, App Store 심사 제출·출시까지 자동화한 파이프라인이다.

| 대상 | lane | 워크플로 |
| --- | --- | --- |
| TestFlight | `fastlane beta` | `.github/workflows/testflight-deploy.yml` |
| App Store | `fastlane release version:1.0.0` | `.github/workflows/appstore-release.yml` |

> **`release` 는 새로 아카이브하지 않는다.** TestFlight 에서 검증 끝난 RC 빌드를 그대로 심사에 올린다. 즉 출시하려는 버전의 빌드가 먼저 `beta` 로 TestFlight 에 올라가 있어야 하며, 없으면 release 는 즉시 실패한다.
>
> App Store Connect 는 빌드의 마케팅 버전(`CFBundleShortVersionString`)과 같은 App Store 버전에만 그 빌드를 붙일 수 있고 빌드 버전은 빌드 시점에 고정된다. 따라서 **마케팅 버전은 RC 빌드를 만들기 전에 정해져야 한다.** 현재 마케팅 버전 소스는 `Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift` 의 `appVersion(version:)` 기본값(`1.0.0`)이므로, 다음 버전을 출시하려면 이 값을 올려 `tuist generate` 후 커밋하고 `beta` 로 RC 를 올린 뒤 같은 버전으로 release 한다.

## 배포 절차

### 1. 메타데이터 갱신

`fastlane/metadata/` 아래 텍스트 파일이 원본이다. App Store Connect 웹에서 수정하지 말고 이 파일들을 고쳐 커밋한다(배포 시 덮어쓰기된다).

```
fastlane/metadata/
├── copyright.txt # 저작권 표기
├── primary_category.txt # 기본 카테고리
├── secondary_category.txt # 보조 카테고리
├── ko/
│ ├── name.txt # 앱 이름 (30자)
│ ├── subtitle.txt # 부제 (30자)
│ ├── description.txt # 설명 (4000자)
│ ├── keywords.txt # 키워드, 쉼표 구분 (100자)
│ ├── promotional_text.txt # 홍보 텍스트 (170자, 심사 없이 수정 가능)
│ ├── release_notes.txt # 이번 버전 변경사항 (4000자)
│ ├── support_url.txt
│ └── privacy_url.txt
└── review_information/
└── notes.txt # 심사 담당자용 기능 확인 안내
```

> **심사 담당자 연락처(이름·전화번호·이메일)는 저장소에 두지 않는다.** 개인정보이므로 파일 대신 환경변수로만 다룬다. 로컬은 `fastlane/.env`(gitignore 대상), CI는 GitHub Secrets에서 주입한다.
>
> ```sh
> # fastlane/.env 에 추가
> APP_REVIEW_FIRST_NAME=길동
> APP_REVIEW_LAST_NAME=홍
> APP_REVIEW_PHONE_NUMBER=+82 10-1234-5678
> APP_REVIEW_EMAIL=contact@example.com
> ```

스크린샷은 `fastlane/screenshots/ko/` 에 둔다. 파일명 순서대로 App Store에 정렬되므로 `bangawo_01_`, `bangawo_02_` 처럼 번호를 붙인다.

> 스크린샷은 카피가 얹힌 마케팅 이미지라 재생성이 불가능하므로 `.gitignore` 예외로 저장소에 추적한다. CI 러너는 체크아웃한 파일을 그대로 업로드한다.

### 2. 사전 검증

```sh
bundle exec fastlane verify_metadata
```

필수 파일 누락, `TODO_` 자리표시자, 글자 수 초과, 스크린샷 부재, 심사 연락처 환경변수 누락을 잡는다. 빌드는 30분 이상 걸리므로 반드시 먼저 통과시킨다.

### 3. 배포 실행

**태그 방식(권장)**

```sh
git tag v1.0.0
git push origin v1.0.0
```

**수동 방식**

GitHub Actions → `App Store Release` → `Run workflow` → 버전 입력. 이때 변경사항을 함께 입력하면 이번 배포에 한해 `release_notes.txt` 를 덮어쓴다(커밋되지는 않는다).

### 4. 워크플로 동작

1. **validate** (ubuntu) — 버전 형식과 메타데이터를 검증한다. 여기서 실패하면 다음 잡을 실행하지 않는다.
2. **release** (ubuntu) — 아카이브를 새로 만들지 않는다. 요청 버전에 해당하는 TestFlight 최신 빌드를 조회 → (없으면 실패) → 그 빌드를 선택해 메타데이터·스크린샷과 함께 심사 제출한다. 서명·Xcode·Tuist 가 필요 없어 ubuntu 에서 돈다.

심사 승인 시 **자동으로 출시된다**(`automatic_release: true`). 수동 출시로 바꾸려면 `Fastfile` 의 해당 값을 `false` 로 둔다.

## 주의 사항

- 버전은 `v1.0.0` 형식만 허용한다. 태그명에서 `v` 를 뗀 값이 **제출할 TestFlight 빌드를 고르는 키**가 된다(버전을 코드에 주입하지 않으므로, 그 버전의 빌드가 TestFlight 에 미리 올라가 있어야 한다).
- 이미 심사 대기 중인 빌드가 있으면 `reject_if_possible: true` 설정에 따라 기존 제출을 반려하고 새로 올린다.
- 수출 규정·IDFA 답변은 `submission_information` 에 하드코딩돼 있다. 광고 SDK를 도입하면 `add_id_info_uses_idfa` 를 갱신해야 한다.
- 앱 이름(`ko/name.txt`)은 App Store 전체에서 고유해야 한다. 최초 등록명과 다르면 업로드가 거절된다.
- 실패 시 `fastlane-logs-{버전}` 아티팩트에 `report.xml` 이 남는다(14일 보관).

## 필요한 Secrets

TestFlight 워크플로와 동일한 값에 더해, 심사 연락처 4개를 추가로 등록해야 한다.

| Secret | 용도 |
| --- | --- |
| `APP_IDENTIFIER`, `APPLE_ID`, `TEAM_ID` | Appfile |
| `MATCH_PASSWORD`, `MATCH_KEYCHAIN_PASSWORD` | 인증서 복호화·키체인 (TestFlight 전용, release 잡에는 불필요) |
| `CONFIG_PRIVATE_REPO_TOKEN` | 인증서 repo·비공개 xcconfig 접근 (TestFlight 전용, release 잡에는 불필요) |
| `APP_STORE_CONNECT_API_KEY_ID` / `_ISSUER_ID` / `_API_KEY_CONTENT` | App Store Connect API (키는 base64) |
| `APP_REVIEW_FIRST_NAME` / `_LAST_NAME` / `_PHONE_NUMBER` / `_EMAIL` | App Review 담당자 연락처 |

> release 잡은 아카이브·서명을 하지 않으므로 `MATCH_*` 와 `CONFIG_PRIVATE_REPO_TOKEN` 을 쓰지 않는다. 다만 이 값들은 TestFlight 워크플로에 여전히 필요하니 삭제하지 않는다.
Loading
Loading