From b1c6cef5c9b7a19179c38c0b4a450afae20205f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 19:29:13 +0000 Subject: [PATCH] feat(ios): automate App Store release build, signing, and upload Fixes the release pipeline so it produces an App-Store-valid artifact once the Apple account secrets are configured: - Device build: release now builds --ios_multi_cpus=arm64 when signing is available; the previous pipeline shipped an arm64 *simulator* app (bazel-out/ios_sim_arm64-...), which App Store Connect rejects. - Code signing scaffolding: the workflow imports the distribution cert (IOS_DISTRIBUTION_CERT_P12/_PASSWORD) into a throwaway keychain and decodes IOS_PROVISIONING_PROFILE next to the BUILD file, where a new :provisioning_profile filegroup picks it up behind --define=ios_release_signing=true. Without secrets it falls back to the unsigned simulator build with a warning, so releases stay green pre-setup. *.mobileprovision is gitignored. - Use the signed IPA that rules_apple emits instead of hand-zipping the .app into a Payload (which broke the code signature). - Upload via fastlane pilot (preinstalled on GitHub macOS runners) using an App Store Connect API key JSON assembled from the existing secrets; altool --upload-app is deprecated. - Refresh docs/RELEASE.md: Bazel 9 is supported now (no more USE_BAZEL_VERSION=8.2.1), document the real reason for the manual tags, the signing wiring, and the TestFlight flow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Jw99o89a2UYe9NKLE9zGUx --- .github/workflows/release-wordchains-ios.yml | 99 ++++++++++++++----- .gitignore | 1 + domains/games/apps/wordchains_ios/BUILD.bazel | 21 ++++ .../games/apps/wordchains_ios/docs/RELEASE.md | 70 +++++++++---- 4 files changed, 149 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release-wordchains-ios.yml b/.github/workflows/release-wordchains-ios.yml index 26dc61a5..77e43861 100644 --- a/.github/workflows/release-wordchains-ios.yml +++ b/.github/workflows/release-wordchains-ios.yml @@ -143,28 +143,77 @@ jobs: disk-cache: release-wordchains-ios repository-cache: true + # Imports the distribution certificate into a throwaway keychain and + # decodes the provisioning profile where BUILD.bazel's + # :provisioning_profile filegroup picks it up. When the signing secrets + # aren't configured yet, fall back to an unsigned simulator build so the + # GitHub Release still gets an artifact (not App Store uploadable). + # See domains/games/apps/wordchains_ios/docs/RELEASE.md for setup. + - name: Set up code signing + id: signing + env: + CERT_P12: ${{ secrets.IOS_DISTRIBUTION_CERT_P12 }} + CERT_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERT_PASSWORD }} + PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + run: | + if [ -z "$CERT_P12" ] || [ -z "$PROFILE" ]; then + echo "::warning::IOS_DISTRIBUTION_CERT_P12 / IOS_PROVISIONING_PROFILE not set — falling back to an unsigned simulator build. See domains/games/apps/wordchains_ios/docs/RELEASE.md." + echo "enabled=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + KEYCHAIN_PATH="$RUNNER_TEMP/wordchains-signing.keychain-db" + KEYCHAIN_PASSWORD=$(uuidgen) + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 1800 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + echo "$CERT_P12" | base64 --decode > "$RUNNER_TEMP/distribution.p12" + security import "$RUNNER_TEMP/distribution.p12" -k "$KEYCHAIN_PATH" \ + -P "$CERT_PASSWORD" -T /usr/bin/codesign + rm -f "$RUNNER_TEMP/distribution.p12" + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + + echo "$PROFILE" | base64 --decode \ + > domains/games/apps/wordchains_ios/WordChains.mobileprovision + echo "enabled=true" >> "$GITHUB_OUTPUT" + + # Device (arm64) build when signing is available — simulator builds are + # rejected by App Store Connect. rules_apple emits a signed IPA directly; + # re-zipping the .app by hand would break the code signature. - name: Build iOS app run: | - bazel build //domains/games/apps/wordchains_ios:WordChains \ - --ios_minimum_os=17.0 \ - --apple_platform_type=ios + if [ "${{ steps.signing.outputs.enabled }}" = "true" ]; then + bazel build //domains/games/apps/wordchains_ios:WordChains \ + --ios_minimum_os=17.0 \ + --ios_multi_cpus=arm64 \ + --define=ios_release_signing=true + else + bazel build //domains/games/apps/wordchains_ios:WordChains \ + --ios_minimum_os=17.0 \ + --apple_platform_type=ios + fi - # For App Store submission, we need an xcarchive + IPA. - # Bazel produces the .app bundle; we package it into an IPA. - - name: Package IPA + - name: Stage release artifacts run: | VERSION="${{ needs.prepare.outputs.version }}" BUILD="${{ needs.prepare.outputs.build_number }}" - APP_PATH=$(find bazel-bin/domains/games/apps/wordchains_ios -name "WordChains.app" -type d | head -1) - mkdir -p Payload - cp -r "$APP_PATH" Payload/ - zip -r "WordChains-${VERSION}-${BUILD}.ipa" Payload + cp bazel-bin/domains/games/apps/wordchains_ios/WordChains.ipa \ + "WordChains-${VERSION}-${BUILD}.ipa" shasum -a 256 "WordChains-${VERSION}-${BUILD}.ipa" | awk '{print $1}' > "WordChains-${VERSION}-${BUILD}.sha256.txt" echo "IPA_FILE=WordChains-${VERSION}-${BUILD}.ipa" >> "$GITHUB_ENV" echo "SHA_FILE=WordChains-${VERSION}-${BUILD}.sha256.txt" >> "$GITHUB_ENV" + - name: Clean up signing keychain + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/wordchains-signing.keychain-db" 2>/dev/null || true + rm -f domains/games/apps/wordchains_ios/WordChains.mobileprovision + - uses: actions/upload-artifact@v7 with: name: wordchains-ios-ipa @@ -195,10 +244,12 @@ jobs: artifacts/**/*.ipa \ artifacts/**/*.sha256.txt - # Upload to App Store Connect via the App Store Connect API. + # Upload to App Store Connect (TestFlight) with fastlane pilot, which is + # preinstalled on GitHub macOS runners and uses the App Store Connect + # API (altool --upload-app is deprecated). # Requires: APP_STORE_CONNECT_API_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, # APP_STORE_CONNECT_API_KEY (base64-encoded .p8 private key) - # See docs/RELEASE.md for setup instructions. + # See domains/games/apps/wordchains_ios/docs/RELEASE.md for setup. - name: Upload to App Store Connect env: APP_STORE_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} @@ -207,20 +258,24 @@ jobs: run: | if [ -z "$APP_STORE_KEY_ID" ]; then echo "::warning::APP_STORE_CONNECT_API_KEY_ID not set — skipping App Store upload." - echo "See docs/RELEASE.md for setup instructions." + echo "See domains/games/apps/wordchains_ios/docs/RELEASE.md for setup instructions." exit 0 fi - # Write API key to file - mkdir -p ~/.appstoreconnect/private_keys - echo "$APP_STORE_KEY" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_KEY_ID}.p8 + API_KEY_JSON="$RUNNER_TEMP/asc_api_key.json" + jq -n \ + --arg key_id "$APP_STORE_KEY_ID" \ + --arg issuer_id "$APP_STORE_ISSUER_ID" \ + --arg key "$(echo "$APP_STORE_KEY" | base64 --decode)" \ + '{key_id: $key_id, issuer_id: $issuer_id, key: $key, in_house: false}' \ + > "$API_KEY_JSON" IPA_FILE=$(find artifacts -name "*.ipa" | head -1) - xcrun altool --upload-app \ - --type ios \ - --file "$IPA_FILE" \ - --apiKey "$APP_STORE_KEY_ID" \ - --apiIssuer "$APP_STORE_ISSUER_ID" + fastlane pilot upload \ + --ipa "$IPA_FILE" \ + --api_key_path "$API_KEY_JSON" \ + --skip_waiting_for_build_processing true - echo "Uploaded to App Store Connect successfully." + rm -f "$API_KEY_JSON" + echo "Uploaded to App Store Connect (TestFlight) successfully." diff --git a/.gitignore b/.gitignore index dd55438a..89e4c6df 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ node_modules web/build_pal/dist .build .swiftpm +*.mobileprovision diff --git a/domains/games/apps/wordchains_ios/BUILD.bazel b/domains/games/apps/wordchains_ios/BUILD.bazel index 3da2bc08..bc9576bd 100644 --- a/domains/games/apps/wordchains_ios/BUILD.bazel +++ b/domains/games/apps/wordchains_ios/BUILD.bazel @@ -18,6 +18,23 @@ swift_library( target_compatible_with = ["@platforms//os:ios"], ) +# App Store signing: build with --define=ios_release_signing=true and place +# the distribution provisioning profile at WordChains.mobileprovision in this +# package (CI decodes it from the IOS_PROVISIONING_PROFILE secret; the file is +# gitignored). Default builds (simulator CI tests) stay unsigned. +config_setting( + name = "release_signing", + define_values = {"ios_release_signing": "true"}, +) + +filegroup( + name = "provisioning_profile", + srcs = glob( + ["*.mobileprovision"], + allow_empty = True, + ), +) + # The Apple bundling rules transition to an iOS target platform before # target_compatible_with is evaluated, so os:ios can't keep Linux CI off # them — Linux analysis fails resolving an Apple C++ toolchain instead of @@ -32,6 +49,10 @@ ios_application( ], infoplists = [":Info.plist"], minimum_os_version = "17.0", + provisioning_profile = select({ + ":release_signing": ":provisioning_profile", + "//conditions:default": None, + }), resources = [ ":word_graph_json", ] + glob(["Sources/Resources/**"]), diff --git a/domains/games/apps/wordchains_ios/docs/RELEASE.md b/domains/games/apps/wordchains_ios/docs/RELEASE.md index 606d85c3..b412f0bd 100644 --- a/domains/games/apps/wordchains_ios/docs/RELEASE.md +++ b/domains/games/apps/wordchains_ios/docs/RELEASE.md @@ -6,9 +6,13 @@ The iOS release pipeline mirrors the wordchains CLI release process: 1. **Version detection** — CI watches `Info.plist` for `CFBundleShortVersionString` changes 2. **Tagging** — auto-creates `wordchains-ios-v` git tags -3. **Build** — Bazel builds the iOS app on macOS runners (`rules_apple` + `rules_swift`) -4. **Release** — creates a GitHub Release with the IPA artifact -5. **App Store upload** — pushes the IPA to App Store Connect via `xcrun altool` +3. **Build** — Bazel builds the iOS app on macOS runners (`rules_apple` + `rules_swift`). + With the code-signing secrets configured, this is a signed **device (arm64)** build; + without them it falls back to an unsigned simulator build (not App Store uploadable) +4. **Release** — creates a GitHub Release with the Bazel-produced IPA artifact +5. **App Store upload** — pushes the IPA to App Store Connect (TestFlight) via + `fastlane pilot` using the App Store Connect API key (skipped with a warning + until the secrets are configured) ## Testing Before Publication @@ -46,18 +50,24 @@ In Xcode: ### Build with Bazel -The canonical build uses Bazel. `rules_apple` does not yet support Bazel 9, -so iOS builds must use Bazel 8 LTS via bazelisk: +The canonical build uses Bazel (the repo's pinned version, currently 9.x — +`rules_apple` 4.5+ supports Bazel 9). These are the same commands the +`test-ios` CI job runs: ```sh # Build the iOS app (simulator): -USE_BAZEL_VERSION=8.2.1 bazel build //domains/games/apps/wordchains_ios:WordChains \ +bazel build //domains/games/apps/wordchains_ios:WordChains \ --ios_minimum_os=17.0 \ --apple_platform_type=ios # Run the Bazel-based unit tests: -USE_BAZEL_VERSION=8.2.1 bazel test //domains/games/apps/wordchains_ios:WordChainsTests \ - --ios_minimum_os=17.0 +# (swift.enable_testing is needed for @testable imports because the repo +# defaults to -c opt; the explicit simulator device avoids the runner +# pairing an ancient device type with the newest iOS runtime.) +bazel test //domains/games/apps/wordchains_ios:WordChainsTests \ + --ios_minimum_os=17.0 \ + --features=swift.enable_testing \ + --ios_simulator_device="iPhone 16" ``` > **Simulator runtime:** The build requires an iOS simulator runtime matching @@ -81,20 +91,28 @@ Before submitting to the App Store, verify these scenarios: ## Build ```sh -# Build (requires Bazel 8 LTS): -USE_BAZEL_VERSION=8.2.1 bazel build //domains/games/apps/wordchains_ios:WordChains \ +# Simulator build (what CI's test-ios job does): +bazel build //domains/games/apps/wordchains_ios:WordChains \ --ios_minimum_os=17.0 \ --apple_platform_type=ios + +# Signed device build for the App Store (what the release workflow does; +# requires the distribution cert in a keychain and the provisioning profile +# at domains/games/apps/wordchains_ios/WordChains.mobileprovision): +bazel build //domains/games/apps/wordchains_ios:WordChains \ + --ios_minimum_os=17.0 \ + --ios_multi_cpus=arm64 \ + --define=ios_release_signing=true ``` ## Known Issues -- **Bazel 9 incompatibility:** `rules_apple` (latest: 4.3.3) does not support - Bazel 9 yet. All targets in `BUILD.bazel` are tagged `manual` so they are - excluded from `bazel build //...` and `bazel test //...`. Build them - explicitly with `USE_BAZEL_VERSION=8.2.1`. Tracking issues: - - [bazelbuild/rules_apple#2863 — Support Bazel 9+](https://github.com/bazelbuild/rules_apple/issues/2863) - - [bazelbuild/rules_apple#2857 — multi_arch_platform removed in Bazel 9](https://github.com/bazelbuild/rules_apple/issues/2857) +- **`manual` tags on the Apple bundling targets:** `ios_application` and + `ios_unit_test` transition to an iOS target platform before + `target_compatible_with` is evaluated, so constraints alone can't keep + Linux CI off them (Linux analysis fails resolving an Apple C++ toolchain + instead of skipping). They are tagged `manual` and built by explicit label + in the `test-ios` CI job and the release workflow. ## Setup TODOs @@ -147,7 +165,17 @@ This is required for automated uploads from GitHub Actions. | `IOS_DISTRIBUTION_CERT_PASSWORD` | Password for the .p12 file | | `IOS_PROVISIONING_PROFILE` | Base64-encoded .mobileprovision file | -- [ ] Update the BUILD.bazel `ios_application` rule with the provisioning profile once created +The CI wiring already exists: when the secrets are present, the release +workflow imports the certificate into a throwaway keychain, decodes the +profile to `domains/games/apps/wordchains_ios/WordChains.mobileprovision` +(gitignored), and builds a signed device IPA with +`--ios_multi_cpus=arm64 --define=ios_release_signing=true`. Until then it +falls back to an unsigned simulator build and skips the App Store upload. + +> **First-release check:** entitlements are derived from the provisioning +> profile by `rules_apple`. If the first signed build fails codesigning or +> upload validation, an explicit `entitlements` attribute on the +> `ios_application` may be needed. ### 5. App Icon @@ -172,9 +200,11 @@ To release a new version: 1. Bump `CFBundleShortVersionString` in `Info.plist` 2. Merge to `main` -3. CI auto-tags and builds -4. IPA uploads to App Store Connect (once secrets are configured) -5. Submit for review in App Store Connect +3. CI auto-tags and builds a signed device IPA +4. IPA uploads to App Store Connect / TestFlight via `fastlane pilot` + (once secrets are configured) +5. Submit for review in App Store Connect (still manual; automatable later + with `fastlane deliver` or the App Store Connect API) ## Manual Release (Tag Push)