Skip to content

Commit 9f6f8e3

Browse files
authored
Run iOS CI on GitHub-hosted macOS (#4359)
1 parent ea5dc44 commit 9f6f8e3

164 files changed

Lines changed: 408 additions & 43434 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ios-ci.yml

Lines changed: 42 additions & 32 deletions
Large diffs are not rendered by default.

.github/workflows/ios-release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ on:
1717

1818
jobs:
1919
ios-build-dynamic:
20-
runs-on: macos-14
20+
runs-on: macos-26
2121
defaults:
2222
run:
2323
working-directory: platform/ios
@@ -59,7 +59,7 @@ jobs:
5959
MapLibre_ios_device.framework.dSYM.zip
6060
6161
ios-build-static:
62-
runs-on: macos-14
62+
runs-on: macos-15
6363
defaults:
6464
run:
6565
working-directory: platform/ios

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,6 @@
113113
[submodule "vendor/wgpu-native"]
114114
path = vendor/wgpu-native
115115
url = https://github.com/gfx-rs/wgpu-native.git
116+
[submodule "vendor/metal-cpp"]
117+
path = vendor/metal-cpp
118+
url = https://github.com/apple/metal-cpp

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ repos:
3232
- id: buildifier
3333

3434
- repo: https://github.com/Mateusz-Grzelinski/actionlint-py
35-
rev: v1.7.7.23
35+
rev: v1.7.12.24
3636
hooks:
3737
- id: actionlint
3838
additional_dependencies: [ shellcheck-py ]

platform/darwin/test/MLNOfflinePackTests.mm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ - (void)testInvalidatingAnInvalidPack {
5252
- (void)testProgressBoxing {
5353
MLNOfflinePackProgress progress = {
5454
.countOfResourcesCompleted = 3,
55-
.countOfResourcesExpected = 2,
5655
.countOfBytesCompleted = 7,
5756
.countOfTilesCompleted = 1,
5857
.countOfTileBytesCompleted = 6,
58+
.countOfResourcesExpected = 2,
5959
.maximumResourcesExpected = UINT64_MAX,
6060
};
6161
MLNOfflinePackProgress roundTrippedProgress =
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
#!/usr/bin/env bash
2+
3+
# Create iOS development signing assets for CI and print them as YAML.
4+
#
5+
# Usage:
6+
# APPSTORE_ISSUER_ID=<issuer-uuid> \
7+
# APPSTORE_KEY_ID=<key-id> \
8+
# APPSTORE_PRIVATE_KEY="$(cat AuthKey_<key-id>.p8)" \
9+
# platform/ios/scripts/ios-ci-create-signing-assets.sh
10+
#
11+
# Optional environment:
12+
# IOS_BUNDLE_ID_PREFIX=org.maplibre
13+
# IOS_CI_RECREATE_DEVELOPMENT_CERTIFICATE=1
14+
#
15+
# The script creates a wildcard iOS Development provisioning profile for
16+
# "${IOS_BUNDLE_ID_PREFIX}.*" and prints the certificate, profile, passwords,
17+
# and bundle ID prefix as YAML for embedding in ios-ci.yml.
18+
19+
set -euo pipefail
20+
21+
bundle_id_prefix="${IOS_BUNDLE_ID_PREFIX:-org.maplibre}"
22+
profile_name="${IOS_PROFILE_NAME:-MapLibre iOS CI Wildcard Development}"
23+
certificate_name="${IOS_CERTIFICATE_NAME:-MapLibre iOS CI}"
24+
25+
require_env() {
26+
local name="$1"
27+
if [[ -z "${!name:-}" ]]; then
28+
echo "$name is required" >&2
29+
exit 1
30+
fi
31+
}
32+
33+
json_get() {
34+
ruby -rjson -e 'path = ARGV.map { |item| item.match?(/\A\d+\z/) ? item.to_i : item }; value = JSON.parse(STDIN.read).dig(*path); puts value if value' "$@"
35+
}
36+
37+
json_array_ids() {
38+
ruby -rjson -e 'puts JSON.parse(STDIN.read).fetch("data", []).map { |item| item.fetch("id") }'
39+
}
40+
41+
json_api_created_certificate_ids() {
42+
ruby -rjson -e '
43+
JSON.parse(STDIN.read).fetch("data", []).each do |item|
44+
attrs = item.fetch("attributes")
45+
puts item.fetch("id") if attrs["certificateType"] == "IOS_DEVELOPMENT" && attrs["displayName"] == "Created via API"
46+
end
47+
'
48+
}
49+
50+
json_profile_ids_with_name() {
51+
ruby -rjson -e '
52+
profile_name = ARGV.fetch(0)
53+
JSON.parse(STDIN.read).fetch("data", []).each do |item|
54+
attrs = item.fetch("attributes")
55+
puts item.fetch("id") if attrs["name"] == profile_name
56+
end
57+
' -- "$1"
58+
}
59+
60+
urlencode() {
61+
ruby -rcgi -e 'print CGI.escape(ARGV.fetch(0))' "$1"
62+
}
63+
64+
base64_one_line() {
65+
if base64 --help 2>&1 | grep -q -- '-w'; then
66+
base64 -w 0 "$1"
67+
else
68+
base64 -i "$1" | tr -d '\n'
69+
fi
70+
}
71+
72+
make_jwt() {
73+
ruby -ropenssl -rjson -rbase64 -e '
74+
def b64url(value)
75+
Base64.urlsafe_encode64(value).delete("=")
76+
end
77+
78+
issuer_id = ENV.fetch("APPSTORE_ISSUER_ID")
79+
key_id = ENV.fetch("APPSTORE_KEY_ID")
80+
private_key = OpenSSL::PKey.read(ENV.fetch("APPSTORE_PRIVATE_KEY"))
81+
now = Time.now.to_i
82+
header = { "alg" => "ES256", "kid" => key_id, "typ" => "JWT" }
83+
claims = { "iss" => issuer_id, "iat" => now - 60, "exp" => now + 1200, "aud" => "appstoreconnect-v1" }
84+
signing_input = [b64url(header.to_json), b64url(claims.to_json)].join(".")
85+
der_signature = private_key.sign(OpenSSL::Digest::SHA256.new, signing_input)
86+
sequence = OpenSSL::ASN1.decode(der_signature)
87+
raw_signature = sequence.value.map { |integer| integer.value.to_s(2).rjust(32, "\x00") }.join
88+
puts [signing_input, b64url(raw_signature)].join(".")
89+
'
90+
}
91+
92+
asc_request() {
93+
local method="$1"
94+
local path="$2"
95+
local body="${3:-}"
96+
local url="https://api.appstoreconnect.apple.com$path"
97+
local response_path
98+
local status
99+
local attempt=0
100+
local max_attempts=4
101+
local delay=5
102+
103+
response_path="$(mktemp)"
104+
105+
while true; do
106+
attempt=$(( attempt + 1 ))
107+
108+
if [[ -n "$body" ]]; then
109+
status="$(curl -sS \
110+
-X "$method" \
111+
-H "Authorization: Bearer $ASC_TOKEN" \
112+
-H "Content-Type: application/json" \
113+
-d "$body" \
114+
-o "$response_path" \
115+
-w "%{http_code}" \
116+
"$url")"
117+
else
118+
status="$(curl -sS \
119+
-X "$method" \
120+
-H "Authorization: Bearer $ASC_TOKEN" \
121+
-H "Content-Type: application/json" \
122+
-o "$response_path" \
123+
-w "%{http_code}" \
124+
"$url")"
125+
fi
126+
127+
echo "$status" > "${asc_status_file:-/dev/null}"
128+
129+
if [[ "$status" -ge 500 && "$attempt" -lt "$max_attempts" ]]; then
130+
echo "App Store Connect request failed: $method $path returned HTTP $status (attempt $attempt/$max_attempts, retrying in ${delay}s)" >&2
131+
cat "$response_path" >&2
132+
sleep "$delay"
133+
delay=$(( delay * 2 ))
134+
continue
135+
fi
136+
137+
break
138+
done
139+
140+
if [[ "$status" -lt 200 || "$status" -ge 300 ]]; then
141+
echo "App Store Connect request failed: $method $path returned HTTP $status" >&2
142+
if [[ "$path" == "/v1/certificates" && "$status" == "403" ]]; then
143+
echo "Check that the API key has access to Certificates, Identifiers & Profiles." >&2
144+
fi
145+
cat "$response_path" >&2
146+
rm -f "$response_path"
147+
return 1
148+
fi
149+
150+
cat "$response_path"
151+
rm -f "$response_path"
152+
}
153+
154+
require_env APPSTORE_ISSUER_ID
155+
require_env APPSTORE_KEY_ID
156+
require_env APPSTORE_PRIVATE_KEY
157+
158+
command -v openssl >/dev/null || {
159+
echo "openssl is required" >&2
160+
exit 1
161+
}
162+
163+
work_dir="$(mktemp -d)"
164+
trap 'rm -rf "$work_dir"' EXIT
165+
asc_status_file="$work_dir/asc_status"
166+
167+
p12_password="${P12_PASSWORD:-$(openssl rand -base64 32)}"
168+
keychain_password="${KEYCHAIN_PASSWORD:-$(openssl rand -base64 32)}"
169+
wildcard_identifier="$bundle_id_prefix.*"
170+
171+
private_key_path="$work_dir/ios-ci.key"
172+
csr_path="$work_dir/ios-ci.csr"
173+
certificate_der_path="$work_dir/ios-ci.cer"
174+
certificate_pem_path="$work_dir/ios-ci.pem"
175+
p12_path="$work_dir/ios-ci.p12"
176+
profile_path="$work_dir/ios-ci.mobileprovision"
177+
178+
openssl genrsa -out "$private_key_path" 2048
179+
openssl req -new -key "$private_key_path" -subj "/CN=$certificate_name" -out "$csr_path"
180+
181+
ASC_TOKEN="$(make_jwt)"
182+
export ASC_TOKEN
183+
184+
if [[ "${IOS_CI_RECREATE_DEVELOPMENT_CERTIFICATE:-}" == "1" ]]; then
185+
certificates_response="$(asc_request GET '/v1/certificates?filter%5BcertificateType%5D=IOS_DEVELOPMENT&limit=10')"
186+
while IFS= read -r certificate_id_to_delete; do
187+
[[ -z "$certificate_id_to_delete" ]] && continue
188+
echo "Deleting existing API-created iOS Development certificate '$certificate_id_to_delete'."
189+
asc_request DELETE "/v1/certificates/$certificate_id_to_delete" >/dev/null
190+
done < <(printf '%s' "$certificates_response" | json_api_created_certificate_ids)
191+
192+
profile_filter="$(urlencode "$profile_name")"
193+
profiles_response="$(asc_request GET "/v1/profiles?filter%5Bname%5D=$profile_filter&limit=200")"
194+
while IFS= read -r profile_id_to_delete; do
195+
[[ -z "$profile_id_to_delete" ]] && continue
196+
echo "Deleting existing iOS CI provisioning profile '$profile_id_to_delete'."
197+
asc_request DELETE "/v1/profiles/$profile_id_to_delete" >/dev/null
198+
done < <(printf '%s' "$profiles_response" | json_profile_ids_with_name "$profile_name")
199+
fi
200+
201+
csr_content="$(cat "$csr_path")"
202+
csr_body="$(ruby -rjson -e 'puts JSON.generate({ data: { type: "certificates", attributes: { certificateType: "IOS_DEVELOPMENT", csrContent: ARGV.fetch(0) } } })' -- "$csr_content")"
203+
if ! certificate_response="$(asc_request POST /v1/certificates "$csr_body")"; then
204+
if [[ "$(cat "$asc_status_file" 2>/dev/null)" == "409" ]]; then
205+
echo "A current iOS Development certificate already exists; deleting and retrying." >&2
206+
certificates_response="$(asc_request GET '/v1/certificates?filter%5BcertificateType%5D=IOS_DEVELOPMENT&limit=10')"
207+
while IFS= read -r certificate_id_to_delete; do
208+
[[ -z "$certificate_id_to_delete" ]] && continue
209+
echo "Deleting existing iOS Development certificate '$certificate_id_to_delete'."
210+
asc_request DELETE "/v1/certificates/$certificate_id_to_delete" >/dev/null
211+
done < <(printf '%s' "$certificates_response" | json_api_created_certificate_ids)
212+
profile_filter="$(urlencode "$profile_name")"
213+
profiles_response="$(asc_request GET "/v1/profiles?filter%5Bname%5D=$profile_filter&limit=200")"
214+
while IFS= read -r profile_id_to_delete; do
215+
[[ -z "$profile_id_to_delete" ]] && continue
216+
echo "Deleting existing iOS CI provisioning profile '$profile_id_to_delete'."
217+
asc_request DELETE "/v1/profiles/$profile_id_to_delete" >/dev/null
218+
done < <(printf '%s' "$profiles_response" | json_profile_ids_with_name "$profile_name")
219+
certificate_response="$(asc_request POST /v1/certificates "$csr_body")"
220+
else
221+
exit 1
222+
fi
223+
fi
224+
certificate_id="$(printf '%s' "$certificate_response" | json_get data id)"
225+
certificate_content="$(printf '%s' "$certificate_response" | json_get data attributes certificateContent)"
226+
227+
printf '%s' "$certificate_content" | base64 --decode >"$certificate_der_path" 2>/dev/null || printf '%s' "$certificate_content" | base64 -D >"$certificate_der_path"
228+
openssl x509 -inform DER -in "$certificate_der_path" -out "$certificate_pem_path"
229+
openssl pkcs12 -export -inkey "$private_key_path" -in "$certificate_pem_path" -out "$p12_path" -password "pass:$p12_password"
230+
231+
bundle_filter="$(urlencode "$wildcard_identifier")"
232+
bundle_response="$(asc_request GET "/v1/bundleIds?filter%5Bidentifier%5D=$bundle_filter")"
233+
bundle_id="$(printf '%s' "$bundle_response" | json_get data 0 id)"
234+
235+
if [[ -z "$bundle_id" ]]; then
236+
bundle_response="$(asc_request POST /v1/bundleIds "$(ruby -rjson -e 'puts JSON.generate({ data: { type: "bundleIds", attributes: { name: ARGV.fetch(0), identifier: ARGV.fetch(1), platform: "IOS" } } })' -- "$profile_name" "$wildcard_identifier")")"
237+
bundle_id="$(printf '%s' "$bundle_response" | json_get data id)"
238+
fi
239+
240+
devices_response="$(asc_request GET '/v1/devices?filter%5Bplatform%5D=IOS&filter%5Bstatus%5D=ENABLED&limit=200')"
241+
device_ids="$(printf '%s' "$devices_response" | json_array_ids)"
242+
243+
profile_body="$(
244+
ruby -rjson -e '
245+
devices = STDIN.read.lines.map(&:strip).reject(&:empty?).map { |id| { type: "devices", id: id } }
246+
relationships = {
247+
bundleId: { data: { type: "bundleIds", id: ARGV.fetch(1) } },
248+
certificates: { data: [{ type: "certificates", id: ARGV.fetch(2) }] },
249+
}
250+
relationships[:devices] = { data: devices } unless devices.empty?
251+
puts JSON.generate({
252+
data: {
253+
type: "profiles",
254+
attributes: { name: ARGV.fetch(0), profileType: "IOS_APP_DEVELOPMENT" },
255+
relationships: relationships,
256+
},
257+
})
258+
' -- "$profile_name" "$bundle_id" "$certificate_id" <<<"$device_ids"
259+
)"
260+
261+
profile_response="$(asc_request POST /v1/profiles "$profile_body")"
262+
profile_content="$(printf '%s' "$profile_response" | json_get data attributes profileContent)"
263+
printf '%s' "$profile_content" | base64 --decode >"$profile_path" 2>/dev/null || printf '%s' "$profile_content" | base64 -D >"$profile_path"
264+
265+
cat <<YAML
266+
# Generated by platform/ios/scripts/ios-ci-create-signing-assets.sh.
267+
# Paste the following block into the 'Install Apple signing assets' step env in ios-ci.yml.
268+
BUILD_CERTIFICATE_BASE64: $(base64_one_line "$p12_path")
269+
P12_PASSWORD: $p12_password
270+
BUILD_PROVISION_PROFILE_BASE64: $(base64_one_line "$profile_path")
271+
KEYCHAIN_PASSWORD: $keychain_password
272+
IOS_BUNDLE_ID_PREFIX: $bundle_id_prefix
273+
YAML
274+
275+
echo "Created signing certificate '$certificate_name' and provisioning profile '$profile_name'." >&2

0 commit comments

Comments
 (0)