diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64c8095..d4767d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,16 @@ -name: Release to App Store +name: iOS Release Pipeline on: + push: + branches: + - main + paths: + - 'V2er/Info.plist' + - 'V2er.xcodeproj/project.pbxproj' workflow_dispatch: inputs: - release_type: - description: 'Release type' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - testflight_only: - description: 'TestFlight only (no App Store release)' + force_release: + description: 'Force release even if version unchanged' required: false default: false type: boolean @@ -22,197 +19,158 @@ env: DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer jobs: - release: - name: Build and Release - runs-on: macos-latest - + version-check: + name: Check Version and Create Tag + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.check.outputs.should_release }} + new_tag: ${{ steps.check.outputs.new_tag }} + version: ${{ steps.check.outputs.version }} + build: ${{ steps.check.outputs.build }} + steps: - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check version and create tag if needed + id: check + run: | + # Get current version from Info.plist using plutil for robust XML parsing + CURRENT_VERSION=$(/usr/bin/plutil -extract CFBundleShortVersionString xml1 -o - V2er/Info.plist | grep '' | sed 's/.*\(.*\)<\/string>.*/\1/' | xargs) + CURRENT_BUILD=$(/usr/bin/plutil -extract CFBundleVersion xml1 -o - V2er/Info.plist | grep '' | sed 's/.*\(.*\)<\/string>.*/\1/' | xargs) + + echo "Current version: $CURRENT_VERSION (build $CURRENT_BUILD)" + + # Check if tag already exists + TAG_NAME="v$CURRENT_VERSION" + + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + if [[ "${{ github.event.inputs.force_release }}" == "true" ]]; then + echo "Tag $TAG_NAME exists but force_release is true" + # Delete existing tag for force release + git push origin --delete "$TAG_NAME" 2>/dev/null || true + echo "should_release=true" >> $GITHUB_OUTPUT + else + echo "Tag $TAG_NAME already exists, skipping release" + echo "should_release=false" >> $GITHUB_OUTPUT + fi + else + echo "Tag $TAG_NAME does not exist, will create it" + echo "should_release=true" >> $GITHUB_OUTPUT + fi + + echo "new_tag=$TAG_NAME" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "build=$CURRENT_BUILD" >> $GITHUB_OUTPUT + + - name: Create and push tag + if: steps.check.outputs.should_release == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + TAG_NAME="${{ steps.check.outputs.new_tag }}" + VERSION="${{ steps.check.outputs.version }}" + BUILD="${{ steps.check.outputs.build }}" + + # Create annotated tag + git tag -a "$TAG_NAME" -m "Release version $VERSION (build $BUILD)" + + # Push tag + git push origin "$TAG_NAME" + + echo "✅ Successfully created tag: $TAG_NAME" + + build-and-release: + name: Build and Release to TestFlight + needs: version-check + if: needs.version-check.outputs.should_release == 'true' + runs-on: macos-latest + + steps: + - name: Checkout repository at tag uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - + ref: ${{ needs.version-check.outputs.new_tag }} + - name: Select Xcode version run: sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer - + - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' - bundler-cache: true - + bundler-cache: false + - name: Install Fastlane run: | gem install fastlane gem install xcpretty - - - name: Configure Git - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - - name: Import certificates - env: - CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }} - CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: | - # Create variables - CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 - KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - - # Import certificate from secrets - echo -n "$CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH - - # Create temporary keychain - security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security set-keychain-settings -lut 21600 $KEYCHAIN_PATH - security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - - # Import certificate to keychain - security import $CERTIFICATE_PATH -P "$CERTIFICATES_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH - security list-keychain -d user -s $KEYCHAIN_PATH - - - name: Download provisioning profiles + + - name: Setup SSH for Match repository + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_KEY }} + + - name: Create App Store Connect API Key env: - PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }} - run: | - # Create the provisioning profiles directory - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - - # Decode and save provisioning profile - echo -n "$PROVISIONING_PROFILE_BASE64" | base64 --decode -o ~/Library/MobileDevice/Provisioning\ Profiles/V2er.mobileprovision - - - name: Bump version - id: version + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }} run: | - # Get current version - CURRENT_VERSION=$(xcodebuild -project V2er.xcodeproj -showBuildSettings | grep MARKETING_VERSION | tr -d 'MARKETING_VERSION = ') - echo "Current version: $CURRENT_VERSION" - - # Calculate new version based on input - IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION" - MAJOR=${VERSION_PARTS[0]} - MINOR=${VERSION_PARTS[1]} - PATCH=${VERSION_PARTS[2]} - - case "${{ github.event.inputs.release_type }}" in - major) - NEW_VERSION="$((MAJOR + 1)).0.0" - ;; - minor) - NEW_VERSION="$MAJOR.$((MINOR + 1)).0" - ;; - patch) - NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" - ;; - esac - - echo "New version: $NEW_VERSION" - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - - # Update version in project - xcrun agvtool new-marketing-version $NEW_VERSION - - # Get and increment build number - BUILD_NUMBER=$(xcodebuild -project V2er.xcodeproj -showBuildSettings | grep CURRENT_PROJECT_VERSION | tr -d 'CURRENT_PROJECT_VERSION = ') - NEW_BUILD_NUMBER=$((BUILD_NUMBER + 1)) - xcrun agvtool new-version -all $NEW_BUILD_NUMBER - - - name: Archive app + mkdir -p ~/.appstoreconnect/private_keys + echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8 + chmod 600 ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8 + + # Set environment variables for Fastlane + echo "APP_STORE_CONNECT_API_KEY_KEY_ID=$APP_STORE_CONNECT_KEY_ID" >> $GITHUB_ENV + echo "APP_STORE_CONNECT_API_KEY_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> $GITHUB_ENV + echo "APP_STORE_CONNECT_API_KEY_KEY=~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" >> $GITHUB_ENV + + - name: Run Fastlane Match env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} TEAM_ID: ${{ secrets.TEAM_ID }} run: | - xcodebuild archive \ - -project V2er.xcodeproj \ - -scheme V2er \ - -sdk iphoneos \ - -configuration Release \ - -archivePath $PWD/build/V2er.xcarchive \ - DEVELOPMENT_TEAM=$TEAM_ID \ - CODE_SIGN_STYLE=Manual \ - CODE_SIGN_IDENTITY="iPhone Distribution" \ - PROVISIONING_PROFILE_SPECIFIER="V2er AppStore" | xcpretty - - - name: Export IPA + fastlane match appstore --readonly + + - name: Build and Upload to TestFlight env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} TEAM_ID: ${{ secrets.TEAM_ID }} run: | - # Create export options plist - cat > ExportOptions.plist < - - - - method - app-store - teamID - $TEAM_ID - uploadSymbols - - compileBitcode - - provisioningProfiles - - com.v2er.app - V2er AppStore - - - - EOF - - xcodebuild -exportArchive \ - -archivePath $PWD/build/V2er.xcarchive \ - -exportOptionsPlist ExportOptions.plist \ - -exportPath $PWD/build \ - -allowProvisioningUpdates | xcpretty - - - name: Upload to TestFlight - env: - APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} - run: | - # Create API key file - mkdir -p ~/.appstoreconnect/private_keys - echo -n "$APP_STORE_CONNECT_API_KEY" > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8 - - xcrun altool --upload-app \ - --type ios \ - --file build/V2er.ipa \ - --apiKey $APP_STORE_CONNECT_API_KEY_ID \ - --apiIssuer $APP_STORE_CONNECT_API_KEY_ISSUER_ID - - - name: Create release tag - run: | - git add -A - git commit -m "Release version ${{ steps.version.outputs.version }}" - git tag -a "v${{ steps.version.outputs.version }}" -m "Release version ${{ steps.version.outputs.version }}" - git push origin main --tags - + fastlane beta + - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: - tag_name: v${{ steps.version.outputs.version }} - name: Release ${{ steps.version.outputs.version }} + tag_name: ${{ needs.version-check.outputs.new_tag }} + name: Release ${{ needs.version-check.outputs.version }} body: | - ## What's New - - This release includes bug fixes and performance improvements. - - ### Changes - - Version bump to ${{ steps.version.outputs.version }} - + ## 🚀 Version ${{ needs.version-check.outputs.version }} + Build: ${{ needs.version-check.outputs.build }} + ### TestFlight - This version has been submitted to TestFlight for testing. - - ${{ github.event.inputs.testflight_only == 'true' && '### Note\nThis is a TestFlight-only release.' || '### App Store\nThis version will be submitted to the App Store after TestFlight testing.' }} + This version has been automatically submitted to TestFlight for beta testing. + + ### What's New + - See [commit history](https://github.com/${{ github.repository }}/commits/${{ needs.version-check.outputs.new_tag }}) for changes + + --- + *This release was automatically created by GitHub Actions* draft: false - prerelease: ${{ github.event.inputs.testflight_only == 'true' }} - - - name: Clean up - if: always() + prerelease: false + + - name: Post release notification + if: success() run: | - security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true - rm -f ~/Library/MobileDevice/Provisioning\ Profiles/V2er.mobileprovision || true \ No newline at end of file + echo "✅ Successfully released version ${{ needs.version-check.outputs.version }} to TestFlight!" + echo "🏷️ Tag: ${{ needs.version-check.outputs.new_tag }}" + echo "🔢 Build: ${{ needs.version-check.outputs.build }}" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8a6919d..6cfdeaa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,15 @@ xcuserdata/ *.ipa *.dSYM.zip *.dSYM + +## Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output +fastlane/.env* +!fastlane/.env.example + +## Match +fastlane/certificates/ +fastlane/profiles/ diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..21ae980 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,22 @@ +# Appfile - App specific configuration + +# App Bundle ID +app_identifier("v2er.app") + +# Apple ID (optional if using API key) +# apple_id("your@email.com") + +# Team ID from Apple Developer Portal +team_id(ENV["TEAM_ID"]) + +# iTunes Connect Team ID (if different from Developer Portal team) +itc_team_id(ENV["ITC_TEAM_ID"] || ENV["TEAM_ID"]) + +# You can set different app identifiers per lane +for_lane :beta do + app_identifier("v2er.app") +end + +for_lane :release do + app_identifier("v2er.app") +end \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..c680417 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,128 @@ +# Fastfile for V2er iOS app + +default_platform(:ios) + +platform :ios do + # Helper method to get App Store Connect API key + private_lane :get_api_key do + app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], + key_filepath: ENV["APP_STORE_CONNECT_API_KEY_KEY"], + in_house: false + ) + end + + # Helper method for building the app + private_lane :build_ipa do + build_app( + scheme: "V2er", + export_method: "app-store", + export_options: { + provisioningProfiles: { + "v2er.app" => "match AppStore v2er.app" + } + }, + clean: true, + output_directory: "./build", + output_name: "V2er.ipa" + ) + end + + desc "Sync certificates and provisioning profiles" + lane :sync_certificates do + match( + type: "appstore", + readonly: is_ci, + app_identifier: "v2er.app", + git_url: ENV["MATCH_GIT_URL"] || "git@github.com:graycreate/certificates-v2er-iOS.git" + ) + end + + desc "Build and upload to TestFlight" + lane :beta do + # Ensure we have the latest certificates + sync_certificates + + # Get App Store Connect API key + api_key = get_api_key + + # Build the app + build_ipa + + # Upload to TestFlight + upload_to_testflight( + api_key: api_key, + skip_submission: false, + skip_waiting_for_build_processing: true, + distribute_external: true, + groups: ["Beta Testers"], + changelog: "Bug fixes and improvements", + notify_external_testers: true + ) + + # Notify success + notification( + title: "V2er iOS", + subtitle: "Successfully uploaded to TestFlight", + message: "Build has been uploaded and will be available for testing soon" + ) if is_ci == false + end + + desc "Submit to App Store Review" + lane :release do + # Ensure we have the latest certificates + sync_certificates + + # Get App Store Connect API key + api_key = get_api_key + + # Build the app + build_ipa + + # Upload to App Store Connect + upload_to_app_store( + api_key: api_key, + skip_metadata: true, + skip_screenshots: true, + submit_for_review: false, + precheck_include_in_app_purchases: false + ) + end + + desc "Create a new version on App Store Connect" + lane :create_app_version do |options| + api_key = get_api_key + + deliver( + api_key: api_key, + app_version: options[:version], + skip_binary_upload: true, + skip_metadata: false, + skip_screenshots: true + ) + end + + desc "Download metadata from App Store Connect" + lane :download_metadata do + api_key = get_api_key + + download_dsyms(api_key: api_key) + + deliver( + api_key: api_key, + skip_binary_upload: true, + skip_screenshots: false, + download_metadata: true, + download_screenshots: true + ) + end + + error do |lane, exception| + notification( + title: "V2er iOS - #{lane} failed", + subtitle: "Error occurred", + message: exception.message + ) if is_ci == false + end +end \ No newline at end of file diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 0000000..299224c --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,37 @@ +# Matchfile - Configuration for code signing + +# Git repository URL for storing certificates +git_url(ENV["MATCH_GIT_URL"] || "git@github.com:graycreate/certificates-v2er-iOS.git") + +# Storage mode +storage_mode("git") + +# Type of certificates/profiles to sync +type("appstore") + +# App Bundle ID +app_identifier(["v2er.app"]) + +# Your Apple Developer username (optional if using API key) +# username("your@email.com") + +# Team ID +team_id(ENV["TEAM_ID"] || "DZCZQ4694Z") + +# Git branch for certificates +git_branch("main") + +# Clone certificates repo to a temporary directory +shallow_clone(true) + +# Generate profiles if they don't exist +generate_apple_certs(true) + +# Platform +platform("ios") + +# Readonly mode for CI +readonly(ENV["CI"] == "true") + +# Verbose output +verbose(false) \ No newline at end of file