fix(ci): preserve codegen output, gate Android job while iterating on… #8
Workflow file for this run
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
| # Publish the React Native SDK example app to internal/test distribution | |
| # tracks on both stores (Google Play internal, Apple TestFlight). Android and | |
| # iOS are independent jobs sharing the same triggers and shared composite | |
| # actions in .github/actions/. | |
| # | |
| # Triggers: | |
| # - workflow_dispatch: Manual publish (preferred — SDK releases don't map 1:1 to example app publishes) | |
| # - release: published: Fires on every SDK release to keep the example app in sync | |
| # | |
| # Required secrets — see "Required secrets" section in the PR description. | |
| name: Publish Example App | |
| on: | |
| workflow_dispatch: | |
| release: | |
| types: [published] | |
| # Temporary: run on every push to this branch so we can iterate on the workflow | |
| # before merge. `workflow_dispatch` only works from the default branch, so this | |
| # is the only way to exercise the pipeline end-to-end against the PR. Remove | |
| # this `push` trigger before merging to master. | |
| push: | |
| branches: [ecm/ci/android-example-play-publish] | |
| jobs: | |
| deploy-android: | |
| name: Publish to Play Store internal track | |
| runs-on: ubuntu-24.04 | |
| # TEMP: gated off while we iterate on the iOS job. Android was confirmed | |
| # working in run 25031149357. Re-enable by removing this `if` once the | |
| # iOS pipeline is reliably green. | |
| if: false | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Prep example app | |
| uses: ./.github/actions/example-publish-prep | |
| with: | |
| api_key: ${{ secrets.KLAVIYO_EXAMPLE_API_KEY }} | |
| # Node + Yarn 3 + workspace deps. Required because the React Native Gradle plugin | |
| # invokes the Metro bundler during bundleRelease to generate the JS bundle. Installs | |
| # the example workspace too, making react-native and its CLI available to Gradle. | |
| - name: Setup | |
| uses: ./.github/actions/setup | |
| - name: Set up JDK 17 | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: 'zulu' | |
| java-version: '17' | |
| java-package: jdk | |
| - name: Add Google Services from Secrets | |
| run: 'echo "$GOOGLE_SERVICES_JSON" > ./example/android/app/google-services.json' | |
| shell: bash | |
| env: | |
| GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} | |
| # Decode the base64-encoded upload keystore from secrets into a file that | |
| # the Gradle signing config can point at. The runner is ephemeral so the | |
| # plaintext .jks is torn down with the VM, but we also remove it at the | |
| # end of the job as hygiene. | |
| - name: Decode upload keystore | |
| run: echo "$SIGNING_KEY" | base64 -d > "${RUNNER_TEMP}/upload.jks" | |
| env: | |
| SIGNING_KEY: ${{ secrets.SIGNING_KEY }} | |
| # versionName is the user-facing version string and is kept in sync with | |
| # the SDK version by bump-version.sh. Read it now so Slack notifications | |
| # can include it on both success and failure paths. | |
| - name: Read versionName | |
| shell: bash | |
| run: | | |
| VERSION_NAME=$(grep -oE 'versionName "[^"]+"' example/android/app/build.gradle | head -1 | sed -E 's/versionName "([^"]+)"/\1/') | |
| echo "Version name: $VERSION_NAME" | |
| echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV | |
| # Auth for the Play Edits API used by the preflight step below. | |
| - uses: google-github-actions/auth@v2 | |
| with: | |
| credentials_json: ${{ secrets.SERVICE_ACCOUNT_JSON }} | |
| # Find the highest versionCode ever uploaded for this app and pick | |
| # highest+1 as our target. The /edits/{editId}/bundles endpoint | |
| # returns ALL bundles for the app (not edit-scoped — confirmed by | |
| # fastlane/supply's aab_version_codes treating it that way and by | |
| # local probe against this app's package). The androidpublisher | |
| # OAuth scope is required explicitly; the default cloud-platform | |
| # scope from `gcloud auth print-access-token` does not cover Play | |
| # API endpoints and silently returns an empty bundles list. | |
| - name: Resolve next versionCode from Play | |
| shell: bash | |
| env: | |
| PACKAGE_NAME: com.klaviyoreactnativesdkexample | |
| run: | | |
| set -euo pipefail | |
| TOKEN=$(gcloud auth print-access-token --scopes=https://www.googleapis.com/auth/androidpublisher) | |
| AUTH="Authorization: Bearer $TOKEN" | |
| API="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$PACKAGE_NAME" | |
| EDIT_ID=$(curl -sS -X POST -H "$AUTH" -H "Content-Type: application/json" "$API/edits" -d '{}' | jq -r '.id') | |
| BUNDLES=$(curl -sS -H "$AUTH" "$API/edits/$EDIT_ID/bundles") | |
| curl -sS -X DELETE -H "$AUTH" "$API/edits/$EDIT_ID" >/dev/null | |
| HIGHEST=$(echo "$BUNDLES" | jq '[.bundles[]?.versionCode // 0] | max // 0') | |
| NEXT=$(( HIGHEST + 1 )) | |
| echo "Highest existing versionCode: $HIGHEST" | |
| echo "Next versionCode: $NEXT" | |
| echo "VERSION_CODE=$NEXT" >> $GITHUB_ENV | |
| # bundleRelease triggers react-native bundle automatically via the RN | |
| # Gradle plugin. We cd into the android directory so that relative paths | |
| # in settings.gradle resolve correctly. | |
| # | |
| # Signing is done inline via -Pandroid.injected.signing.* properties so | |
| # AGP produces a properly v2/v3-signed AAB in a single pass — no | |
| # separate re-sign step, no risk of double-signing (Play rejects AABs | |
| # with multiple signer certificates). | |
| - name: Assemble Release Bundle | |
| shell: bash | |
| env: | |
| KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} | |
| KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} | |
| ALIAS: ${{ secrets.ALIAS }} | |
| # Prevent Metro from trying to start a dev server during the build | |
| CI: true | |
| run: | | |
| cd example/android && ./gradlew :app:bundleRelease \ | |
| -PreleaseVersionCode=${{ env.VERSION_CODE }} \ | |
| -Pandroid.injected.signing.store.file="${RUNNER_TEMP}/upload.jks" \ | |
| -Pandroid.injected.signing.store.password="${KEY_STORE_PASSWORD}" \ | |
| -Pandroid.injected.signing.key.alias="${ALIAS}" \ | |
| -Pandroid.injected.signing.key.password="${KEY_PASSWORD}" | |
| # `status: draft` — the release lands on the internal track unpublished | |
| # so it can be manually promoted in Play Console. Required while the app | |
| # listing itself is in Draft state (no published version yet); flip to | |
| # `completed` once the listing is fully configured for release. | |
| - name: Deploy to Internal Track | |
| uses: r0adkll/upload-google-play@v1.1.3 | |
| with: | |
| serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} | |
| packageName: com.klaviyoreactnativesdkexample | |
| releaseFiles: example/android/app/build/outputs/bundle/release/app-release.aab | |
| track: internal | |
| status: draft | |
| - name: Clean up keystore | |
| if: always() | |
| run: rm -f "${RUNNER_TEMP}/upload.jks" | |
| # TEMP: Slack notifications disabled while iterating on the publish | |
| # workflow. Re-enable by changing `if: false` back to `if: success()` | |
| # / `if: failure()` once both jobs are reliably green. | |
| - name: Notify Slack on success | |
| if: false | |
| uses: ./.github/actions/notify-slack-publish | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| result: success | |
| platform: Play Store | |
| version_label: ${{ env.VERSION_NAME }} (${{ env.VERSION_CODE }}) | |
| - name: Notify Slack on failure | |
| if: false | |
| uses: ./.github/actions/notify-slack-publish | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| result: failure | |
| platform: Play Store | |
| version_label: ${{ env.VERSION_NAME || '?' }} (${{ env.VERSION_CODE || '?' }}) | |
| deploy-ios: | |
| name: Publish to TestFlight | |
| runs-on: macos-15 | |
| env: | |
| DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Prep example app | |
| uses: ./.github/actions/example-publish-prep | |
| with: | |
| api_key: ${{ secrets.KLAVIYO_EXAMPLE_API_KEY }} | |
| # Node + Yarn 3 + workspace deps. The React Native Xcode build phase | |
| # invokes Metro to bundle JS during archive; node and yarn must be on PATH. | |
| - name: Setup | |
| uses: ./.github/actions/setup | |
| - name: Set up Ruby | |
| uses: ruby/setup-ruby@v1 | |
| with: | |
| ruby-version: '3.2' | |
| bundler-cache: true | |
| # Gemfile lives at example/, not example/ios/. The xcodeproj gem | |
| # used by the per-target signing step is pulled in transitively | |
| # via cocoapods 1.15.2. | |
| working-directory: example | |
| # Real (not stubbed) plist injected from a base64-encoded secret so the | |
| # built app can talk to Firebase for push delivery. The contents are the | |
| # GoogleService-Info.plist downloaded from the Firebase console for the | |
| # com.klaviyoreactnativesdkexample bundle ID, base64-encoded and stored | |
| # as the GOOGLE_SERVICE_INFO_PLIST_BASE64 repo secret. | |
| - name: Inject GoogleService-Info.plist | |
| env: | |
| GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST_BASE64 }} | |
| shell: bash | |
| run: echo "$GOOGLE_SERVICE_INFO_PLIST_BASE64" | base64 -d > example/ios/GoogleService-Info.plist | |
| # bundle exec pod install with --repo-update so a stale CDN cache on the | |
| # runner can't pin us to old pod versions. The .xcode.env.local file is | |
| # generated by pod install and pins NODE_BINARY at the path of node at | |
| # install time; we delete it so the Xcode build phase falls back to | |
| # `command -v node`, which resolves to the setup-node binary on $PATH. | |
| # | |
| # RCT_NEW_ARCH_ENABLED=1 is required for RN's new-architecture codegen | |
| # to run during pod install. Without it, Pods like ReactAppDependencyProvider | |
| # are still wired into the Pods project but the codegen outputs they | |
| # depend on (e.g. RCTAppDependencyProvider.h) never get generated, and | |
| # xcodebuild archive fails on missing headers. RN 0.81's default is | |
| # new-arch on; matching that here keeps the example app representative. | |
| - name: Install CocoaPods dependencies | |
| working-directory: example/ios | |
| shell: bash | |
| env: | |
| RCT_NEW_ARCH_ENABLED: '1' | |
| run: | | |
| bundle exec pod install --repo-update | |
| rm -f .xcode.env.local | |
| # Write the App Store Connect API .p8 to the conventional location so | |
| # both `xcodebuild -allowProvisioningUpdates ...` and `xcrun altool | |
| # --apiKey ...` auto-discover it. Apple tools look for the file at | |
| # ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8. | |
| - name: Write App Store Connect API key | |
| env: | |
| APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }} | |
| shell: bash | |
| run: | | |
| KEY_DIR="$HOME/.appstoreconnect/private_keys" | |
| mkdir -p "$KEY_DIR" | |
| echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 -d > "$KEY_DIR/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" | |
| chmod 600 "$KEY_DIR/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" | |
| # Read MARKETING_VERSION (kept in sync with the SDK version by | |
| # bump-version.sh) so it can be referenced in Slack notifications. | |
| - name: Read marketing version | |
| shell: bash | |
| run: | | |
| MARKETING_VERSION=$(grep -m1 'MARKETING_VERSION = ' example/ios/KlaviyoReactNativeSdkExample.xcodeproj/project.pbxproj | awk -F' = ' '{print $2}' | tr -d '; ') | |
| echo "Marketing version: $MARKETING_VERSION" | |
| echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV | |
| # Preflight: ask App Store Connect for the most recent CFBundleVersion | |
| # for this app and use latest+1. Mirrors the Android preflight; lets us | |
| # archive once with the right build number and avoid an archive-and-retry | |
| # loop. The `jwt` gem is installed inline because example/Gemfile only | |
| # declares CocoaPods (we don't want to pollute it). | |
| - name: Resolve next build number from App Store Connect | |
| 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 }} | |
| BUNDLE_ID: com.klaviyoreactnativesdkexample | |
| shell: bash | |
| run: | | |
| gem install jwt --no-document --silent | |
| ruby <<'RUBY' | |
| require 'jwt' | |
| require 'json' | |
| require 'net/http' | |
| require 'openssl' | |
| require 'uri' | |
| key_id = ENV.fetch('APP_STORE_CONNECT_API_KEY_ID') | |
| issuer = ENV.fetch('APP_STORE_CONNECT_API_KEY_ISSUER_ID') | |
| bundle_id = ENV.fetch('BUNDLE_ID') | |
| p8_path = File.expand_path("~/.appstoreconnect/private_keys/AuthKey_#{key_id}.p8") | |
| private_key = OpenSSL::PKey::EC.new(File.read(p8_path)) | |
| now = Time.now.to_i | |
| token = JWT.encode( | |
| { iss: issuer, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1' }, | |
| private_key, | |
| 'ES256', | |
| { kid: key_id } | |
| ) | |
| def get(uri, token) | |
| req = Net::HTTP::Get.new(uri, 'Authorization' => "Bearer #{token}") | |
| res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) } | |
| raise "ASC API #{uri}: HTTP #{res.code}\n#{res.body}" unless res.is_a?(Net::HTTPSuccess) | |
| JSON.parse(res.body) | |
| end | |
| apps = get(URI("https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=#{bundle_id}&limit=1"), token) | |
| raise "No app record in App Store Connect for bundleId=#{bundle_id}. Create the app listing first." if apps['data'].empty? | |
| app_id = apps['data'][0]['id'] | |
| builds = get(URI("https://api.appstoreconnect.apple.com/v1/builds?filter[app]=#{app_id}&sort=-uploadedDate&limit=1"), token) | |
| latest = builds['data'].empty? ? 0 : builds['data'][0]['attributes']['version'].to_i | |
| next_build = latest + 1 | |
| puts "Latest TestFlight build for #{bundle_id}: #{latest}" | |
| puts "Next build number: #{next_build}" | |
| File.open(ENV.fetch('GITHUB_ENV'), 'a') { |f| f.puts "BUILD_NUMBER=#{next_build}" } | |
| RUBY | |
| # Stamp the resolved build number into the project so Xcode picks it up | |
| # at archive time. agvtool updates CFBundleVersion across all targets. | |
| - name: Set build number on project | |
| working-directory: example/ios | |
| shell: bash | |
| run: xcrun agvtool new-version -all "$BUILD_NUMBER" | |
| # Stamp the team ID into ExportOptions.plist (checked-in copy uses a | |
| # placeholder so the team ID isn't recorded in source). | |
| - name: Stamp Apple Team ID into ExportOptions.plist | |
| working-directory: example/ios | |
| env: | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| shell: bash | |
| run: | | |
| /usr/libexec/PlistBuddy -c "Set :teamID ${APPLE_TEAM_ID}" ExportOptions.plist | |
| # Cloud-managed signing: -allowProvisioningUpdates lets xcodebuild call | |
| # App Store Connect (using the API key flags below) to register the | |
| # distribution cert, create or refresh provisioning profiles, and | |
| # download anything missing. The .p8 sits at the conventional path so | |
| # Apple tools can find it; -authenticationKeyPath is still required for | |
| # CLI invocations. | |
| - name: Archive | |
| working-directory: example/ios | |
| env: | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} | |
| # New architecture must be enabled at archive time too — some RN | |
| # build-phase scripts read this env var directly to decide whether | |
| # to invoke codegen and pull in the generated provider files. | |
| RCT_NEW_ARCH_ENABLED: '1' | |
| # Prevent Metro from trying to start a dev server during the build | |
| CI: true | |
| shell: bash | |
| run: | | |
| # Only clear the previous archive — DO NOT `rm -rf ./build`. Pod | |
| # install generates RN's new-arch codegen artifacts into | |
| # ./build/generated/ios/ (RCTAppDependencyProvider.h etc.) and | |
| # xcodebuild reads them from there during archive. Wiping ./build | |
| # right before archive deletes those headers and breaks the build. | |
| rm -rf KlaviyoReactNativeSdkExample.xcarchive | |
| # `-destination "generic/platform=iOS"` pins the archive to iOS device. | |
| # Without it, xcodebuild defaults to the runner's "My Mac" destination | |
| # (the scheme has Designed-for-iPad enabled, and Apple Silicon runners | |
| # advertise "My Mac" as a valid target). Mac archive uses Development | |
| # signing rules, which is why earlier runs failed asking for an iOS | |
| # App Development profile. | |
| # | |
| # No CODE_SIGN_IDENTITY override here. With the right destination | |
| # (iOS device), Automatic signing + the `archive` action picks Apple | |
| # Distribution on its own; combining Automatic with a manual | |
| # CODE_SIGN_IDENTITY is what xcodebuild flagged as "conflicting | |
| # provisioning settings". | |
| xcodebuild -workspace KlaviyoReactNativeSdkExample.xcworkspace \ | |
| -scheme KlaviyoReactNativeSdkExample \ | |
| -configuration Release \ | |
| -destination "generic/platform=iOS" \ | |
| archive -archivePath KlaviyoReactNativeSdkExample.xcarchive \ | |
| -allowProvisioningUpdates \ | |
| -authenticationKeyID "$ASC_KEY_ID" \ | |
| -authenticationKeyIssuerID "$ASC_ISSUER_ID" \ | |
| -authenticationKeyPath "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" \ | |
| DEVELOPMENT_TEAM="$APPLE_TEAM_ID" | |
| - name: Export IPA | |
| working-directory: example/ios | |
| env: | |
| ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} | |
| shell: bash | |
| run: | | |
| xcodebuild -exportArchive \ | |
| -archivePath KlaviyoReactNativeSdkExample.xcarchive \ | |
| -exportOptionsPlist ExportOptions.plist \ | |
| -exportPath ./build \ | |
| -allowProvisioningUpdates \ | |
| -authenticationKeyID "$ASC_KEY_ID" \ | |
| -authenticationKeyIssuerID "$ASC_ISSUER_ID" \ | |
| -authenticationKeyPath "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" | |
| # altool with the API key — Apple's recommended path. No Apple ID, no | |
| # app-specific password, no 2FA dance. | |
| - name: Upload to TestFlight | |
| working-directory: example/ios | |
| env: | |
| ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} | |
| shell: bash | |
| run: | | |
| xcrun altool --upload-app --type ios \ | |
| --file "build/KlaviyoReactNativeSdkExample.ipa" \ | |
| --apiKey "$ASC_KEY_ID" \ | |
| --apiIssuer "$ASC_ISSUER_ID" | |
| - name: Clean up API key | |
| if: always() | |
| shell: bash | |
| run: rm -rf "$HOME/.appstoreconnect/private_keys" | |
| # TEMP: Slack notifications disabled while iterating on the publish | |
| # workflow. Re-enable by changing `if: false` back to `if: success()` | |
| # / `if: failure()` once both jobs are reliably green. | |
| - name: Notify Slack on success | |
| if: false | |
| uses: ./.github/actions/notify-slack-publish | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| result: success | |
| platform: TestFlight | |
| version_label: ${{ env.MARKETING_VERSION }} (${{ env.BUILD_NUMBER }}) | |
| - name: Notify Slack on failure | |
| if: false | |
| uses: ./.github/actions/notify-slack-publish | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| result: failure | |
| platform: TestFlight | |
| version_label: ${{ env.MARKETING_VERSION || '?' }} (${{ env.BUILD_NUMBER || '?' }}) |