name: "iOS Publish" # Required GitHub Secrets: # # IOS_CERTIFICATE_BASE64 - Apple distribution certificate (.p12) encoded in base64 # IOS_CERTIFICATE_PASSWORD - Password for the .p12 certificate # IOS_PROVISION_PROFILE_BASE64 - App Store provisioning profile (.mobileprovision) encoded in base64 # IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 - Share extension App Store provisioning profile (.mobileprovision) encoded in base64 # ASC_API_KEY_P8_BASE64 - App Store Connect API key (.p8) encoded in base64 # ASC_API_KEY_ID - App Store Connect API Key ID # ASC_ISSUER_ID - App Store Connect Issuer ID on: workflow_dispatch: permissions: contents: read concurrency: group: ios-publish-${{ github.ref }} cancel-in-progress: false jobs: prepare-assets: name: Prepare iOS Assets runs-on: ubuntu-latest timeout-minutes: 30 outputs: version: ${{ steps.get-version.outputs.version }} steps: - uses: actions/checkout@v4 - name: Get version from package.json id: get-version run: | VERSION=$(node -p "require('./package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm install - name: Install electron dependencies run: | pushd electron npm install popd - name: Init mobile submodule run: | git submodule init git submodule update --remote "resources/mobile" - name: Build app assets run: ./cmd appbuild publish - name: Upload iOS platform artifacts uses: actions/upload-artifact@v4 with: name: ios-platform path: resources/mobile/platforms/ios/ retention-days: 1 build-ios: name: Build & Submit iOS needs: prepare-assets runs-on: macos-26 timeout-minutes: 60 environment: build steps: - uses: actions/checkout@v4 - name: Init mobile submodule run: | git submodule init git submodule update --remote "resources/mobile" - name: Download prepared assets uses: actions/download-artifact@v4 with: name: ios-platform path: resources/mobile/platforms/ios/ - name: Select Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: Install CocoaPods run: | if [ -f "resources/mobile/platforms/ios/eeuiApp/Podfile" ]; then cd resources/mobile/platforms/ios/eeuiApp pod install fi - name: Import signing certificate env: IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} run: | # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -hex 20) 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 CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH" security import "$CERTIFICATE_PATH" \ -P "$IOS_CERTIFICATE_PASSWORD" \ -A \ -t cert \ -f pkcs12 \ -k "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" - name: Import provisioning profile env: IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }} run: | set -euo pipefail APP_PROFILE_PATH=$RUNNER_TEMP/app.mobileprovision SHARE_PROFILE_PATH=$RUNNER_TEMP/share-extension.mobileprovision APP_PROFILE_PLIST=$RUNNER_TEMP/app-profile.plist SHARE_PROFILE_PLIST=$RUNNER_TEMP/share-extension-profile.plist echo "$IOS_PROVISION_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH" echo "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > "$SHARE_PROFILE_PATH" security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST" security cms -D -i "$SHARE_PROFILE_PATH" > "$SHARE_PROFILE_PLIST" APP_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$APP_PROFILE_PLIST") SHARE_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$SHARE_PROFILE_PLIST") IOS_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" "$APP_PROFILE_PLIST") APP_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$APP_PROFILE_PLIST") SHARE_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$SHARE_PROFILE_PLIST") if [ "$APP_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task" ]; then echo "Expected app profile for $IOS_TEAM_ID.com.dootask.task, got $APP_PROFILE_APP_ID" exit 1 fi if [ "$SHARE_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task.shareExtension" ]; then echo "Expected share extension profile for $IOS_TEAM_ID.com.dootask.task.shareExtension, got $SHARE_PROFILE_APP_ID" exit 1 fi if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$APP_PROFILE_PLIST" >/dev/null; then echo "The DooTask app profile must include Push Notifications." exit 1 fi if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$APP_PROFILE_PLIST" | grep -q "group.im.dootask"; then echo "The DooTask app profile must include App Group group.im.dootask." exit 1 fi if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$SHARE_PROFILE_PLIST" | grep -q "group.im.dootask"; then echo "The share extension profile must include App Group group.im.dootask." exit 1 fi mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp "$APP_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ cp "$SHARE_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ echo "APP_PROFILE_NAME=$APP_PROFILE_NAME" >> $GITHUB_ENV echo "SHARE_PROFILE_NAME=$SHARE_PROFILE_NAME" >> $GITHUB_ENV echo "IOS_TEAM_ID=$IOS_TEAM_ID" >> $GITHUB_ENV - name: Configure manual signing run: | set -euo pipefail ruby <<'RUBY' require 'xcodeproj' project_path = 'resources/mobile/platforms/ios/eeuiApp/eeuiApp.xcodeproj' project = Xcodeproj::Project.open(project_path) { 'DooTask' => ENV.fetch('APP_PROFILE_NAME'), 'ShareExtension' => ENV.fetch('SHARE_PROFILE_NAME') }.each do |target_name, profile_name| target = project.targets.find { |item| item.name == target_name } abort "Target #{target_name} not found in #{project_path}" unless target target.build_configurations.each do |config| next unless config.name == 'Release' config.build_settings['CODE_SIGN_STYLE'] = 'Manual' config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('IOS_TEAM_ID') config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution' config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile_name end end project.save RUBY - name: Resolve iOS build number env: ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} run: | set -euo pipefail ruby <<'RUBY' require 'base64' require 'json' require 'net/http' require 'openssl' require 'uri' BUNDLE_ID = 'com.dootask.task' VERSION_CONFIG_PATH = 'resources/mobile/platforms/ios/eeuiApp/Config/Version.xcconfig' def base64url(value) Base64.urlsafe_encode64(value).delete('=') end def jwt_es256_signature(private_key, unsigned) der_signature = private_key.sign('SHA256', unsigned) sequence = OpenSSL::ASN1.decode(der_signature) sequence.value.map { |integer| integer.value.to_s(2).rjust(32, "\0")[-32, 32] }.join end def asc_token key_id = ENV.fetch('ASC_API_KEY_ID') issuer_id = ENV.fetch('ASC_ISSUER_ID') private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64'))) now = Time.now.to_i header = { alg: 'ES256', kid: key_id, typ: 'JWT' } payload = { iss: issuer_id, iat: now, exp: now + 20 * 60, aud: 'appstoreconnect-v1' } unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}" signature = jwt_es256_signature(private_key, unsigned) "#{unsigned}.#{base64url(signature)}" end def asc_get(path, params, token) uri = URI::HTTPS.build( host: 'api.appstoreconnect.apple.com', path: path, query: URI.encode_www_form(params) ) request_uri = uri loop do response = Net::HTTP.start(request_uri.host, request_uri.port, use_ssl: true) do |http| request = Net::HTTP::Get.new(request_uri) request['Authorization'] = "Bearer #{token}" http.request(request) end unless response.is_a?(Net::HTTPSuccess) abort "App Store Connect API request failed: #{response.code} #{response.body}" end parsed = JSON.parse(response.body) yield parsed next_link = parsed.dig('links', 'next') break unless next_link request_uri = URI(next_link) end end token = asc_token app_id = nil asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page| app_id = page.fetch('data').first&.fetch('id') end abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id existing_versions = [] asc_get('/v1/builds', { 'filter[app]' => app_id, 'fields[builds]' => 'version', 'limit' => 200 }, token) do |page| existing_versions.concat( page.fetch('data').map { |build| build.dig('attributes', 'version').to_s } ) end max_build_number = existing_versions .select { |version| version.match?(/\A\d+\z/) } .map(&:to_i) .max || 0 next_build_number = max_build_number + 1 config_content = File.exist?(VERSION_CONFIG_PATH) ? File.read(VERSION_CONFIG_PATH) : '' if config_content.match?(/^VERSION_CODE\s*=/) config_content = config_content.gsub(/^VERSION_CODE\s*=.*$/, "VERSION_CODE = #{next_build_number}") else config_content = "#{config_content.rstrip}\nVERSION_CODE = #{next_build_number}\n" end File.write(VERSION_CONFIG_PATH, config_content) File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build_number}" } puts "Latest App Store Connect build number: #{max_build_number}" puts "Resolved iOS build number: #{next_build_number}" RUBY - name: Build archive run: | set -euo pipefail cd resources/mobile/platforms/ios/eeuiApp xcodebuild archive \ -workspace eeuiApp.xcworkspace \ -scheme eeuiApp \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath $RUNNER_TEMP/eeuiApp.xcarchive \ -allowProvisioningUpdates \ DEVELOPMENT_TEAM=$IOS_TEAM_ID \ CODE_SIGN_IDENTITY="Apple Distribution" \ CODE_SIGN_STYLE=Manual \ | xcpretty if [ ! -d "$RUNNER_TEMP/eeuiApp.xcarchive" ]; then echo "Archive was not created at $RUNNER_TEMP/eeuiApp.xcarchive" exit 1 fi - name: Export IPA run: | set -euo pipefail cd resources/mobile/platforms/ios/eeuiApp # Generate ExportOptions.plist cat > $RUNNER_TEMP/ExportOptions.plist << PLIST method app-store signingStyle manual teamID ${IOS_TEAM_ID} provisioningProfiles com.dootask.task ${APP_PROFILE_NAME} com.dootask.task.shareExtension ${SHARE_PROFILE_NAME} uploadBitcode uploadSymbols PLIST xcodebuild -exportArchive \ -archivePath $RUNNER_TEMP/eeuiApp.xcarchive \ -exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \ -exportPath $RUNNER_TEMP/ipa-output \ -allowProvisioningUpdates \ | xcpretty - name: Submit to App Store Connect env: ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} run: | set -euo pipefail # Prepare API key mkdir -p ~/private_keys echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > ~/private_keys/AuthKey_${ASC_API_KEY_ID}.p8 # Find and upload IPA IPA_PATH=$(find $RUNNER_TEMP/ipa-output -name "*.ipa" | head -1) if [ -z "$IPA_PATH" ]; then echo "No IPA file found in $RUNNER_TEMP/ipa-output" exit 1 fi echo "Uploading: $IPA_PATH" xcrun altool --upload-app \ -f "$IPA_PATH" \ --type ios \ --apiKey "$ASC_API_KEY_ID" \ --apiIssuer "$ASC_ISSUER_ID" - name: Clean up if: always() run: | security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true rm -f $RUNNER_TEMP/certificate.p12 rm -f $RUNNER_TEMP/app.mobileprovision rm -f $RUNNER_TEMP/share-extension.mobileprovision rm -f $RUNNER_TEMP/app-profile.plist rm -f $RUNNER_TEMP/share-extension-profile.plist rm -rf ~/private_keys