-
Notifications
You must be signed in to change notification settings - Fork 0
298 lines (273 loc) · 12.8 KB
/
Copy pathci.yml
File metadata and controls
298 lines (273 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
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"