Skip to content

@

@ #64

Workflow file for this run

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"