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