Skip to content

ci(example): pull Apple team ID from secret instead of hardcoding #2

ci(example): pull Apple team ID from secret instead of hardcoding

ci(example): pull Apple team ID from secret instead of hardcoding #2

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
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 }}
# 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.*` gradle 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).
# versionCode is overridden per-run via github.run_number so successive
# CI uploads never collide on Play's strictly-increasing versionCode rule.
# Read in build.gradle as `project.findProperty("versionCode")` — AGP's
# `android.injected.version.code` is an IDE flag and isn't reliably
# honored by command-line Gradle builds.
# Caveat: manual Play uploads that bump versionCode outside CI can get
# ahead of run_number — if that happens, bump CI past the manual number
# (re-trigger the workflow N times, or add an offset).
- name: Assemble Release Bundle
run: |
cd example/android && ./gradlew :app:bundleRelease \
-PreleaseVersionCode=${{ github.run_number }} \
-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}"
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
# `status: draft` — the release lands on the internal track unpublished
# so you can manually promote it in Play Console. Required while the app
# listing itself is still 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"
- name: Notify Slack on success
if: success()
uses: ./.github/actions/notify-slack-publish
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
result: success
platform: Play Store
version_label: ${{ github.event.release.tag_name || 'manual' }}
- name: Notify Slack on failure
if: failure()
uses: ./.github/actions/notify-slack-publish
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
result: failure
platform: Play Store
version_label: ${{ github.event.release.tag_name || 'manual' }}
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
working-directory: example/ios
# 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.
- name: Install CocoaPods dependencies
working-directory: example/ios
shell: bash
run: |
bundle exec pod install --repo-update
rm -f .xcode.env.local
# Cert + provisioning profile setup mirrors the iOS test app
# (klaviyo-ios-test-app/.github/workflows/testflight.yml). Profiles are
# imported by their UUID (xcodebuild matches by UUID, not name) and the
# discovered UUIDs are exported to GITHUB_ENV for downstream steps.
- name: Install Apple Certificate
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.EXTENSION_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
shell: bash
run: |
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -l ~/Library/Keychains/build.keychain
security list-keychains -d user -s ~/Library/Keychains/build.keychain-db ~/Library/Keychains/login.keychain-db
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output certificate.p12
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/xcodebuild
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
echo "Keychain search list:"
security list-keychains -d user
echo "Valid signing identities (cert+key pairs):"
security find-identity -v -p codesigning
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode > app.mobileprovision
APP_UUID=$(grep -aEo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' app.mobileprovision | head -1)
echo "App profile UUID: ${APP_UUID}"
cp app.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/${APP_UUID}.mobileprovision
echo "APP_PROFILE_UUID=${APP_UUID}" >> $GITHUB_ENV
if [ -n "$EXTENSION_PROVISION_PROFILE_BASE64" ]; then
echo -n "$EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > extension.mobileprovision
EXT_UUID=$(grep -aEo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' extension.mobileprovision | head -1)
echo "Extension profile UUID: ${EXT_UUID}"
cp extension.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/${EXT_UUID}.mobileprovision
echo "EXT_PROFILE_UUID=${EXT_UUID}" >> $GITHUB_ENV
else
# If no separate extension profile was provided, fall back to the
# app profile UUID. Works only if the app profile's bundle ID is a
# wildcard that covers the extension; otherwise the extension build
# will fail and EXTENSION_PROVISION_PROFILE_BASE64 must be set.
echo "EXT_PROFILE_UUID=${APP_UUID}" >> $GITHUB_ENV
fi
# MARKETING_VERSION is the user-facing version string and is kept in sync
# with the SDK version by bump-version.sh. BUILD_NUMBER must strictly
# increase across TestFlight uploads — github.run_number gives us that.
- name: Set build number
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
echo "BUILD_NUMBER=${{ github.run_number }}" >> $GITHUB_ENV
# Modern Xcode ignores CLI-passed signing flags in favor of project-level
# settings, so we must edit the .pbxproj. We update both ExportOptions.plist
# (provisioning profile UUIDs by bundle ID) and the Xcode project itself
# (per-target Manual signing settings) via the xcodeproj Ruby gem.
- name: Configure per-target signing
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
/usr/libexec/PlistBuddy -c "Set :provisioningProfiles:com.klaviyoreactnativesdkexample ${APP_PROFILE_UUID}" ExportOptions.plist
/usr/libexec/PlistBuddy -c "Set :provisioningProfiles:com.klaviyoreactnativesdkexample.KlaviyoReactNativeSdkExampleExtension ${EXT_PROFILE_UUID}" ExportOptions.plist
ruby <<'RUBY'
require 'xcodeproj'
project = Xcodeproj::Project.open('KlaviyoReactNativeSdkExample.xcodeproj')
app_uuid = ENV['APP_PROFILE_UUID']
ext_uuid = ENV['EXT_PROFILE_UUID']
team_id = ENV['APPLE_TEAM_ID']
raise "APP_PROFILE_UUID is empty" if app_uuid.nil? || app_uuid.empty?
raise "EXT_PROFILE_UUID is empty" if ext_uuid.nil? || ext_uuid.empty?
raise "APPLE_TEAM_ID is empty" if team_id.nil? || team_id.empty?
signing_targets = %w[KlaviyoReactNativeSdkExample KlaviyoReactNativeSdkExampleExtension]
project.targets.each do |target|
next unless signing_targets.include?(target.name)
uuid = (target.name == 'KlaviyoReactNativeSdkExample') ? app_uuid : ext_uuid
target.build_configurations.each do |config|
config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
config.build_settings['DEVELOPMENT_TEAM'] = team_id
config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = uuid
end
puts "#{target.name}: PROVISIONING_PROFILE_SPECIFIER = #{uuid}"
end
project.save
RUBY
# Retry loop handles the case where someone uploaded a manual TestFlight
# build outside of CI and bumped the build number past github.run_number.
# On collision, parse the previousBundleVersion from altool's error output
# and re-archive with build number = prev + 1.
- name: Build and Upload to TestFlight
working-directory: example/ios
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Prevent Metro from trying to start a dev server during the build
CI: true
shell: bash
run: |
MAX_RETRIES=3
BUILD_NUMBER=${{ env.BUILD_NUMBER }}
UPLOAD_SUCCESS=false
for attempt in $(seq 1 $MAX_RETRIES); do
echo "=== Attempt $attempt: build number $BUILD_NUMBER ==="
rm -rf ./build KlaviyoReactNativeSdkExample.xcarchive
xcrun agvtool new-version -all $BUILD_NUMBER
xcodebuild -workspace KlaviyoReactNativeSdkExample.xcworkspace \
-scheme KlaviyoReactNativeSdkExample \
-configuration Release \
archive -archivePath KlaviyoReactNativeSdkExample.xcarchive \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID"
xcodebuild -exportArchive \
-archivePath KlaviyoReactNativeSdkExample.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath ./build
UPLOAD_OUTPUT=$(xcrun altool --upload-app --type ios \
--file "build/KlaviyoReactNativeSdkExample.ipa" \
--username "$APPLE_ID" --password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
echo "$UPLOAD_OUTPUT"
if ! echo "$UPLOAD_OUTPUT" | grep -q "ERROR:"; then
UPLOAD_SUCCESS=true
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
break
fi
PREV_BUILD=$(echo "$UPLOAD_OUTPUT" | grep -o 'previousBundleVersion = [0-9]*' | head -1 | grep -o '[0-9]*') || true
if [ -z "$PREV_BUILD" ]; then
echo "Upload failed with non-recoverable error"
exit 1
fi
BUILD_NUMBER=$((PREV_BUILD + 1))
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
echo "Build $PREV_BUILD already exists — retrying with $BUILD_NUMBER"
done
if [ "$UPLOAD_SUCCESS" != "true" ]; then
echo "Upload failed after $MAX_RETRIES attempts"
exit 1
fi
- name: Clean up keychain and signing artifacts
if: always()
shell: bash
run: |
security delete-keychain build.keychain 2>/dev/null || true
rm -f certificate.p12 app.mobileprovision extension.mobileprovision
- name: Notify Slack on success
if: success()
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: failure()
uses: ./.github/actions/notify-slack-publish
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
result: failure
platform: TestFlight
version_label: ${{ env.MARKETING_VERSION || '?' }} (${{ env.BUILD_NUMBER || github.run_number }})