Repository navigation
255 lines (239 loc) · 13.2 KB
/
Copy pathrelease.yml
File metadata and controls
255 lines (239 loc) · 13.2 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
name: Release
# Tag a version (for example v0.1.0) to archive the app for iOS and for macOS, sign both and upload them
# to TestFlight. Signs with one fixed Apple Distribution certificate (secrets DIST_CERT_P12_BASE64 and
# DIST_CERT_PASSWORD), so no run creates a certificate and Apple's certificate limit is never reached.
# The Mac upload also needs a Mac Installer Distribution certificate (MAC_INSTALLER_CERT_P12_BASE64 and
# MAC_INSTALLER_CERT_PASSWORD); without those secrets the Mac job says so and stops, and iOS still ships.
# Pull requests and forks never run this: it only fires on a tag pushed to this repository.
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
testflight:
name: TestFlight (${{ matrix.platform }})
if: github.repository == 'ericdahl-dev/ShowRecorder'
strategy:
# A Mac problem never stops the iPhone and iPad build, and the other way round.
fail-fast: false
matrix:
include:
- platform: ios
destination: generic/platform=iOS
profile_type: IOS_APP_STORE
profile_name: ShowRecorder CI App Store
asc_platform: IOS
# The Live Activity widget extension (#136), embedded in the iPhone app only.
extension_bundle_id: dev.ericdahl.ShowRecorder.LiveActivity
extension_profile_name: ShowRecorder CI Live Activity App Store
# The Apple Watch remote (#100), embedded in the iPhone app only.
watch_bundle_id: dev.ericdahl.ShowRecorder.watchkitapp
watch_profile_name: ShowRecorder CI Watch App Store
- platform: macos
destination: generic/platform=macOS
profile_type: MAC_APP_STORE
profile_name: ShowRecorder CI Mac App Store
asc_platform: MAC_OS
extension_bundle_id: ''
extension_profile_name: ''
watch_bundle_id: ''
watch_profile_name: ''
# Standard GitHub-hosted runner, as in ci.yml. Avoid -large/-xlarge (always billed).
runs-on: macos-26
timeout-minutes: 60
env:
# Secrets this workflow needs; see docs/DEVELOPING.md "Releasing to TestFlight".
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
BUNDLE_ID: dev.ericdahl.ShowRecorder
PROFILE_NAME: ${{ matrix.profile_name }}
PROFILE_TYPE: ${{ matrix.profile_type }}
EXTENSION_BUNDLE_ID: ${{ matrix.extension_bundle_id }}
EXTENSION_PROFILE_NAME: ${{ matrix.extension_profile_name }}
WATCH_BUNDLE_ID: ${{ matrix.watch_bundle_id }}
WATCH_PROFILE_NAME: ${{ matrix.watch_profile_name }}
PLATFORM: ${{ matrix.asc_platform }}
HAS_MAC_INSTALLER_CERT: ${{ secrets.MAC_INSTALLER_CERT_P12_BASE64 != '' }}
steps:
# The Mac ships in the Mac App Store from 1.1 (#374): a missing installer certificate fails the Mac job
# (the iPhone and iPad build still uploads, fail-fast is off) instead of skipping it quietly.
- name: Check the Mac installer certificate
if: matrix.platform == 'macos' && env.HAS_MAC_INSTALLER_CERT != 'true'
run: |
echo "::error::No Mac upload: add MAC_INSTALLER_CERT_P12_BASE64 and MAC_INSTALLER_CERT_PASSWORD (see docs/DEVELOPING.md)."
exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check the tag
run: |
# Only releases from main, or from a release branch (release/1.0) for a fix to a version in
# App Review, so a stray tag on a feature branch can't ship.
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main \
&& ! git branch -r --contains "$GITHUB_SHA" | grep -q '^ *origin/release/'; then
echo "::error::Tag ${GITHUB_REF_NAME} is not on main or a release/ branch. Tag a commit that has been merged."
exit 1
fi
# The build number is the run number plus the attempt, so re-running a tag
# gets a new, higher number instead of a duplicate. Marketing version is the tag.
echo "MARKETING_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
echo "BUILD_NUMBER=${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_ENV"
- name: Show toolchain
run: |
xcodebuild -version
swift --version
echo "Version ${MARKETING_VERSION} (${BUILD_NUMBER})"
- name: Write the App Store Connect key
env:
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
run: |
set +x
umask 077
mkdir -p "$RUNNER_TEMP/asc"
printf '%s\n' "$ASC_KEY_P8" > "$RUNNER_TEMP/asc/AuthKey_${ASC_KEY_ID}.p8"
echo "ASC_KEY_PATH=$RUNNER_TEMP/asc/AuthKey_${ASC_KEY_ID}.p8" >> "$GITHUB_ENV"
- name: Import the Apple Distribution certificate
env:
DIST_CERT_P12_BASE64: ${{ secrets.DIST_CERT_P12_BASE64 }}
DIST_CERT_PASSWORD: ${{ secrets.DIST_CERT_PASSWORD }}
MAC_INSTALLER_CERT_P12_BASE64: ${{ secrets.MAC_INSTALLER_CERT_P12_BASE64 }}
MAC_INSTALLER_CERT_PASSWORD: ${{ secrets.MAC_INSTALLER_CERT_PASSWORD }}
run: |
set +x
umask 077
KEYCHAIN="$RUNNER_TEMP/release.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
printf '%s' "$DIST_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 3600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
if [ "${{ matrix.platform }}" = macos ]; then
# The Mac upload is a signed installer package.
printf '%s' "$MAC_INSTALLER_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/installer.p12"
security import "$RUNNER_TEMP/installer.p12" -k "$KEYCHAIN" -P "$MAC_INSTALLER_CERT_PASSWORD" -T /usr/bin/productbuild -T /usr/bin/security
rm -f "$RUNNER_TEMP/installer.p12"
# When the installer certificate expires (it lasts a year); an expired one stops the Mac upload.
security find-certificate -c "3rd Party Mac Developer Installer" -p "$KEYCHAIN" | openssl x509 -noout -enddate \
| sed 's/^notAfter=/Mac installer certificate expires: /' || echo "::warning::Couldn't read the Mac installer certificate's expiry"
fi
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
rm -f "$RUNNER_TEMP/dist.p12"
echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"
# The certificate's serial number, to find it in App Store Connect.
SERIAL="$(security find-certificate -c "Apple Distribution" -p "$KEYCHAIN" | openssl x509 -noout -serial | cut -d= -f2)"
echo "CERT_SERIAL=$SERIAL" >> "$GITHUB_ENV"
security find-identity -v -p codesigning "$KEYCHAIN" | sed -E 's/[0-9A-F]{40}/<hash>/'
- name: Get the App Store provisioning profile (${{ matrix.profile_type }})
run: |
set +x
python3 scripts/ci_profile.py | tee "$RUNNER_TEMP/profile.txt"
echo "PROFILE_UUID=$(sed -n 's/^uuid=//p' "$RUNNER_TEMP/profile.txt")" >> "$GITHUB_ENV"
# The Live Activity extension has its own bundle ID and profile; registered on first use.
if [ -n "$EXTENSION_BUNDLE_ID" ]; then
BUNDLE_ID="$EXTENSION_BUNDLE_ID" PROFILE_NAME="$EXTENSION_PROFILE_NAME" CREATE_BUNDLE_ID=1 \
BUNDLE_NAME="ShowRecorder Live Activity" python3 scripts/ci_profile.py
fi
# So does the watch app (#100). Its bundle ID is an iOS one in App Store Connect.
if [ -n "$WATCH_BUNDLE_ID" ]; then
BUNDLE_ID="$WATCH_BUNDLE_ID" PROFILE_NAME="$WATCH_PROFILE_NAME" CREATE_BUNDLE_ID=1 \
BUNDLE_NAME="ShowRecorder Watch" python3 scripts/ci_profile.py
fi
- name: Archive
run: |
set -o pipefail
xcodebuild archive \
-project ShowRecorder.xcodeproj -scheme ShowRecorder -configuration Release \
-destination '${{ matrix.destination }}' \
-archivePath "$RUNNER_TEMP/ShowRecorder.xcarchive" \
DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution" \
APP_PROFILE_NAME="$PROFILE_NAME" EXTENSION_PROFILE_NAME="$EXTENSION_PROFILE_NAME" \
WATCH_PROFILE_NAME="$WATCH_PROFILE_NAME" \
OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN" \
MARKETING_VERSION="$MARKETING_VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
2>&1 | tee "$RUNNER_TEMP/archive.log"
- name: Check the archive before uploading
run: ./scripts/preflight.sh "$RUNNER_TEMP/ShowRecorder.xcarchive"
- name: Upload to TestFlight
run: |
set -o pipefail
INSTALLER=""
if [ "${{ matrix.platform }}" = macos ]; then
INSTALLER="<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>"
fi
EXTENSION_PROFILE=""
if [ -n "$EXTENSION_BUNDLE_ID" ]; then
EXTENSION_PROFILE="<key>${EXTENSION_BUNDLE_ID}</key><string>${EXTENSION_PROFILE_NAME}</string>"
fi
if [ -n "$WATCH_BUNDLE_ID" ]; then
EXTENSION_PROFILE="${EXTENSION_PROFILE}<key>${WATCH_BUNDLE_ID}</key><string>${WATCH_PROFILE_NAME}</string>"
fi
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?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-connect</string>
<key>destination</key><string>upload</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
${INSTALLER}
<key>provisioningProfiles</key>
<dict><key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>${EXTENSION_PROFILE}</dict>
<key>teamID</key><string>${DEVELOPMENT_TEAM}</string>
<key>uploadSymbols</key><true/>
<key>manageAppVersionAndBuildNumber</key><false/>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/ShowRecorder.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
-exportPath "$RUNNER_TEMP/export" \
-authenticationKeyPath "$ASC_KEY_PATH" \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID" \
2>&1 | tee "$RUNNER_TEMP/upload.log"
- name: Set What to Test and send the build to testers
# The upload has happened; a failure here prints a warning and never fails the release.
# A tag whose message contains [internal] goes to internal testers only, until
# scripts/testflight_promote.py sends it on (docs/DEVELOPING.md, "Internal-only tags").
continue-on-error: true
env:
TAG: ${{ github.ref_name }}
run: |
# The checkout can leave the tag lightweight, without its message: fetch the tag itself. Only an annotated
# tag has a message of its own (a lightweight one would read its commit's). If the fetch fails, nobody
# can tell whether the tag was meant to be internal, so it is treated as internal: it never reaches
# external testers by mistake, and testflight_promote.py sends it on.
TAG_MESSAGE=""
if ! git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"; then
echo "::warning::Couldn't read the tag's message; sending this build to internal testers only."
TAG_MESSAGE="[internal] (the tag's message couldn't be read)"
elif [ "$(git for-each-ref --format='%(objecttype)' "refs/tags/$TAG")" = tag ]; then
TAG_MESSAGE="$(git for-each-ref --format='%(contents)' "refs/tags/$TAG")"
fi
TAG_MESSAGE="$TAG_MESSAGE" python3 scripts/testflight_notes.py
- name: Remove the keys and the keychain
if: always()
run: |
rm -rf "$RUNNER_TEMP/asc" "$RUNNER_TEMP/dist.p12" "$RUNNER_TEMP/installer.p12"
[ -n "$KEYCHAIN" ] && security delete-keychain "$KEYCHAIN" || true
rm -f ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/*.provisionprofile || true
rm -f ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.mobileprovision ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.provisionprofile || true
- name: Keep the logs when something failed
if: failure()
uses: actions/upload-artifact@v4
with:
name: release-logs-${{ matrix.platform }}
path: |
${{ runner.temp }}/archive.log
${{ runner.temp }}/upload.log
retention-days: 14