Skip to content

fix(ci): preserve codegen output, gate Android job while iterating on… #8

fix(ci): preserve codegen output, gate Android job while iterating on…

fix(ci): preserve codegen output, gate Android job while iterating on… #8

Workflow file for this run

# 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 || '?' }})