Record the advert footage in a simulator, like the screenshots #142
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: CI | |
| # Split deliberately by cost. Analyse and test run on Linux, which is free and | |
| # fast, and cover everything that is pure Dart — including the guide-link | |
| # encoder, which is checked byte-for-byte against links confirmed working on a | |
| # physical iPhone. Only the iOS build needs a macOS runner. | |
| # | |
| # There is no Mac in this project, so the macOS job is the only place the Swift | |
| # OCR bridge ever compiles. Expect it to be where breakages surface. | |
| on: | |
| push: | |
| branches: [main] | |
| tags: ['v*'] | |
| pull_request: | |
| workflow_dispatch: | |
| inputs: | |
| testflight: | |
| description: 'Also build signed and upload to TestFlight' | |
| type: boolean | |
| default: false | |
| env: | |
| FLUTTER_VERSION: '3.44.8' | |
| jobs: | |
| # This repository is PUBLIC, so a committed credential is disclosed the moment | |
| # it is pushed. CI secrets are fine — they are encrypted and never exposed to | |
| # fork PRs — but nothing sensitive may exist as a file. This job fails the | |
| # build rather than letting one through. | |
| secrets: | |
| name: No credentials committed | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Reject credential file types | |
| run: | | |
| found=$(git ls-files | grep -E '\.(p8|p12|pem|key|keystore|jks|mobileprovision)$' || true) | |
| if [ -n "$found" ]; then | |
| echo "::error::credential files must never be committed to a public repo:" | |
| echo "$found" | |
| exit 1 | |
| fi | |
| echo "no credential file types present" | |
| - name: Reject key material and tokens in tracked content | |
| run: | | |
| if git grep -InE -- '-----BEGIN [A-Z ]*PRIVATE KEY|gh[pousr]_[A-Za-z0-9]{20}|github_pat_[A-Za-z0-9_]{20}|xox[baprs]-|AKIA[0-9A-Z]{16}' -- . ':(exclude).github/workflows/*'; then | |
| echo "::error::key material or a token appears in tracked files" | |
| exit 1 | |
| fi | |
| echo "no key material in tracked files" | |
| - name: Warn on anything that looks like a personal screenshot | |
| run: | | |
| found=$(git ls-files 'real/*' 'screenshots/*' | grep -viE '\.md$' || true) | |
| if [ -n "$found" ]; then | |
| echo "::error::personal screenshots are gitignored for a reason:" | |
| echo "$found" | |
| exit 1 | |
| fi | |
| echo "no screenshots committed" | |
| dart: | |
| name: Analyse and test | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: ${{ env.FLUTTER_VERSION }} | |
| channel: stable | |
| cache: true | |
| - run: flutter pub get | |
| - run: dart format --output=none --set-exit-if-changed lib test | |
| - run: flutter analyze | |
| - run: flutter test --reporter expanded | |
| # The Worker that issues complimentary codes. Its one testable half is token | |
| # verification, which decides whether a phone holding an admin code may issue | |
| # or withdraw codes at all — a verifier that said yes to anything would make | |
| # every check behind it decorative, and one that said no to everything would | |
| # look exactly like a withdrawn code. Node implements the same WebCrypto | |
| # Ed25519 the Worker runtime does, so it can be checked without deploying. | |
| worker: | |
| name: Test the codes Worker | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| # No dependencies and no lockfile: node:test is built in, so there is | |
| # nothing to install and nothing to keep up to date. | |
| - run: node --test | |
| working-directory: server/comp-codes-worker | |
| ios: | |
| name: Build iOS | |
| runs-on: macos-26 | |
| needs: [secrets, dart] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: ${{ env.FLUTTER_VERSION }} | |
| channel: stable | |
| cache: true | |
| - run: flutter pub get | |
| # The share extension is a second Xcode target, and there is no Xcode on | |
| # the machine this app is written on. Rather than hand-edit | |
| # project.pbxproj — a UUID cross-referenced graph, not a config file — the | |
| # target is generated here by a tool that understands the format. Runs on | |
| # every build and is idempotent, so the committed project file never has to | |
| # carry it. | |
| - name: Add the share extension target | |
| run: | | |
| gem install --no-document xcodeproj | |
| ruby ios/add_share_extension.rb | |
| # Prove it landed, rather than trusting the script's own word. | |
| grep -q 'ShareExtension' ios/Runner.xcodeproj/project.pbxproj | |
| echo "target present in the project file" | |
| # Unsigned: proves the Swift bridge and the pod install are sound without | |
| # needing certificates. Signing comes in with the TestFlight job below. | |
| # | |
| # This now also compiles the share extension, which is the check that | |
| # matters most for it — the extension has no tests and cannot run here. | |
| - name: Build (no codesign) | |
| run: flutter build ios --release --no-codesign | |
| # A green build says the compile worked, not that the extension shipped | |
| # inside the app. An appex that is built and never embedded produces an app | |
| # that installs, runs, and simply never appears in the share sheet — which | |
| # is indistinguishable, from the outside, from not having written it. | |
| - name: Prove the extension is embedded | |
| run: | | |
| APPEX="build/ios/iphoneos/Runner.app/PlugIns/ShareExtension.appex" | |
| test -d "$APPEX" || { echo "::error::no appex at $APPEX"; ls -R build/ios/iphoneos/Runner.app/PlugIns 2>/dev/null || true; exit 1; } | |
| POINT=$(plutil -extract NSExtension.NSExtensionPointIdentifier raw "$APPEX/Info.plist") | |
| test "$POINT" = "com.apple.share-services" || { echo "::error::wrong extension point: $POINT"; exit 1; } | |
| URLS=$(plutil -extract NSExtension.NSExtensionAttributes.NSExtensionActivationRule.NSExtensionActivationSupportsWebURLWithMaxCount raw "$APPEX/Info.plist") | |
| test "$URLS" = "1" || { echo "::error::not activated for web URLs: $URLS"; exit 1; } | |
| echo "embedded, share-services, activates on 1 web URL" | |
| plutil -extract CFBundleIdentifier raw "$APPEX/Info.plist" | |
| - name: Report app size | |
| run: | | |
| du -sh build/ios/iphoneos/Runner.app | |
| echo "Built unsigned. Add signing secrets to ship to TestFlight." | |
| # Without a Mac this is the device-testing loop — the only way the app reaches | |
| # a phone. The sequence is ported from the mobile-companion project, which has | |
| # shipped builds through it, including its hard-won manual-signing fix. | |
| # | |
| # Skips itself cleanly until the secrets exist, so CI stays green meanwhile. | |
| # | |
| # Reused from the existing Apple account (no need to mint again): | |
| # IOS_P12_BASE64, IOS_P12_PASSWORD Apple Distribution cert W68LQJ5JQ8 | |
| # APPLE_TEAM_ID 7WA4F8P743 | |
| # ASC_KEY_ID, ASC_ISSUER_ID, ASC_API_KEY_P8_BASE64 App Manager API key | |
| # New, because a profile is bound to one bundle id: | |
| # IOS_PROVISION_PROFILE_BASE64, IOS_PROVISION_PROFILE_NAME | |
| # Deliberately NOT on every push. Shipping a build per commit burns macOS | |
| # minutes, churns build numbers and fills TestFlight with noise — one change | |
| # produced two builds before this gate existed. | |
| # | |
| android: | |
| name: Build Android | |
| # Ubuntu, not macOS: the Android toolchain needs no Apple hardware. | |
| runs-on: ubuntu-latest | |
| needs: [secrets, dart] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: '17' | |
| - uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: ${{ env.FLUTTER_VERSION }} | |
| channel: stable | |
| cache: true | |
| - run: flutter pub get | |
| # The only check that knows whether the Kotlin share bridge compiles, | |
| # and the only one that runs on every push. | |
| # | |
| # Gradle did fail on the machine this app is written on, with | |
| # "Unable to establish loopback connection", which is not about loopback: | |
| # an AF_UNIX socket binds under %LOCALAPPDATA%\Temp there and cannot be | |
| # connected to, so Selector.open() throws and the daemon and its client | |
| # never meet. Fixed on 2026-08-20 by pointing | |
| # -Djdk.net.unixdomain.tmpdir at a directory outside that tree, so a | |
| # local build is possible again -- but CI stays the authority, because it | |
| # is the only Android build anyone is obliged to look at. | |
| - name: Build (debug, unsigned) | |
| run: flutter build apk --debug | |
| # A green build says the Kotlin compiled, not that the export path is | |
| # wired. These assert the things whose absence fails SILENTLY at runtime: | |
| # no provider and every share dies with EACCES; the androidx provider | |
| # rather than ours and a GPX is handed over as application/octet-stream; | |
| # no <queries> and every named button vanishes on Android 11+; no paths | |
| # file and the provider throws on first use. The Dart test | |
| # test/map_targets_test.dart checks the other half — that every package | |
| # Wren offers is actually declared here. | |
| - name: Prove the share plumbing is declared | |
| run: | | |
| M=android/app/src/main/AndroidManifest.xml | |
| P=android/app/src/main/res/xml/file_paths.xml | |
| grep -q 'android:name="\.PlaceFileProvider"' "$M" || { echo "::error::the provider must be our subclass — androidx's FileProvider types a GPX as application/octet-stream, because Android's mime table has no gpx entry"; exit 1; } | |
| grep -q 'android:grantUriPermissions="true"' "$M" || { echo "::error::without grantUriPermissions every FLAG_GRANT_READ_URI_PERMISSION is a no-op and the receiver gets a SecurityException"; exit 1; } | |
| grep -q 'FILE_PROVIDER_PATHS' "$M" || { echo "::error::the provider has no paths meta-data"; exit 1; } | |
| grep -q '<queries>' "$M" || { echo "::error::no <queries> — package visibility hides every map app on Android 11+"; exit 1; } | |
| test -f "$P" || { echo "::error::missing res/xml/file_paths.xml"; exit 1; } | |
| grep -q '<files-path' "$P" || { echo "::error::exports must live in filesDir, not the reclaimable cache — a receiver that defers the import can find the bytes gone"; exit 1; } | |
| grep -qr 'androidx.browser.customtabs' android/app/src/main/kotlin || { echo "::error::no Custom Tabs import — the Google Maps route needs a real Chrome, because Google blocks sign-in in a WebView"; exit 1; } | |
| echo "provider, paths, queries and Custom Tabs all present" | |
| # Every android:name=".Foo" in the manifest is shorthand for | |
| # <namespace>.Foo, and nothing in a debug build checks that the class is | |
| # really there. Get it wrong and the APK builds, installs, and dies on | |
| # launch with "Activity class does not exist" -- which is exactly what | |
| # happened here: the namespace was renamed to com.spencerfields.littlebird | |
| # while the Kotlin stayed in the old com.spencerfields.reel_places package, | |
| # so the Android app could not start at all and no test said so. | |
| - name: Prove every manifest class exists | |
| run: | | |
| M=android/app/src/main/AndroidManifest.xml | |
| NS=$(grep -oP 'namespace\s*=\s*"\K[^"]+' android/app/build.gradle.kts) | |
| [ -n "$NS" ] || { echo "::error::could not read the namespace from build.gradle.kts"; exit 1; } | |
| DIR="android/app/src/main/kotlin/$(echo "$NS" | tr . /)" | |
| echo "namespace $NS -> $DIR" | |
| fail=0 | |
| for cls in $(grep -oP 'android:name="\.\K[A-Za-z0-9_]+' "$M" | sort -u); do | |
| if [ -f "$DIR/$cls.kt" ] || [ -f "$DIR/$cls.java" ]; then | |
| echo " ok $NS.$cls" | |
| else | |
| echo "::error::$M names .$cls, which resolves to $NS.$cls -- no such class in $DIR. The APK will build and then fail to launch." | |
| fail=1 | |
| fi | |
| done | |
| # And the reverse: a class whose package line disagrees with the | |
| # namespace compiles perfectly and is unreachable from the manifest. | |
| shopt -s nullglob | |
| found=0 | |
| for f in $DIR/*.kt; do | |
| found=1 | |
| grep -q "^package $NS\$" "$f" || { echo "::error::$f does not declare 'package $NS'"; fail=1; } | |
| done | |
| [ "$found" = 1 ] || { echo "::error::no Kotlin at all in $DIR -- the sources are in some other package and nothing the manifest names can resolve"; fail=1; } | |
| exit $fail | |
| # What the app asks for is decided by its dependencies, not by its own | |
| # manifest, and nothing in the source tree shows the result. Three | |
| # permissions arrived that way: BILLING from the billing library, and | |
| # INTERNET plus ACCESS_NETWORK_STATE from the Google datatransport it | |
| # depends on. | |
| # | |
| # INTERNET is now genuinely needed -- the system geocoder turns a name | |
| # read off a screenshot into a coordinate, and it answers over the | |
| # network -- so it is declared deliberately in the app manifest rather | |
| # than inherited. BILLING is wanted too, since 2026-08-28: Android sells | |
| # the same unlock as iOS, so it is declared and asserted present below. | |
| # Only ACCESS_NETWORK_STATE is removed -- nothing here reads it. | |
| # | |
| # This reads what was BUILT, for both variants, because the source | |
| # manifest cannot show what a dependency added. | |
| - name: Prove the app asks only for what it uses | |
| run: | | |
| set -e | |
| ( cd android && ./gradlew --no-daemon -q :app:processReleaseMainManifest ) | |
| R=build/app/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml | |
| [ -f "$R" ] || { echo "::error::no release manifest at $R — the checks below would pass by finding nothing"; exit 1; } | |
| echo "release: $R"; grep -o 'uses-permission android:name="[^"]*"' "$R" || echo " (none)" | |
| fail=0 | |
| for perm in android.permission.ACCESS_NETWORK_STATE; do | |
| if grep -q "\"$perm\"" "$R"; then | |
| echo "::error::the release manifest declares $perm, which nothing in Wren uses. It came from a dependency — restore the matching tools:node=\"remove\" line in android/app/src/main/AndroidManifest.xml." | |
| fail=1 | |
| fi | |
| done | |
| # And the one that must be there. Without it every lookup fails with | |
| # a SecurityException at run time and nothing at build time. | |
| grep -q '"android.permission.INTERNET"' "$R" || { | |
| echo "::error::the release manifest has no INTERNET. The geocoder needs it; without it every place lookup fails on a device and no test here would notice." | |
| fail=1 | |
| } | |
| # And the one that used to be forbidden. Android sells the same | |
| # unlock as iOS now, so a BillingClient is constructed for real and | |
| # the permission must survive the merge. Without it billing fails | |
| # when a user taps Unlock, not when CI builds. | |
| grep -q '"com.android.vending.BILLING"' "$R" || { | |
| echo "::error::the release manifest has no BILLING. Android sells the unlock, so in_app_purchase needs it — a billing call without the permission fails at connection time on a device, silently, for every buyer." | |
| fail=1 | |
| } | |
| # A place name is looked up; the phone's own position never is. | |
| for perm in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION; do | |
| if grep -q "$perm" "$R"; then | |
| echo "::error::the release manifest declares $perm. Wren never asks where the phone is, only where a place named in a screenshot is, and a location permission on the listing says otherwise." | |
| fail=1 | |
| fi | |
| done | |
| [ "$fail" = 0 ] && echo "asks for INTERNET and BILLING, and nothing else" | |
| exit $fail | |
| - name: Report APK size | |
| run: du -sh build/app/outputs/flutter-apk/app-debug.apk | |
| # The APK is kept because this job is the only place it can be built, and a | |
| # compiled hand-off is not a working one -- proving that needs the real | |
| # binary on a real Android system, driven through its own UI. | |
| - name: Keep the APK for device testing | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: app-debug-apk | |
| path: build/app/outputs/flutter-apk/app-debug.apk | |
| retention-days: 7 | |
| # Ships when you ask for it: | |
| # gh workflow run ci.yml -f testflight=true | |
| # or on a version tag: | |
| # git tag v1.0.1 && git push origin v1.0.1 | |
| testflight: | |
| name: TestFlight | |
| runs-on: macos-26 | |
| needs: ios | |
| # `inputs.testflight` is a real boolean because the input is typed, so it is | |
| # used directly — comparing it to the string 'true' silently never matches. | |
| if: >- | |
| (github.event_name == 'workflow_dispatch' && inputs.testflight) || | |
| startsWith(github.ref, 'refs/tags/v') | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: ${{ env.FLUTTER_VERSION }} | |
| channel: stable | |
| cache: true | |
| - run: flutter pub get | |
| - id: signing | |
| env: | |
| P12_B64: ${{ secrets.IOS_P12_BASE64 }} | |
| ASC_KEY: ${{ secrets.ASC_API_KEY_P8_BASE64 }} | |
| run: | | |
| [ -n "$P12_B64" ] && echo "ready=true" >> "$GITHUB_OUTPUT" || echo "ready=false" >> "$GITHUB_OUTPUT" | |
| [ -n "$ASC_KEY" ] && echo "upload=true" >> "$GITHUB_OUTPUT" || echo "upload=false" >> "$GITHUB_OUTPUT" | |
| [ -n "$P12_B64" ] || echo "::notice::signing secrets absent — skipping the signed build" | |
| # Fail in seconds rather than after a full archive and a long upload. | |
| # altool reports a bad key as a generic "Failure to authenticate", which | |
| # says nothing about which of the three ASC secrets is wrong — so check | |
| # them against the API first and say so plainly. | |
| - name: Check the App Store Connect key actually authenticates | |
| if: steps.signing.outputs.upload == 'true' | |
| env: | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} | |
| run: | | |
| # macOS runners enforce PEP 668, so installing into the system Python | |
| # is refused. A throwaway venv keeps it isolated and avoids | |
| # --break-system-packages. | |
| python3 -m venv /tmp/ascvenv | |
| /tmp/ascvenv/bin/pip install --quiet pyjwt cryptography | |
| /tmp/ascvenv/bin/python - <<'PY' | |
| import base64, json, os, sys, time, urllib.request, urllib.error | |
| import jwt | |
| key_id = os.environ["ASC_KEY_ID"].strip() | |
| issuer = os.environ["ASC_ISSUER_ID"].strip() | |
| try: | |
| pem = base64.b64decode(os.environ["ASC_API_KEY_P8_BASE64"]).decode() | |
| except Exception as e: | |
| sys.exit(f"::error::ASC_API_KEY_P8_BASE64 is not valid base64 ({e})") | |
| if "PRIVATE KEY" not in pem: | |
| sys.exit("::error::ASC_API_KEY_P8_BASE64 does not decode to a PEM " | |
| "private key. Did you base64 the .p8 itself?") | |
| now = int(time.time()) | |
| token = jwt.encode({"iss": issuer, "iat": now, "exp": now + 300, | |
| "aud": "appstoreconnect-v1"}, | |
| pem, algorithm="ES256", | |
| headers={"kid": key_id, "typ": "JWT"}) | |
| req = urllib.request.Request( | |
| "https://api.appstoreconnect.apple.com/v1/apps?limit=1", | |
| headers={"Authorization": f"Bearer {token}"}) | |
| try: | |
| with urllib.request.urlopen(req, timeout=30) as r: | |
| print(f"key {key_id} authenticates (HTTP {r.status})") | |
| except urllib.error.HTTPError as e: | |
| sys.exit( | |
| f"::error::ASC key {key_id} was rejected (HTTP {e.code}). The " | |
| "three secrets must agree: ASC_API_KEY_P8_BASE64 must be the " | |
| f".p8 whose Key ID is {key_id}, and ASC_ISSUER_ID must be the " | |
| "issuer from the same App Store Connect tab. An APNs or " | |
| "In-App-Purchase key will fail here.") | |
| PY | |
| - name: Import certificate and provisioning profile | |
| if: steps.signing.outputs.ready == 'true' | |
| env: | |
| P12_B64: ${{ secrets.IOS_P12_BASE64 }} | |
| P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }} | |
| PROFILE_B64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} | |
| run: | | |
| KEYCHAIN=build.keychain | |
| security create-keychain -p actions "$KEYCHAIN" | |
| security default-keychain -s "$KEYCHAIN" | |
| security unlock-keychain -p actions "$KEYCHAIN" | |
| security set-keychain-settings -t 3600 -u "$KEYCHAIN" | |
| echo "$P12_B64" | base64 --decode > cert.p12 | |
| security import cert.p12 -k "$KEYCHAIN" -P "$P12_PASSWORD" -T /usr/bin/codesign | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k actions "$KEYCHAIN" | |
| rm -f cert.p12 | |
| mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" | |
| echo "$PROFILE_B64" | base64 --decode > profile.mobileprovision | |
| UUID=$(security cms -D -i profile.mobileprovision | plutil -extract UUID raw -) | |
| cp profile.mobileprovision "$HOME/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision" | |
| - name: Write ExportOptions.plist | |
| if: steps.signing.outputs.ready == 'true' | |
| env: | |
| TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| PROFILE_NAME: ${{ secrets.IOS_PROVISION_PROFILE_NAME }} | |
| run: | | |
| cat > ios/ExportOptions.plist <<EOF | |
| <?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</string> | |
| <key>teamID</key><string>$TEAM_ID</string> | |
| <key>signingStyle</key><string>manual</string> | |
| <key>provisioningProfiles</key><dict> | |
| <key>com.spencerfields.littlebird</key> | |
| <string>$PROFILE_NAME</string> | |
| </dict> | |
| <key>uploadSymbols</key><true/> | |
| </dict></plist> | |
| EOF | |
| # flutter create leaves the project on automatic signing, which needs an | |
| # Apple account in Xcode and therefore fails on a runner. Forcing manual | |
| # signing is the fix that took a while to find on the last project. | |
| - name: Force manual code signing | |
| if: steps.signing.outputs.ready == 'true' | |
| env: | |
| TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| PROFILE_NAME: ${{ secrets.IOS_PROVISION_PROFILE_NAME }} | |
| run: | | |
| sed -i '' 's/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/g' ios/Runner.xcodeproj/project.pbxproj | |
| sed -i '' 's/"iPhone Developer"/"Apple Distribution"/g' ios/Runner.xcodeproj/project.pbxproj | |
| { | |
| echo "CODE_SIGN_STYLE=Manual" | |
| echo "DEVELOPMENT_TEAM=$TEAM_ID" | |
| echo "PROVISIONING_PROFILE_SPECIFIER=$PROFILE_NAME" | |
| echo "CODE_SIGN_IDENTITY=Apple Distribution" | |
| } >> ios/Flutter/Release.xcconfig | |
| # No unlock code is compiled in any more. Complimentary codes are issued | |
| # and checked by the Worker, so there is no secret for this build to | |
| # carry, a fork gains nothing by building it, and rotating a code no | |
| # longer needs a release. The WREN_REVIEW_CODE repository secret is now | |
| # unused and can be deleted. | |
| - name: Build signed IPA | |
| if: steps.signing.outputs.ready == 'true' | |
| run: | | |
| flutter build ipa --release \ | |
| --export-options-plist=ios/ExportOptions.plist \ | |
| --build-number=${{ github.run_number }} | |
| - name: Keep the IPA even if upload fails | |
| if: steps.signing.outputs.ready == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ipa | |
| path: build/ios/ipa/*.ipa | |
| if-no-files-found: warn | |
| # API key rather than an app-specific password: fewer secrets, and it is | |
| # the path the desktop project already moved to. | |
| - name: Upload to TestFlight | |
| if: steps.signing.outputs.ready == 'true' && steps.signing.outputs.upload == 'true' | |
| env: | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} | |
| run: | | |
| mkdir -p "$HOME/.appstoreconnect/private_keys" | |
| echo "$ASC_API_KEY_P8_BASE64" | base64 --decode \ | |
| > "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" | |
| IPA=$(ls build/ios/ipa/*.ipa | head -1) | |
| xcrun altool --upload-app -f "$IPA" -t ios \ | |
| --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" | |
| rm -f "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" |