💄 [Design] 마이페이지 명함 히어로를 2D 카드로 고정 (#1331) #505
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # | |
| # tuist-ci.yml | |
| # umc-product-iOS | |
| # | |
| # Created by euijjang97 on 7/5/26. | |
| # | |
| name: Tuist CI | |
| # 저장소의 단일 빌드/테스트 게이트 (이슈 #1128). | |
| # 레거시 AppProduct 는 v2.2.0 에 동결되어 더 이상 변경되지 않으므로, 그것만 감시하던 | |
| # ios.yml 은 폐기했다. 다시 필요해지면 태그 v2.2.0 에서 복원한다. | |
| # 빌드 진입점은 로컬과 동일하게 UMCApp/Makefile 을 사용한다(CLAUDE.md 규약). | |
| on: | |
| push: | |
| branches: [ "develop" ] | |
| pull_request: | |
| branches: [ "develop" ] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: tuist-ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| build-test: | |
| name: Build & Test (UMCApp + watchOS / Tuist) | |
| runs-on: macos-26 | |
| defaults: | |
| run: | |
| working-directory: UMCApp | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Select Xcode 26.4+ | |
| run: | | |
| XCODE_PATH=$(ls -d /Applications/Xcode_26.4*.app 2>/dev/null | sort -V | tail -n 1) | |
| if [ -z "$XCODE_PATH" ]; then | |
| echo "❌ Xcode 26.4+ not found on runner. Available:" | |
| ls -d /Applications/Xcode_*.app 2>/dev/null || true | |
| exit 1 | |
| fi | |
| echo "Selecting $XCODE_PATH" | |
| sudo xcode-select -s "$XCODE_PATH/Contents/Developer" | |
| xcodebuild -version | |
| - name: Create Secrets.xcconfig placeholder | |
| run: | | |
| # 신규 클론에는 git-untracked Secrets.xcconfig 가 없다. | |
| # Shared.xcconfig 의 `#include?` 는 optional 이라 없어도 빌드되지만, | |
| # 로컬 개발과 동일한 상태를 만들기 위해 템플릿(플레이스홀더 값)을 복사한다. | |
| if [ ! -f Secrets/Secrets.xcconfig ]; then | |
| cp Secrets/Secrets.xcconfig.template Secrets/Secrets.xcconfig | |
| fi | |
| - name: Restore GoogleService-Info.plist (optional secret) | |
| env: | |
| GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST_BASE64 }} | |
| run: | | |
| # RemoteConfigService/configureFirebaseIfNeeded()는 plist가 없어도 항상 | |
| # fail-open으로 동작하므로(#946), 시크릿이 없는 환경(fork PR 등)에서는 | |
| # 조용히 스킵한다 — 이 경우 CI는 실제 Firebase 없이 빌드/테스트만 검증한다. | |
| if [ -z "$GOOGLE_SERVICE_INFO_PLIST_BASE64" ]; then | |
| echo "GOOGLE_SERVICE_INFO_PLIST_BASE64 not set. Skipping GoogleService-Info.plist restore." | |
| exit 0 | |
| fi | |
| PLIST_DIR="UMCApp/Resources" | |
| PLIST_PATH="${PLIST_DIR}/GoogleService-Info.plist" | |
| mkdir -p "$PLIST_DIR" | |
| echo "Restoring GoogleService-Info.plist from GOOGLE_SERVICE_INFO_PLIST_BASE64..." | |
| if ! printf "%s" "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 --decode > "$PLIST_PATH"; then | |
| echo "ERROR: Failed to decode GOOGLE_SERVICE_INFO_PLIST_BASE64" | |
| exit 1 | |
| fi | |
| # 레거시 AppProduct/ci_scripts/ci_post_clone.sh(114행~)의 검증 로직과 동일한 | |
| # 수준으로 복원 결과를 확인한다 (plist 헤더 → 유효성 → 필수 키 존재). | |
| if ! grep -q "<plist" "$PLIST_PATH"; then | |
| echo "ERROR: Restored GoogleService-Info.plist does not look like a plist file." | |
| exit 1 | |
| fi | |
| if ! plutil -lint "$PLIST_PATH" >/dev/null 2>&1; then | |
| echo "ERROR: GoogleService-Info.plist is not a valid plist file." | |
| exit 1 | |
| fi | |
| if ! grep -q "<key>GOOGLE_APP_ID</key>" "$PLIST_PATH"; then | |
| echo "ERROR: GOOGLE_APP_ID missing in GoogleService-Info.plist." | |
| exit 1 | |
| fi | |
| echo "GoogleService-Info.plist restored successfully at: $PLIST_PATH" | |
| - name: Install mise & Tuist (from mise.toml) | |
| uses: jdx/mise-action@v2 | |
| with: | |
| working_directory: UMCApp | |
| install: true | |
| cache: true | |
| - name: Cache SwiftPM / Tuist artifacts | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/tuist | |
| ~/Library/Caches/org.swift.swiftpm | |
| UMCApp/Tuist/.build | |
| # 캐시된 SwiftPM `.build` 에는 체크아웃 절대경로(`/Users/runner/work/{레포명}/{레포명}/...`)가 | |
| # 박혀 있다. 레포 이름이 바뀌면 그 경로가 어긋나 tuist generate 가 xcframework 를 못 찾으므로, | |
| # 키와 restore-keys 양쪽에 레포명을 넣어 rename 시 자동으로 무효화되게 한다 (이슈 #1296). | |
| key: ${{ runner.os }}-tuist-${{ github.event.repository.name }}-${{ hashFiles('UMCApp/Tuist/Package.resolved', 'UMCApp/Tuist/Package.swift', 'UMCApp/mise.toml') }} | |
| restore-keys: | | |
| ${{ runner.os }}-tuist-${{ github.event.repository.name }}- | |
| - name: Install dependencies (tuist install) | |
| run: make install | |
| - name: Generate project (tuist generate --no-open) | |
| run: make generate | |
| - name: Build & Test (make test) | |
| run: make test | |
| # 아래 watchOS 스텝들을 별도 잡으로 빼지 않는 이유(이슈 #1216): | |
| # 시크릿 복원 · mise/Tuist 설치 · SPM 캐시 · tuist generate 를 통째로 한 번 더 | |
| # 돌려야 해서 러너 시간이 두 배가 된다. 같은 잡에 이어 붙이면 생성된 워크스페이스를 | |
| # 그대로 재사용한다. | |
| - name: Check watchOS simulator runtime | |
| run: | | |
| # generic/platform=watchOS Simulator 빌드는 런타임 설치 없이도 통과하는 경우가 | |
| # 많다. 그래서 여기서 실패시키지 않고 경고만 남긴다 — 러너 이미지에서 런타임이 | |
| # 빠졌을 때 뒤 스텝이 깨지면 이 로그가 원인을 바로 가리킨다. | |
| if xcrun simctl list runtimes | grep -qi watchos; then | |
| xcrun simctl list runtimes | grep -i watchos | |
| else | |
| echo "::warning::watchOS simulator runtime not found. Attempting download..." | |
| xcodebuild -downloadPlatform watchOS || \ | |
| echo "::warning::watchOS runtime download failed. Continuing with generic destination." | |
| fi | |
| - name: Build watchOS app (make build-watch) | |
| run: make build-watch | |
| # UMCApp 스킴의 test action 에는 UMCAppTests 만 들어 있어서 위 `make test` 로는 | |
| # Core 모듈 테스트가 돌지 않는다. 워치 회귀를 잡으려면 스킴을 명시해야 한다. | |
| # destination 은 Makefile 기본값(iOS Simulator)을 그대로 쓴다 — CoreWatchConnectivity | |
| # 는 iOS/watchOS 멀티플랫폼 타겟이라 iOS 시뮬레이터에서 로직 테스트가 돈다. | |
| - name: Test CoreWatchConnectivity | |
| run: make test SCHEME=CoreWatchConnectivity | |
| notify: | |
| name: Notify Discord | |
| needs: build-test | |
| if: ${{ always() }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| # 실패한 잡/스텝과 그 로그를 조회하려면 워크플로 기본 `contents: read` 만으로는 부족하다. | |
| actions: read | |
| env: | |
| DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} | |
| DISCORD_MENTION_ROLE_ID: ${{ secrets.DISCORD_MENTION_ROLE_ID }} | |
| BUILD_RESULT: ${{ needs.build-test.result }} | |
| REPOSITORY: ${{ github.repository }} | |
| REF_NAME: ${{ github.ref_name }} | |
| ACTOR: ${{ github.actor }} | |
| COMMIT_SHA: ${{ github.sha }} | |
| WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| steps: | |
| # 알림만 보고 원인을 알 수 있게 실패한 스텝 이름과 로그의 error: 라인을 미리 받아 둔다 (이슈 #1304). | |
| # 조회가 깨져도 알림 자체는 나가야 하므로 전부 fail-open — 없으면 아래 스텝이 해당 필드를 생략한다. | |
| - name: Collect failure details | |
| if: ${{ needs.build-test.result == 'failure' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh api "repos/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?per_page=100" \ | |
| > jobs.json || echo '{}' > jobs.json | |
| JOB_ID=$(jq -r '[.jobs[]? | select(.conclusion == "failure")][0].id // empty' jobs.json) | |
| if [ -n "$JOB_ID" ]; then | |
| gh api "repos/${REPOSITORY}/actions/jobs/${JOB_ID}/logs" > job.log || true | |
| fi | |
| - name: Send build result to Discord | |
| run: | | |
| if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then | |
| echo "DISCORD_WEBHOOK_URL is not configured. Skipping Discord notification." | |
| exit 0 | |
| fi | |
| python3 - << 'PY' > payload.json | |
| import json | |
| import os | |
| import re | |
| def failed_step(): | |
| """실패한 첫 스텝을 `잡 › 스텝` 과 그 잡 로그 링크로 돌려준다.""" | |
| try: | |
| with open("jobs.json", encoding="utf-8") as file: | |
| jobs = json.load(file)["jobs"] | |
| except (OSError, ValueError, KeyError): | |
| return None | |
| for job in jobs: | |
| for step in job.get("steps", []): | |
| if step.get("conclusion") == "failure": | |
| return f"[{job['name']} › {step['name']}]({job['html_url']})" | |
| return None | |
| def error_excerpt(): | |
| """로그에서 `error:` 라인만 추려 임베드 필드(1024자) 안에 들어가게 자른다. | |
| Xcode 컴파일 에러와 XCTest 실패 모두 `error:` 를 포함하므로 이 한 줄짜리 | |
| 필터로 대부분의 원인이 잡힌다. 더 정교한 파싱이 필요해지면 그때 늘린다. | |
| """ | |
| try: | |
| with open("job.log", encoding="utf-8", errors="replace") as file: | |
| lines = file.readlines() | |
| except OSError: | |
| return None | |
| seen = [] | |
| for line in lines: | |
| if "error:" not in line: | |
| continue | |
| # GitHub 이 붙이는 타임스탬프 접두사와 ANSI 컬러 코드를 벗겨 낸다. | |
| text = re.sub(r"^\S+Z\s+", "", line.rstrip()) | |
| text = re.sub(r"\x1b\[[0-9;]*m", "", text) | |
| if text and text not in seen: | |
| seen.append(text) | |
| if not seen: | |
| return None | |
| excerpt = "\n".join(seen[-5:])[:960] | |
| return f"```\n{excerpt}\n```" | |
| build_result = os.environ["BUILD_RESULT"] | |
| short_sha = os.environ["COMMIT_SHA"][:7] | |
| pr_url = os.environ.get("PR_URL") or "N/A" | |
| mention_role_id = os.environ.get("DISCORD_MENTION_ROLE_ID", "").strip() | |
| if build_result == "success": | |
| status_text = "SUCCESS ✅" | |
| color = 5763719 # green | |
| mention = "" | |
| elif build_result == "cancelled": | |
| # concurrency.cancel-in-progress 로 앞선 런이 잘린 경우 — 실패가 아니다. | |
| status_text = "CANCELLED ⏹️" | |
| color = 9807270 # grey | |
| mention = "" | |
| else: | |
| status_text = "FAILED ❌" | |
| color = 15548997 # red | |
| # 채널 전체를 깨우는 @here 폴백은 쓰지 않는다. 역할 ID 가 설정된 경우에만 멘션. | |
| mention = f"<@&{mention_role_id}>" if mention_role_id else "" | |
| fields = [ | |
| {"name": "Build Status", "value": status_text, "inline": True}, | |
| {"name": "Branch", "value": os.environ["REF_NAME"], "inline": True}, | |
| {"name": "Commit", "value": f"`{short_sha}` by {os.environ['ACTOR']}", "inline": True}, | |
| ] | |
| if build_result == "failure": | |
| step = failed_step() | |
| if step: | |
| fields.append({"name": "Failed Step", "value": step, "inline": False}) | |
| excerpt = error_excerpt() | |
| if excerpt: | |
| fields.append({"name": "Error Log", "value": excerpt, "inline": False}) | |
| fields.append({"name": "PR", "value": pr_url, "inline": False}) | |
| payload = { | |
| "content": mention, | |
| "embeds": [ | |
| { | |
| "title": "UMCApp (Tuist) CI Build Result", | |
| "url": os.environ["WORKFLOW_URL"], | |
| "color": color, | |
| "fields": fields, | |
| "footer": {"text": os.environ["REPOSITORY"]}, | |
| } | |
| ], | |
| } | |
| print(json.dumps(payload)) | |
| PY | |
| curl -sS -X POST "$DISCORD_WEBHOOK_URL" \ | |
| -H "Content-Type: application/json" \ | |
| --data @payload.json |