Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 77 additions & 22 deletions .github/workflows/release-wordchains-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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."
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ node_modules
web/build_pal/dist
.build
.swiftpm
*.mobileprovision
21 changes: 21 additions & 0 deletions domains/games/apps/wordchains_ios/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/**"]),
Expand Down
70 changes: 50 additions & 20 deletions domains/games/apps/wordchains_ios/docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<VERSION>` 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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down
Loading