diff --git a/.github/actions/ios-simulator-build/action.yml b/.github/actions/ios-simulator-build/action.yml new file mode 100644 index 00000000..1e7743e4 --- /dev/null +++ b/.github/actions/ios-simulator-build/action.yml @@ -0,0 +1,139 @@ +name: iOS Simulator Build +description: Select an installed iOS simulator runtime and build the app with xcodebuild. + +inputs: + scheme: + description: Xcode scheme to build + required: true + +runs: + using: composite + steps: + - name: Select iOS Simulator Runtime (installed) + id: pick_ios + shell: bash + run: | + set -euo pipefail + + # 설치된 iPhone 시뮬레이터 중 최신 iOS 버전 선택 + RESULT=$(python3 - <<'PY' + import re, subprocess, sys + + def ver_key(version): + return tuple(int(part) for part in version.split('.')) + + text = subprocess.check_output(["xcrun", "simctl", "list", "devices"], text=True) + lines = text.splitlines() + current_ver = None + candidates = [] + + for line in lines: + header = re.match(r"^-- iOS ([0-9]+(?:\.[0-9]+)*) --$", line.strip()) + if header: + current_ver = header.group(1) + continue + if current_ver is None: + continue + if "(unavailable)" in line: + continue + if "iPhone" not in line: + continue + + raw = line.strip() + if "platform:" in raw and "name:" in raw and "OS:" in raw: + kv = {} + for part in raw.split(","): + if ":" not in part: + continue + k, v = part.split(":", 1) + kv[k.strip()] = v.strip() + name = kv.get("name", raw) + else: + name = raw + name = re.sub(r"\s+\([0-9A-Fa-f-]{36}\)\s+\(.*\)$", "", name) + + candidates.append((current_ver, name)) + + if len(candidates) <= 0: + print("No available iPhone simulators found", file=sys.stderr) + sys.exit(1) + + latest_version = max((candidate[0] for candidate in candidates), key=ver_key) + latest_candidates = [ + candidate for candidate in candidates + if candidate[0] == latest_version + ] + chosen_version, chosen_device_name = min( + latest_candidates, + key=lambda candidate: candidate[1] + ) + + print(f"{chosen_version}|{chosen_device_name}") + sys.exit(0) + PY + ) + + if [ -z "${RESULT:-}" ]; then + echo "No iPhone simulator devices detected." >&2 + exit 1 + fi + + IFS='|' read -r IOS_VER DEVICE_NAME <<< "$RESULT" + + echo "Chosen iOS runtime version (iPhone): $IOS_VER" + echo "Chosen simulator: $DEVICE_NAME" + + echo "ios_version=$IOS_VER" >> "$GITHUB_OUTPUT" + echo "device_name=$DEVICE_NAME" >> "$GITHUB_OUTPUT" + + - name: Build + shell: bash + env: + SCHEME: ${{ inputs.scheme }} + IOS_VER: ${{ steps.pick_ios.outputs.ios_version }} + DEVICE_NAME: ${{ steps.pick_ios.outputs.device_name }} + run: | + set -euo pipefail + set -x + SPM_DIR="$GITHUB_WORKSPACE/.spm" + mkdir -p "$SPM_DIR" + + xcodebuild -version + + echo "Using scheme: $SCHEME" + + echo "Using simulator: $DEVICE_NAME (iOS ${IOS_VER})" + + set -o pipefail + set +e + echo "== Resolving Swift Package dependencies ==" + xcodebuild \ + -scheme "$SCHEME" \ + -configuration Debug \ + -clonedSourcePackagesDirPath "$SPM_DIR" \ + -resolvePackageDependencies + echo "== Starting xcodebuild build ==" + xcodebuild \ + -scheme "$SCHEME" \ + -configuration Debug \ + -destination "platform=iOS Simulator,OS=${IOS_VER},name=${DEVICE_NAME}" \ + -clonedSourcePackagesDirPath "$SPM_DIR" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + -showBuildTimingSummary \ + build \ + | tee build.log + echo "== xcodebuild finished ==" + XC_STATUS=${PIPESTATUS[0]} + set -e + + if [ -f build.log ]; then + echo "== error: lines ==" + if grep -i "error:" build.log; then + if [ "$XC_STATUS" -eq 0 ]; then + XC_STATUS=1 + fi + fi + fi + + exit $XC_STATUS diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de651986..390752e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,36 @@ permissions: checks: write jobs: + detect_qa_tag: + runs-on: macos-latest + outputs: + has_qa_tag: ${{ steps.detect.outputs.has_qa_tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Detect QA Tag on PR Head + id: detect + shell: bash + run: | + set -euo pipefail + PR_HEAD_SHA="${{ github.event.pull_request.head.sha }}" + MATCHED_TAG="$(git tag --points-at "$PR_HEAD_SHA" | grep -E '^qa(-local)?-' | head -n 1 || true)" + + if [ -n "$MATCHED_TAG" ]; then + echo "Found QA tag on PR head: $MATCHED_TAG" + echo "has_qa_tag=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "No QA tag found on PR head" + echo "has_qa_tag=false" >> "$GITHUB_OUTPUT" + build: + needs: detect_qa_tag + if: needs.detect_qa_tag.outputs.has_qa_tag != 'true' runs-on: macos-latest timeout-minutes: 30 steps: @@ -37,144 +66,10 @@ jobs: restore-keys: | ${{ runner.os }}-spm- - - - name: Select iOS Simulator Runtime (installed) - id: pick_ios - shell: bash - run: | - set -euo pipefail - - # macOS 메인 버전에 맞는 iOS 버전 중 최신 버전의 iPhone 선택 - RESULT=$(python3 - <<'PY' - import re, subprocess, sys - - xcode_ver = subprocess.check_output(["xcodebuild", "-version"], text=True).splitlines()[0].strip() - xcode_major = xcode_ver.split()[1].split('.')[0] - try: - xcode_major_num = int(xcode_major) - except ValueError: - xcode_major_num = None - if xcode_major_num is not None and xcode_major_num <= 15: - xcode_major = "26" - - text = subprocess.check_output(["xcrun", "simctl", "list", "devices"], text=True) - lines = text.splitlines() - - def ver_key(v): - return tuple(int(x) for x in v.split('.')) - - # 1) 최신 iOS 버전(해당 mac 메이저) 찾기 - latest_ver = None - for line in lines: - header = re.match(r"^-- iOS ([0-9]+(?:\.[0-9]+)*) --$", line.strip()) - if not header: - continue - ver = header.group(1) - if not ver.startswith(f"{xcode_major}."): - continue - if latest_ver is None or ver_key(ver) > ver_key(latest_ver): - latest_ver = ver - - if latest_ver is None: - print(f"No iOS versions found for Xcode major {xcode_major}", file=sys.stderr) - sys.exit(1) - - # 2) 해당 버전 섹션에서 첫 iPhone 찾고 즉시 종료 - current_ver = None - for line in lines: - header = re.match(r"^-- iOS ([0-9]+(?:\.[0-9]+)*) --$", line.strip()) - if header: - current_ver = header.group(1) - continue - if current_ver != latest_ver: - continue - if "(unavailable)" in line: - continue - if "iPhone" in line: - raw = line.strip() - # key:value 형태면 딕셔너리로 파싱해서 name만 사용 - if "platform:" in raw and "name:" in raw and "OS:" in raw: - kv = {} - for part in raw.split(","): - if ":" not in part: - continue - k, v = part.split(":", 1) - kv[k.strip()] = v.strip() - name = kv.get("name", raw) - else: - name = raw - # UUID/상태만 제거하고 모델명 괄호는 유지 - name = re.sub(r"\s+\([0-9A-Fa-f-]{36}\)\s+\(.*\)$", "", name) - print(f"{latest_ver}|{name}") - sys.exit(0) - - print(f"No iPhone candidates found for iOS {latest_ver}", file=sys.stderr) - sys.exit(1) - PY - ) - - if [ -z "${RESULT:-}" ]; then - echo "No iPhone simulator devices detected." >&2 - exit 1 - fi - - IFS='|' read -r IOS_VER DEVICE_NAME <<< "$RESULT" - - echo "Chosen iOS runtime version (iPhone): $IOS_VER" - echo "Chosen simulator: $DEVICE_NAME" - - echo "ios_version=$IOS_VER" >> "$GITHUB_OUTPUT" - echo "device_name=$DEVICE_NAME" >> "$GITHUB_OUTPUT" - - name: Build - shell: bash - run: | - set -euo pipefail - set -x - IOS_VER="${{ steps.pick_ios.outputs.ios_version }}" - DEVICE_NAME="${{ steps.pick_ios.outputs.device_name }}" - SPM_DIR="$GITHUB_WORKSPACE/.spm" - mkdir -p "$SPM_DIR" - - xcodebuild -version - - echo "Using scheme: $SCHEME" - - echo "Using simulator: $DEVICE_NAME (iOS ${IOS_VER})" - - set -o pipefail - set +e - echo "== Resolving Swift Package dependencies ==" - xcodebuild \ - -scheme "$SCHEME" \ - -configuration Debug \ - -clonedSourcePackagesDirPath "$SPM_DIR" \ - -resolvePackageDependencies - echo "== Starting xcodebuild build ==" - xcodebuild \ - -scheme "$SCHEME" \ - -configuration Debug \ - -destination "platform=iOS Simulator,OS=${IOS_VER},name=${DEVICE_NAME}" \ - -clonedSourcePackagesDirPath "$SPM_DIR" \ - -skipPackagePluginValidation \ - -skipMacroValidation \ - -showBuildTimingSummary \ - build \ - | tee build.log - echo "== xcodebuild finished ==" - XC_STATUS=${PIPESTATUS[0]} - set -e - - if [ -f build.log ]; then - echo "== error: lines ==" - if grep -i "error:" build.log; then - if [ "$XC_STATUS" -eq 0 ]; then - XC_STATUS=1 - fi - fi - fi - - exit $XC_STATUS + uses: ./.github/actions/ios-simulator-build + with: + scheme: ${{ env.SCHEME }} - name: Comment build failure on PR if: failure() && github.event.pull_request.head.repo.fork == false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c66bb93..1bbc3f2f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,15 +51,6 @@ jobs: run: | printf '%s' "$ASC_KEY_CONTENT" | base64 -D > "$ASC_KEY_PATH" - - name: Debug ASC key fingerprint - run: | - shasum -a 256 "$ASC_KEY_PATH" - - - name: Debug ASC metadata fingerprint - run: | - printf '%s' "$ASC_KEY_ID" | shasum -a 256 - printf '%s' "$ASC_ISSUER_ID" | shasum -a 256 - - name: Release to App Store Connect run: bundle exec fastlane release diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index c53575da..50b266dd 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -7,6 +7,7 @@ on: workflow_dispatch: env: + SCHEME: DevLog RUBY_VERSION: "3.2" XCODE_VERSION: latest APP_STORE_TEAM_ID: ${{ secrets.APP_STORE_TEAM_ID }} @@ -22,7 +23,37 @@ permissions: contents: read jobs: + validate: + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Cache SwiftPM + uses: actions/cache@v4 + with: + path: | + ~/.swiftpm + ~/Library/Caches/org.swift.swiftpm + ~/Library/Developer/Xcode/SourcePackages + .spm + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Validate Debug Simulator Build + uses: ./.github/actions/ios-simulator-build + with: + scheme: ${{ env.SCHEME }} + testflight: + needs: validate runs-on: macos-latest timeout-minutes: 45 @@ -47,14 +78,13 @@ jobs: run: | printf '%s' "$ASC_KEY_CONTENT" | base64 -D > "$ASC_KEY_PATH" - - name: Debug ASC key fingerprint - run: | - shasum -a 256 "$ASC_KEY_PATH" + - name: Build for TestFlight + run: bundle exec fastlane testflight_build_only - - name: Debug ASC metadata fingerprint - run: | - printf '%s' "$ASC_KEY_ID" | shasum -a 256 - printf '%s' "$ASC_ISSUER_ID" | shasum -a 256 + - name: Skip TestFlight Upload for Local QA Tag + if: github.event_name == 'push' && startsWith(github.ref_name, 'qa-local-') + run: echo "Skipping TestFlight upload for local QA tag ${GITHUB_REF_NAME}" - name: Upload to TestFlight - run: bundle exec fastlane deploy_testflight + if: github.event_name != 'push' || !startsWith(github.ref_name, 'qa-local-') + run: bundle exec fastlane upload_testflight_build diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e0b72a89..7b0473cf 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,6 +1,8 @@ XCODE_PROJ = "DevLog.xcodeproj" APP_IDENTIFIER = "opfic.DevLog" TARGET_NAME = "DevLog" +TESTFLIGHT_BUILD_OUTPUT_DIRECTORY = "fastlane/testflight_build" +TESTFLIGHT_IPA_OUTPUT_PATH = File.join(TESTFLIGHT_BUILD_OUTPUT_DIRECTORY, "#{TARGET_NAME}.ipa") default_platform(:ios) @@ -103,6 +105,8 @@ platform :ios do project: XCODE_PROJ, scheme: TARGET_NAME, export_method: "app-store", + output_directory: TESTFLIGHT_BUILD_OUTPUT_DIRECTORY, + output_name: "#{TARGET_NAME}.ipa", xcargs: "-skipPackagePluginValidation" ) @@ -110,18 +114,28 @@ platform :ios do end lane :deploy_testflight do - api_key = build_for_store + build_for_store - upload_to_testflight( - api_key: api_key, - skip_waiting_for_build_processing: true - ) + upload_testflight_build end lane :testflight_build_only do build_for_store end + lane :upload_testflight_build do + api_key = asc_api_key + ipa_output_path = File.expand_path(TESTFLIGHT_IPA_OUTPUT_PATH, Dir.pwd) + + UI.user_error!("Missing built ipa at #{ipa_output_path}") if !File.exist?(ipa_output_path) + + upload_to_testflight( + api_key: api_key, + ipa: ipa_output_path, + skip_waiting_for_build_processing: true + ) + end + lane :release do api_key = asc_api_key version_number = get_version_number(