Skip to content

TestFlight Build

TestFlight Build #598

name: TestFlight Build
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Skip signing, archive, and TestFlight upload
required: true
type: boolean
default: false
source_ref:
description: Git ref/SHA to build instead of the workflow ref
required: false
type: string
version_override:
description: Marketing version to upload instead of auto-computed train
required: false
type: string
permissions:
contents: write
concurrency:
group: ${{ github.event_name != 'workflow_dispatch' && 'testflight-main-latest' || format('testflight-manual-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
env:
FASTLANE_SKIP_UPDATE_CHECK: "1"
jobs:
check-changes:
name: Check for new changes since last TestFlight upload
runs-on: macos-15
outputs:
should_run: ${{ steps.change-check.outputs.should_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if main HEAD changed since last upload
id: change-check
if: ${{ github.event_name != 'workflow_dispatch' }}
shell: bash
run: |
set -euo pipefail
last_tag="$(git tag -l 'testflight/*' --sort=-creatordate | head -n 1 || true)"
if [ -z "$last_tag" ]; then
echo "No prior testflight tags found; proceeding with upload."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
latest_uploaded_sha="$(git rev-parse "${last_tag}^{commit}")"
head_sha="$(git rev-parse HEAD)"
echo "Latest tag: ${last_tag}"
echo "Last uploaded SHA: ${latest_uploaded_sha}"
echo "Current HEAD SHA: ${head_sha}"
if [ "$latest_uploaded_sha" = "$head_sha" ]; then
echo "No new HEAD commit since last successful TestFlight upload; skipping."
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "New commit detected; continuing with TestFlight upload."
echo "should_run=true" >> "$GITHUB_OUTPUT"
build-upload:
name: Archive and upload to TestFlight
needs:
- check-changes
if: ${{ github.event_name == 'workflow_dispatch' || needs.check-changes.outputs.should_run == 'true' }}
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref || github.ref }}
fetch-depth: 0
- name: Resolve checked-out source
id: source
shell: bash
run: |
set -euo pipefail
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.source_ref || github.ref }}" >> "$GITHUB_OUTPUT"
- name: Checkout workflow helpers
if: ${{ !inputs.dry_run }}
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: .workflow-source
fetch-depth: 1
- name: Select Xcode
if: ${{ !inputs.dry_run }}
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Resolve Xcode cache key
id: xcode-cache
if: ${{ !inputs.dry_run }}
shell: bash
run: |
set -euo pipefail
xcode_key="$(xcodebuild -version | tr '\n' '-' | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/-$//')"
echo "version=$xcode_key" >> "$GITHUB_OUTPUT"
- name: Restore Swift package cache
if: ${{ !inputs.dry_run }}
uses: actions/cache/restore@v4
with:
path: ${{ vars.IOS_WORKDIR || 'ios' }}/SourcePackages
key: spm-${{ runner.os }}-${{ runner.arch }}-${{ steps.xcode-cache.outputs.version }}-${{ hashFiles('ios/epac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
- name: Set up Ruby and Bundler
if: ${{ !inputs.dry_run }}
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ${{ vars.IOS_WORKDIR || 'ios' }}
- name: Write App Store Connect credentials
if: ${{ !inputs.dry_run }}
shell: bash
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
run: |
set -euo pipefail
: "${ASC_KEY_ID:?ASC_KEY_ID secret is required}"
: "${ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required}"
: "${ASC_PRIVATE_KEY:?ASC_PRIVATE_KEY secret is required}"
mkdir -p "$HOME/.appstoreconnect/private_keys"
p8_path="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8"
printf '%s' "$ASC_PRIVATE_KEY" > "$p8_path"
chmod 600 "$p8_path"
jq -n \
--arg key_id "$ASC_KEY_ID" \
--arg issuer_id "$ASC_ISSUER_ID" \
--arg key "$ASC_PRIVATE_KEY" \
'{
key_id: $key_id,
issuer_id: $issuer_id,
key: $key,
in_house: false
}' > /tmp/asc_api_key.json
chmod 600 /tmp/asc_api_key.json
{
echo "ASC_KEY_ID=$ASC_KEY_ID"
echo "ASC_ISSUER_ID=$ASC_ISSUER_ID"
echo "ASC_KEY_PATH=$p8_path"
} >> "$GITHUB_ENV"
- name: Install release helper dependencies
if: ${{ !inputs.dry_run && (github.event_name != 'workflow_dispatch' || inputs.version_override == '') }}
run: |
python3 -m venv .venv-release
.venv-release/bin/python -m pip install --upgrade pip --quiet
.venv-release/bin/python -m pip install PyJWT requests cryptography --quiet
- name: Compute TestFlight version train
id: version
if: ${{ !inputs.dry_run && (github.event_name != 'workflow_dispatch' || inputs.version_override == '') }}
run: |
.venv-release/bin/python scripts/release/compute_next_version.py \
--key-id "$ASC_KEY_ID" \
--issuer-id "$ASC_ISSUER_ID" \
--private-key-path "$HOME/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8" \
--app-id "${{ vars.APPLE_APP_ID }}" \
--bump minor \
--prefer-existing-train \
--output-format github-output
- name: Write distribution certificate
if: ${{ !inputs.dry_run }}
shell: bash
env:
DISTRIBUTION_CERT_P12: ${{ secrets.DISTRIBUTION_CERT_P12 }}
DISTRIBUTION_CERT_PASSWORD: ${{ secrets.DISTRIBUTION_CERT_PASSWORD }}
run: |
set -euo pipefail
: "${DISTRIBUTION_CERT_P12:?DISTRIBUTION_CERT_P12 secret is required}"
: "${DISTRIBUTION_CERT_PASSWORD:?DISTRIBUTION_CERT_PASSWORD secret is required}"
printf '%s' "$DISTRIBUTION_CERT_P12" | base64 --decode > /tmp/dist_cert.p12
chmod 600 /tmp/dist_cert.p12
echo "DIST_CERT_PASSWORD=$DISTRIBUTION_CERT_PASSWORD" >> "$GITHUB_ENV"
- name: Create ephemeral keychain
if: ${{ !inputs.dry_run }}
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: scripts/runner/ephemeral_keychain.sh create
- name: Dry-run summary
if: ${{ inputs.dry_run }}
run: |
echo "Dry run: skipped signing, archive, TestFlight upload, dSYM upload, and keychain setup."
- name: Build and upload to TestFlight
id: build-number
if: ${{ !inputs.dry_run }}
working-directory: ${{ vars.IOS_WORKDIR || 'ios' }}
env:
NEW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override || steps.version.outputs.next_version }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
SPM_CLONED_SOURCE_PACKAGES_PATH: SourcePackages
run: |
set +e
output="$(bundle exec fastlane deploy \
scheme:${{ vars.SCHEME }} \
bundle_id:${{ vars.BUNDLE_ID }} \
team_id:${{ vars.APPLE_TEAM_ID }} \
xcodeproj:${{ vars.XCODEPROJ_PATH }} \
extra_bundle_ids:"${{ vars.EXTRA_BUNDLE_IDS || '' }}" 2>&1)"
exit_code=$?
set -e
echo "$output"
if [ $exit_code -ne 0 ] && echo "$output" | grep -q "Upload limit reached"; then
echo "::warning::Apple daily upload limit reached. Build was successful but upload was rejected. This is transient and resolves after 24 hours."
echo "upload_limit_reached=true" >> "$GITHUB_OUTPUT"
exit 0
fi
exit $exit_code
- name: Tag TestFlight build provenance
if: ${{ !inputs.dry_run && steps.build-number.outputs.upload_limit_reached != 'true' }}
shell: bash
env:
BUILD_NUMBER: ${{ steps.build-number.outputs.build_number }}
SOURCE_SHA: ${{ steps.source.outputs.sha }}
VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override || steps.version.outputs.next_version }}
run: |
set -euo pipefail
tag="testflight/${VERSION}/build-${BUILD_NUMBER}"
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
existing_sha="$(git rev-list -n 1 "${tag}")"
if [ "$existing_sha" != "$SOURCE_SHA" ]; then
echo "::error::${tag} already points at ${existing_sha}, not ${SOURCE_SHA}."
exit 1
fi
echo "${tag} already points at ${SOURCE_SHA}."
exit 0
fi
git config user.name "riddim-developer-bot"
git config user.email "developer-bot@riddimsoftware.com"
git tag -a "${tag}" "${SOURCE_SHA}" \
-m "TestFlight build ${BUILD_NUMBER}" \
-m "version=${VERSION}" \
-m "workflow_run_id=${GITHUB_RUN_ID}" \
-m "source_sha=${SOURCE_SHA}"
git push origin "refs/tags/${tag}"
- name: Resolve TestFlight build ID
id: build-id
if: ${{ !inputs.dry_run && steps.build-number.outputs.upload_limit_reached != 'true' }}
env:
VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override || steps.version.outputs.next_version }}
BUILD_NUMBER: ${{ steps.build-number.outputs.build_number }}
APPLE_APP_ID: ${{ vars.APPLE_APP_ID }}
run: |
.venv-release/bin/python scripts/release/resolve_build_id.py \
--app-id "$APPLE_APP_ID" \
--version "$VERSION" \
--build-number "$BUILD_NUMBER"
- name: Wait for TestFlight processing
if: ${{ !inputs.dry_run && steps.build-number.outputs.upload_limit_reached != 'true' }}
run: |
.venv-release/bin/python scripts/release/wait_for_build_processed.py \
--build-id "${{ steps.build-id.outputs.build_id }}"
- name: Attach build to PublicTesting group
if: ${{ !inputs.dry_run && steps.build-number.outputs.upload_limit_reached != 'true' }}
run: |
.venv-release/bin/python scripts/release/attach_build_to_group.py \
--build-id "${{ steps.build-id.outputs.build_id }}" \
--group-name "PublicTesting" \
--app-id "${{ vars.APPLE_APP_ID }}"
- name: Check beta review contact configuration
id: beta-review-config
if: ${{ !inputs.dry_run && steps.build-number.outputs.upload_limit_reached != 'true' }}
run: |
if [ -n "${{ vars.BETA_REVIEW_CONTACT_EMAIL }}" ] && \
[ -n "${{ vars.BETA_REVIEW_CONTACT_FIRST_NAME }}" ] && \
[ -n "${{ vars.BETA_REVIEW_CONTACT_LAST_NAME }}" ] && \
[ -n "${{ vars.BETA_REVIEW_CONTACT_PHONE }}" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::warning::Beta review contact variables not configured. Skipping beta app review submission. Set BETA_REVIEW_CONTACT_EMAIL, BETA_REVIEW_CONTACT_FIRST_NAME, BETA_REVIEW_CONTACT_LAST_NAME, and BETA_REVIEW_CONTACT_PHONE as repository variables to enable automatic beta review submission."
fi
- name: Ensure beta review info
if: ${{ !inputs.dry_run && steps.build-number.outputs.upload_limit_reached != 'true' && steps.beta-review-config.outputs.configured == 'true' }}
env:
BETA_REVIEW_CONTACT_EMAIL: ${{ vars.BETA_REVIEW_CONTACT_EMAIL }}
BETA_REVIEW_CONTACT_FIRST_NAME: ${{ vars.BETA_REVIEW_CONTACT_FIRST_NAME }}
BETA_REVIEW_CONTACT_LAST_NAME: ${{ vars.BETA_REVIEW_CONTACT_LAST_NAME }}
BETA_REVIEW_CONTACT_PHONE: ${{ vars.BETA_REVIEW_CONTACT_PHONE }}
run: |
.venv-release/bin/python scripts/release/ensure_beta_review_info.py \
--app-id "${{ vars.APPLE_APP_ID }}" \
--build-id "${{ steps.build-id.outputs.build_id }}"
- name: Submit for Beta App Review
if: ${{ !inputs.dry_run && steps.build-number.outputs.upload_limit_reached != 'true' && steps.beta-review-config.outputs.configured == 'true' }}
run: |
.venv-release/bin/python scripts/release/submit_beta_app_review.py \
--build-id "${{ steps.build-id.outputs.build_id }}"
- name: Delete ephemeral keychain
if: ${{ always() && !inputs.dry_run }}
run: scripts/runner/ephemeral_keychain.sh cleanup
- name: Summary
if: ${{ !inputs.dry_run }}
run: |
{
if [ "${{ steps.build-number.outputs.upload_limit_reached }}" = "true" ]; then
echo "## TestFlight upload skipped — daily limit reached"
echo ""
echo "Apple's daily upload limit (~3 builds/day) was hit. The build compiled successfully but the upload was rejected with HTTP 409."
echo "This is transient and self-resolves after 24 hours. No action required."
echo ""
echo "- Workflow source: ${{ github.event_name }}"
echo "- Workflow commit: ${{ github.sha }}"
echo "- Built source ref: ${{ steps.source.outputs.ref }}"
echo "- Built source commit: ${{ steps.source.outputs.sha }}"
else
echo "## TestFlight build uploaded"
echo ""
echo "- Workflow source: ${{ github.event_name }}"
echo "- Workflow commit: ${{ github.sha }}"
echo "- Built source ref: ${{ steps.source.outputs.ref }}"
echo "- Built source commit: ${{ steps.source.outputs.sha }}"
echo "- Version: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override || steps.version.outputs.next_version }}"
echo "- Version source: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override && 'manual override' || steps.version.outputs.version_source }}"
echo "- Build: #${{ steps.build-number.outputs.build_number }}"
echo "- TestFlight: https://appstoreconnect.apple.com/apps/${{ vars.APPLE_APP_ID }}/testflight/ios"
if [ "${{ steps.beta-review-config.outputs.configured }}" != "true" ]; then
echo ""
echo "> **Note:** Beta app review submission was skipped because \`BETA_REVIEW_CONTACT_*\` repository variables are not configured."
fi
fi
} >> "$GITHUB_STEP_SUMMARY"