Test fixtures for the file importer, and a listing that matches the app #50
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 | |
| 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 | |
| # Unsigned: proves the Swift bridge and the pod install are sound without | |
| # needing certificates. Signing comes in with the TestFlight job below. | |
| - name: Build (no codesign) | |
| run: flutter build ios --release --no-codesign | |
| - 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. | |
| # | |
| # 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" |