Skip to content

Rebuild apps.html and android-privacy.html, which the pipeline had stopped touching #152

Rebuild apps.html and android-privacy.html, which the pipeline had stopped touching

Rebuild apps.html and android-privacy.html, which the pipeline had stopped touching #152

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
# 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"