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
149 changes: 149 additions & 0 deletions .github/workflows/testflight.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
name: TestFlight refresh

# A TestFlight build expires after 90 days. This workflow runs twice a month
# and uploads a new build only when the newest build has less than 35 days of
# life left. The public TestFlight link then keeps working without manual work.
on:
schedule:
- cron: "0 15 2,16 * *"
workflow_dispatch:
inputs:
force:
description: "Upload even when the newest build is fresh"
type: boolean
default: false

permissions:
contents: read

jobs:
refresh:
runs-on: macos-26
steps:
- uses: actions/checkout@v4

- name: Install the API key
env:
ASC_P8_BASE64: ${{ secrets.ASC_P8_BASE64 }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo "$ASC_P8_BASE64" | base64 -d > ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8
pip3 install --quiet --break-system-packages pyjwt cryptography

- name: Stop when the newest build is fresh
if: ${{ !inputs.force }}
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_APP_ID: ${{ secrets.ASC_APP_ID }}
run: |
set +e
python3 scripts/testflight.py check-age
code=$?
set -e
if [ "$code" = "3" ]; then
echo "SKIP=true" >> "$GITHUB_ENV"
elif [ "$code" != "0" ]; then
exit "$code"
fi

- name: Set up JDK 17
if: env.SKIP != 'true'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- name: Set up Gradle
if: env.SKIP != 'true'
uses: gradle/actions/setup-gradle@v4

- name: Select the newest Xcode 26
if: env.SKIP != 'true'
run: |
sudo xcode-select -s "$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)/Contents/Developer"
xcodebuild -version

- name: Install the signing identity and the profile
if: env.SKIP != 'true'
env:
IOS_DIST_KEY_BASE64: ${{ secrets.IOS_DIST_KEY_BASE64 }}
IOS_DIST_CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
IOS_PROFILE_APP_BASE64: ${{ secrets.IOS_PROFILE_APP_BASE64 }}
run: |
KC=$RUNNER_TEMP/build.keychain-db
KCPASS=$(uuidgen)
security create-keychain -p "$KCPASS" "$KC"
security set-keychain-settings -lut 21600 "$KC"
security unlock-keychain -p "$KCPASS" "$KC"
security list-keychains -d user -s "$KC" login.keychain-db
echo "$IOS_DIST_KEY_BASE64" | base64 -d > "$RUNNER_TEMP/dist.key"
echo "$IOS_DIST_CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.cer"
security import "$RUNNER_TEMP/dist.key" -k "$KC" -T /usr/bin/codesign -A
security import "$RUNNER_TEMP/dist.cer" -k "$KC" -T /usr/bin/codesign -A
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KCPASS" "$KC" > /dev/null
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
echo "$IOS_PROFILE_APP_BASE64" | base64 -d > "$RUNNER_TEMP/app.mobileprovision"
UUID=$(security cms -D -i "$RUNNER_TEMP/app.mobileprovision" | plutil -extract UUID raw -o - -)
cp "$RUNNER_TEMP/app.mobileprovision" "$HOME/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision"
security find-identity -p codesigning "$KC"

- name: Make the Xcode project
if: env.SKIP != 'true'
run: |
brew install xcodegen
cd iosApp && xcodegen generate

- name: Archive
if: env.SKIP != 'true'
run: |
cd iosApp
# The build number comes from the date, so each run makes a higher one.
BUILD_NUMBER=$(date -u +%Y%m%d%H%M)
xcodebuild -project ChainCheck.xcodeproj -scheme ChainCheck \
-destination "generic/platform=iOS" \
-archivePath build/ChainCheck.xcarchive archive \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER"

- name: Upload to App Store Connect
if: env.SKIP != 'true'
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: |
cd iosApp
cat > build/ExportOptions.plist <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>signingStyle</key><string>manual</string>
<key>teamID</key><string>M7D6YHVDNK</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>com.glazkov.chaincheck</key><string>ChainCheck AppStore</string>
</dict>
<key>uploadSymbols</key><true/>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath build/ChainCheck.xcarchive \
-exportOptionsPlist build/ExportOptions.plist \
-exportPath build/export \
-authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8 \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID"

- name: Publish to the public group
if: env.SKIP != 'true'
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_APP_ID: ${{ secrets.ASC_APP_ID }}
ASC_GROUP_ID: ${{ secrets.ASC_GROUP_ID }}
run: python3 scripts/testflight.py finish
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ DerivedData/
build/ios/
# Signing/config that may carry a team id or key; keep local.
iosApp/Configuration/Config.xcconfig
# The Xcode project is generated from iosApp/project.yml with xcodegen.
iosApp/ChainCheck.xcodeproj/
# CocoaPods (only if the Mac setup uses Pods instead of SPM).
Pods/
*.xcworkspace/xcuserdata/
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import platform.UIKit.UIApplication
@Composable
actual fun PlatformMap(
data: MapData,
focus: MapFocus?,
layers: MapLayers,
onWebcamTap: (MapWebcam) -> Unit,
onNavigateTo: (lat: Double, lon: Double, label: String) -> Unit,
modifier: Modifier,
Expand Down
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ kotlin = "2.1.21"
composeMultiplatform = "1.8.2"
androidx-activity = "1.10.1"
androidx-fragment = "1.8.6"
coroutines = "1.11.0"
# 1.11.0 is compiled with Kotlin 2.2.20 and its iOS klibs are ABI-incompatible
# with Kotlin 2.1.21; stay on 1.10.2 until the Kotlin/CMP pair moves to 2.2.x.
coroutines = "1.10.2"
serialization = "1.8.1"
datetime = "0.6.2"
ktor = "3.1.3"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions iosApp/ChainCheck/Assets.xcassets/AppIcon.appiconset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
6 changes: 6 additions & 0 deletions iosApp/ChainCheck/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
15 changes: 15 additions & 0 deletions iosApp/ChainCheck/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import SwiftUI
import ComposeApp

struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
// MainViewController() is a top-level Kotlin function; Kotlin/Native
// exposes it on the file-name class MainViewControllerKt.
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}

struct ContentView: View {
var body: some View { ComposeView().ignoresSafeArea() }
}
46 changes: 46 additions & 0 deletions iosApp/ChainCheck/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>ChainCheck</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>ChainCheck uses your location to show nearby chain controls and cameras on the map.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
10 changes: 10 additions & 0 deletions iosApp/ChainCheck/iOSApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftUI

@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView().ignoresSafeArea(.all)
}
}
}
21 changes: 20 additions & 1 deletion iosApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ declarations have iOS `actual` implementations in `composeApp/src/iosMain/`.
| Map view | Placeholder text | `iosMain/.../ui/MapScreen.ios.kt` |
| Push token (FCM) | Stub returns `null` | `iosMain/.../push/PushToken.ios.kt` |
| App Check token | Stub returns `null` | `iosMain/.../data/AppCheck.ios.kt` |
| Xcode project | Not created yet | this directory |
| Xcode project | Done, generated with xcodegen | `project.yml` |

What this means: once the Xcode project embeds the framework, the app launches
and Home, Routes, Resorts, Trip brief, and Alerts all work against the live
Expand All @@ -39,6 +39,25 @@ enforcement is off (monitoring only); wire it when enforcement flips in October.
`composeApp/src/commonMain/.../data/Api.kt` and is public. No per-platform
backend work is needed; the JSON API is client agnostic.

## Build and release

The Xcode project is generated, not committed. To build locally:

```bash
brew install xcodegen # once
cd iosApp && xcodegen generate
xcodebuild -project ChainCheck.xcodeproj -scheme ChainCheck \
-destination "generic/platform=iOS Simulator" build
```

Release signing is manual: an Apple Distribution certificate plus the
"ChainCheck AppStore" provisioning profile, both created through the App Store
Connect API. The scheduled workflow `.github/workflows/testflight.yml`
re-archives and re-uploads a TestFlight build when the newest one has less
than 35 days of life left, using `scripts/testflight.py` for the API steps.
Secrets live in the GitHub repo settings (ASC key, dist cert and key, profile,
all base64).

## Step 1: create the Xcode project

On the Mac, create a SwiftUI app in this `iosApp/` directory (Xcode: New
Expand Down
60 changes: 60 additions & 0 deletions iosApp/project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# The Xcode project definition. Regenerate the project with: xcodegen generate
name: ChainCheck
options:
bundleIdPrefix: com.glazkov
deploymentTarget:
iOS: "16.0"
createIntermediateGroups: true

settings:
base:
DEVELOPMENT_TEAM: M7D6YHVDNK
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
TARGETED_DEVICE_FAMILY: "1"

targets:
ChainCheck:
type: application
platform: iOS
sources:
- path: ChainCheck
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.glazkov.chaincheck
INFOPLIST_FILE: ChainCheck/Info.plist
MARKETING_VERSION: "0.1.2"
CURRENT_PROJECT_VERSION: "1"
FRAMEWORK_SEARCH_PATHS:
- "$(inherited)"
- "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
OTHER_LDFLAGS:
- "$(inherited)"
- "-framework"
- "ComposeApp"
CODE_SIGN_STYLE: Automatic
ENABLE_USER_SCRIPT_SANDBOXING: NO
configs:
# The release archive signs manually with the profile from the App
# Store Connect API. Automatic signing without an Xcode account does
# not work on this machine.
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "Apple Distribution"
PROVISIONING_PROFILE_SPECIFIER: "ChainCheck AppStore"
preBuildScripts:
- name: Compile Kotlin Framework
script: |
cd "$SRCROOT/.."
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
basedOnDependencyAnalysis: false

schemes:
ChainCheck:
build:
targets:
ChainCheck: all
run:
config: Debug
archive:
config: Release
Loading