TestFlight Build #598
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
| 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" |