Compare commits

..

26 Commits

Author SHA1 Message Date
0ae551615f ios: use signing identity fingerprint in ci
Some checks failed
TestFlight Release / testflight (push) Failing after 16s
2026-06-25 22:10:06 -07:00
88bef50ae7 ios: create named ci keychain in home
Some checks failed
TestFlight Release / testflight (push) Failing after 15s
2026-06-25 22:07:12 -07:00
0d069b4233 ios: create ci keychain by name
Some checks failed
TestFlight Release / testflight (push) Failing after 11s
2026-06-25 22:05:47 -07:00
60bbe077e8 ios: pass signing keychain to xcode
Some checks failed
TestFlight Release / testflight (push) Failing after 18s
2026-06-25 22:02:19 -07:00
0b09d5425b ios: handle empty ci keychain list
Some checks failed
TestFlight Release / testflight (push) Failing after 15s
2026-06-25 21:58:01 -07:00
c9a3015e35 ios: parse ci profile without keychain
Some checks failed
TestFlight Release / testflight (push) Failing after 9s
2026-06-25 21:56:19 -07:00
abd8a80daa ios: isolate ci signing keychains
Some checks failed
TestFlight Release / testflight (push) Failing after 8s
2026-06-25 21:52:17 -07:00
0f76ef91a9 ios: restore working ci p12 import
Some checks failed
TestFlight Release / testflight (push) Failing after 9s
2026-06-25 21:48:19 -07:00
72e2ffd898 ios: use temporary keychain path in ci
Some checks failed
TestFlight Release / testflight (push) Failing after 9s
2026-06-25 21:46:48 -07:00
4c610c89e1 ios: install ci profiles for xcode signing
Some checks failed
TestFlight Release / testflight (push) Failing after 9s
2026-06-25 21:44:42 -07:00
477921563f ios: remove invalid ci codesign path
Some checks failed
TestFlight Release / testflight (push) Failing after 18s
2026-06-25 21:36:37 -07:00
0fca0e93ec ios: grant ci key access to xcode tools
Some checks failed
TestFlight Release / testflight (push) Failing after 10s
2026-06-25 21:35:11 -07:00
f977f9943c ios: patch generated release signing settings
Some checks failed
TestFlight Release / testflight (push) Failing after 16s
2026-06-25 21:31:51 -07:00
f445730a41 ios: override iphoneos signing identity
Some checks failed
TestFlight Release / testflight (push) Failing after 16s
2026-06-25 21:29:35 -07:00
76cb808c33 ios: use disposable keychain as ci default
Some checks failed
TestFlight Release / testflight (push) Failing after 15s
2026-06-25 21:27:19 -07:00
e167bd983f ios: use generic xcode signing selector
Some checks failed
TestFlight Release / testflight (push) Failing after 19s
2026-06-25 21:25:13 -07:00
e4dd91564f ios: unlock signing keychain before build
Some checks failed
TestFlight Release / testflight (push) Failing after 17s
2026-06-25 21:20:31 -07:00
3bfde476a6 ios: use single identity signing p12
Some checks failed
TestFlight Release / testflight (push) Failing after 16s
2026-06-25 21:18:54 -07:00
b8676027db ios: trust Apple root in CI signing keychain
Some checks failed
TestFlight Release / testflight (push) Failing after 8s
2026-06-25 21:12:53 -07:00
d36d2c60a3 ios: install Apple WWDR intermediate in CI
Some checks failed
TestFlight Release / testflight (push) Failing after 18s
2026-06-25 21:11:01 -07:00
3d7031bb40 ios: avoid default keychain mutation in ci
Some checks failed
TestFlight Release / testflight (push) Failing after 17s
2026-06-25 21:08:32 -07:00
fa9b725c77 ios: expose signing keychain to xcodebuild
Some checks failed
TestFlight Release / testflight (push) Failing after 9s
2026-06-25 21:07:38 -07:00
a88987d08d ios: pin distribution signing identity
Some checks failed
TestFlight Release / testflight (push) Failing after 15s
2026-06-25 21:05:26 -07:00
e137ea1077 ios: bootstrap signing with existing certificate
Some checks failed
TestFlight Release / testflight (push) Failing after 17s
2026-06-25 21:03:43 -07:00
fad25d7f2b ios: configure api-key TestFlight signing 2026-06-25 20:51:01 -07:00
fb28508764 ios: ci: keychain cleanup 2026-06-25 20:35:39 -07:00
6 changed files with 532 additions and 71 deletions

View File

@@ -11,6 +11,8 @@ permissions:
jobs: jobs:
testflight: testflight:
runs-on: xcode runs-on: xcode
env:
SIGNING_KEYCHAIN: sybil_signing_temp
defaults: defaults:
run: run:
@@ -69,38 +71,119 @@ jobs:
brew install "${missing_tools[@]}" brew install "${missing_tools[@]}"
- name: Import code signing certificates - name: Install signing secrets
uses: Apple-Actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
p12-password: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
keychain: ${{ env.HOME }}/Library/Keychains/signing_temp
- name: Create fastlane environment
working-directory: ios
env: env:
FASTLANE_USER: ${{ secrets.FASTLANE_USER }} APPSTORE_CERTIFICATES_FILE_BASE64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} APPSTORE_CERTIFICATES_PASSWORD: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPSTORE_PROVISIONING_PROFILE_BASE64 }}
run: | run: |
set -euo pipefail set -euo pipefail
: "${FASTLANE_USER:?FASTLANE_USER secret is required}" : "${APPSTORE_CERTIFICATES_FILE_BASE64:?APPSTORE_CERTIFICATES_FILE_BASE64 secret is required}"
: "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD:?FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD secret is required}" : "${APPSTORE_CERTIFICATES_PASSWORD:?APPSTORE_CERTIFICATES_PASSWORD secret is required}"
: "${APPSTORE_PROVISIONING_PROFILE_BASE64:?APPSTORE_PROVISIONING_PROFILE_BASE64 secret is required}"
keychain_password="$(uuidgen)"
previous_default_keychain="$(security default-keychain -d user | sed 's/[ "]//g' || true)"
if [[ "${previous_default_keychain}" == *"/${SIGNING_KEYCHAIN}"* || ! -e "${previous_default_keychain}" ]]; then
previous_default_keychain=""
fi
developer_dir="$(xcode-select -p)"
signing_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/sybil-signing.XXXXXX")"
mkdir -p "${HOME}/Library/Keychains"
keychain_name="${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}-${GITHUB_RUN_ID:-$(uuidgen)}.keychain"
certificate_path="${signing_dir}/appstore-signing.p12"
wwdr_certificate_path="${signing_dir}/AppleWWDRCAG3.cer"
profile_path="${signing_dir}/Sybil_AppStore_CI.mobileprovision"
profile_plist="${signing_dir}/profile.plist"
old_profile_dir="${HOME}/Library/MobileDevice/Provisioning Profiles"
xcode_profile_dir="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"
mkdir -p "${old_profile_dir}" "${xcode_profile_dir}"
printf '%s' "${APPSTORE_CERTIFICATES_FILE_BASE64}" | base64 --decode > "${certificate_path}"
printf '%s' "${APPSTORE_PROVISIONING_PROFILE_BASE64}" | base64 --decode > "${profile_path}"
curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer -o "${wwdr_certificate_path}"
openssl smime -inform DER -verify -noverify -in "${profile_path}" -out "${profile_plist}" >/dev/null
profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print UUID' "${profile_plist}")"
profile_name="$(/usr/libexec/PlistBuddy -c 'Print Name' "${profile_plist}")"
old_profile_path="${old_profile_dir}/${profile_uuid}.mobileprovision"
xcode_profile_path="${xcode_profile_dir}/${profile_uuid}.mobileprovision"
old_named_profile_path="${old_profile_dir}/Sybil_AppStore_CI.mobileprovision"
xcode_named_profile_path="${xcode_profile_dir}/Sybil_AppStore_CI.mobileprovision"
cp "${profile_path}" "${old_profile_path}"
cp "${profile_path}" "${xcode_profile_path}"
cp "${profile_path}" "${old_named_profile_path}"
cp "${profile_path}" "${xcode_named_profile_path}"
base_keychains=()
while IFS= read -r existing_keychain; do
[[ -z "${existing_keychain}" ]] && continue
[[ "${existing_keychain}" == *"/${SIGNING_KEYCHAIN}"* ]] && continue
[[ ! -e "${existing_keychain}" ]] && continue
base_keychains+=("${existing_keychain}")
done < <(security list-keychains -d user | sed 's/[ "]//g')
security delete-keychain "${keychain_name}" >/dev/null 2>&1 || true
rm -f "${HOME}/Library/Keychains/${keychain_name}-db"
security create-keychain -p "${keychain_password}" "${keychain_name}"
security set-keychain-settings -lut 21600 "${keychain_name}"
security unlock-keychain -p "${keychain_password}" "${keychain_name}"
security import "${wwdr_certificate_path}" \
-k "${keychain_name}" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/xcodebuild
security import "${certificate_path}" \
-k "${keychain_name}" \
-P "${APPSTORE_CERTIFICATES_PASSWORD}" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/xcodebuild \
-T "${developer_dir}/usr/bin/xcodebuild"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain_name}"
if [[ "${#base_keychains[@]}" -gt 0 ]]; then
security list-keychains -d user -s "${keychain_name}" "${base_keychains[@]}"
else
security list-keychains -d user -s "${keychain_name}"
fi
security default-keychain -d user -s "${keychain_name}"
keychain_path="$(security list-keychains -d user | sed 's/[ "]//g' | head -n 1)"
security find-identity -v -p codesigning "${keychain_path}"
security find-identity -v -p codesigning
echo "Installed ${profile_name} (${profile_uuid}) provisioning profile"
{ {
printf 'FASTLANE_USER=%s\n' "${FASTLANE_USER}" echo "SYBIL_SIGNING_KEYCHAIN_PATH=${keychain_path}"
printf 'FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=%s\n' "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD}" echo "SYBIL_SIGNING_KEYCHAIN_NAME=${keychain_name}"
printf 'FASTLANE_SKIP_UPDATE_CHECK=1\n' echo "SYBIL_SIGNING_KEYCHAIN_PASSWORD=${keychain_password}"
printf 'FASTLANE_HIDE_CHANGELOG=1\n' echo "SYBIL_PREVIOUS_DEFAULT_KEYCHAIN=${previous_default_keychain}"
} > .env echo "SYBIL_PROVISIONING_PROFILE_UUID=${profile_uuid}"
echo "SYBIL_SIGNING_DIR=${signing_dir}"
echo "SYBIL_OLD_PROFILE_PATH=${old_profile_path}"
echo "SYBIL_XCODE_PROFILE_PATH=${xcode_profile_path}"
echo "SYBIL_OLD_NAMED_PROFILE_PATH=${old_named_profile_path}"
echo "SYBIL_XCODE_NAMED_PROFILE_PATH=${xcode_named_profile_path}"
} >> "${GITHUB_ENV}"
- name: Build and upload to TestFlight - name: Build and upload to TestFlight
working-directory: ios working-directory: ios
env: env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64: "true"
FASTLANE_DONT_STORE_PASSWORD: "1" FASTLANE_DONT_STORE_PASSWORD: "1"
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_SKIP_UPDATE_CHECK: "1"
SYBIL_PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
run: | run: |
set -euo pipefail set -euo pipefail
security unlock-keychain -p "${SYBIL_SIGNING_KEYCHAIN_PASSWORD}" "${SYBIL_SIGNING_KEYCHAIN_PATH}"
security list-keychains -d user -s "${SYBIL_SIGNING_KEYCHAIN_PATH}" $(security list-keychains -d user | sed 's/[ "]//g')
security default-keychain -d user -s "${SYBIL_SIGNING_KEYCHAIN_PATH}"
security find-identity -v -p codesigning "${SYBIL_SIGNING_KEYCHAIN_PATH}"
security find-identity -v -p codesigning
SYBIL_VERSION_TAG="${TAG_NAME}" bundle exec fastlane ios beta SYBIL_VERSION_TAG="${TAG_NAME}" bundle exec fastlane ios beta
- name: Locate IPA - name: Locate IPA
@@ -186,3 +269,18 @@ jobs:
"${api_url}/repos/${repository}/releases/${release_id}/assets?name=${asset_name}" >/dev/null "${api_url}/repos/${repository}/releases/${release_id}/assets?name=${asset_name}" >/dev/null
echo "Published ${IPA_NAME} to ${release_name}" echo "Published ${IPA_NAME} to ${release_name}"
- name: Clean up temporary keychain
if: always()
run: |
if [[ -n "${SYBIL_PREVIOUS_DEFAULT_KEYCHAIN:-}" ]]; then
security default-keychain -d user -s "${SYBIL_PREVIOUS_DEFAULT_KEYCHAIN}" || true
fi
rm -f \
"${SYBIL_OLD_PROFILE_PATH:-}" \
"${SYBIL_XCODE_PROFILE_PATH:-}" \
"${SYBIL_OLD_NAMED_PROFILE_PATH:-}" \
"${SYBIL_XCODE_NAMED_PROFILE_PATH:-}"
security delete-keychain "${SYBIL_SIGNING_KEYCHAIN_PATH:-${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db}" || true
rm -f "${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}-"*.keychain-db
rm -rf "${SYBIL_SIGNING_DIR:-}"

View File

@@ -1,14 +1,18 @@
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2 FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
FASTLANE_TEAM_ID=DQQH5H6GBD FASTLANE_TEAM_ID=DQQH5H6GBD
FASTLANE_USER=you@example.com
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
FASTLANE_SKIP_UPDATE_CHECK=1 FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1 FASTLANE_HIDE_CHANGELOG=1
SYBIL_APP_STORE_APPLE_ID=6759442828 SYBIL_APP_STORE_APPLE_ID=6759442828
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2 SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
SYBIL_PROVISIONING_PROFILE_UUID=
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
SYBIL_SIGNING_CERTIFICATE_ID=
SYBIL_SIGNING_KEYCHAIN=
# Optional App Store Connect API key settings for non-interactive upload and # App Store Connect API key settings for TestFlight upload and signing setup.
# TestFlight build-number lookup.
APP_STORE_CONNECT_API_KEY_ID= APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_API_ISSUER_ID= APP_STORE_CONNECT_API_ISSUER_ID=
APP_STORE_CONNECT_API_KEY_PATH= APP_STORE_CONNECT_API_KEY_PATH=

View File

@@ -32,6 +32,12 @@ targets:
INFOPLIST_KEY_UILaunchScreen_Generation: YES INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
configs:
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: Apple Distribution
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
schemes: schemes:
Sybil: Sybil:

View File

@@ -13,20 +13,63 @@ git tag release/v1.10.0
git push origin release/v1.10.0 git push origin release/v1.10.0
``` ```
The release job runs on the `xcode` runner label, imports the signing p12 with The release job runs on the `xcode` runner label, imports the signing p12 into
`Apple-Actions/import-codesign-certs`, builds and uploads the app with fastlane, a temporary per-user keychain, makes that keychain the user default for the
then creates or updates the matching Gitea release with the generated IPA as an duration of the job, installs the App Store provisioning profile in both the
asset. legacy MobileDevice directory and the Xcode UserData directory used by newer
Xcode releases, builds and uploads the app with fastlane, then creates or
updates the matching Gitea release with the generated IPA as an asset. The job
restores the previous user default keychain and deletes the temporary signing
keychain and installed profiles in an `always()` cleanup step.
Required repository secrets: Required repository secrets:
```text ```text
APP_STORE_CONNECT_API_KEY_ID
APP_STORE_CONNECT_API_ISSUER_ID
APP_STORE_CONNECT_API_KEY_CONTENT
APPSTORE_CERTIFICATES_FILE_BASE64 APPSTORE_CERTIFICATES_FILE_BASE64
APPSTORE_CERTIFICATES_PASSWORD APPSTORE_CERTIFICATES_PASSWORD
FASTLANE_USER APPSTORE_PROVISIONING_PROFILE_BASE64
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
``` ```
Generate or refresh the signing assets locally with:
```sh
cd ios
fastlane ios create_ci_signing
```
The generated `build/signing/ci-secrets.env` file is ignored by Git. Copy its
certificate and provisioning profile values into the repository secrets listed
above. The workflow uses the `Sybil AppStore CI` provisioning profile name by
default.
Fastlane keeps two signing names separate. `SYBIL_CODE_SIGN_IDENTITY` is the
exact certificate common name used when exporting a local p12 for secrets, while
`SYBIL_XCODE_CODE_SIGN_IDENTITY` defaults to the certificate SHA-1 fingerprint
that Xcode uses during archive. `SYBIL_EXPORT_SIGNING_CERTIFICATE` defaults to
the generic `Apple Distribution` selector used in the export options.
The Release signing settings are also present in `Apps/Sybil/project.yml` so
XcodeGen emits a manually signed App Store archive configuration. CI passes the
installed provisioning profile UUID to Fastlane as
`SYBIL_PROVISIONING_PROFILE_UUID`; Fastlane writes that UUID into the generated
project before archiving. CI also passes the temporary keychain path as
`CODE_SIGN_KEYCHAIN` so Xcode searches the disposable keychain for the imported
Distribution identity.
If the Apple team has reached the Distribution certificate limit, set
`SYBIL_SIGNING_CERTIFICATE_ID` to the portal id for a certificate whose private
key exists in the local login keychain before running `create_ci_signing`. The
lane will export the local identity and create the provisioning profile against
that existing certificate instead of creating another Distribution certificate.
If `create_ci_signing` fails with an expired or missing agreement error, the
Apple Developer Program account holder must accept the current agreements in
App Store Connect before new certificates or provisioning profiles can be
created through the API.
The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset
upload, with `contents: write` permissions. In Gitea this covers release asset upload, with `contents: write` permissions. In Gitea this covers release asset
publication. publication.

View File

@@ -1,6 +1,13 @@
require "dotenv" require "dotenv"
require "base64"
require "fileutils"
require "json"
require "net/http"
require "open3" require "open3"
require "openssl"
require "securerandom"
require "shellwords" require "shellwords"
require "uri"
require "yaml" require "yaml"
Dotenv.load(File.expand_path("../.env", __dir__)) Dotenv.load(File.expand_path("../.env", __dir__))
@@ -11,10 +18,15 @@ APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD") TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828") APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2") PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
PROFILE_SPECIFIER = ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"].to_s.strip.empty? ? "Sybil AppStore CI" : ENV["SYBIL_PROVISIONING_PROFILE_SPECIFIER"]
SIGNING_CERTIFICATE_NAME = ENV["SYBIL_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "Apple Distribution: James Magahern (DQQH5H6GBD)" : ENV["SYBIL_CODE_SIGN_IDENTITY"]
XCODE_CODE_SIGN_IDENTITY = ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"].to_s.strip.empty? ? "6B74B268C4761720FB2051D01D8BB3E47B55D9F5" : ENV["SYBIL_XCODE_CODE_SIGN_IDENTITY"]
EXPORT_SIGNING_CERTIFICATE = ENV["SYBIL_EXPORT_SIGNING_CERTIFICATE"].to_s.strip.empty? ? "Apple Distribution" : ENV["SYBIL_EXPORT_SIGNING_CERTIFICATE"]
IOS_ROOT = File.expand_path("..", __dir__) IOS_ROOT = File.expand_path("..", __dir__)
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj") PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml") PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml") APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing")
SCHEME = "Sybil" SCHEME = "Sybil"
TARGET = "SybilApp" TARGET = "SybilApp"
@@ -29,10 +41,46 @@ def capture(command)
UI.user_error!("Command failed: #{command}\n#{stderr.strip}") UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
end end
def run_silent(*command, error_message:)
_stdout, stderr, status = Open3.capture3(*command)
return if status.success?
UI.user_error!("#{error_message}\n#{stderr.strip}")
end
def user_keychains
capture("security list-keychains -d user").lines.map { |line| line.strip.delete('"') }.reject(&:empty?)
end
def app_project_settings def app_project_settings
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base") YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
end end
def apply_release_signing_settings
require "xcodeproj"
project = Xcodeproj::Project.open(PROJECT_FILE)
target = project.targets.find { |candidate| candidate.name == TARGET }
UI.user_error!("Could not find target #{TARGET} in #{PROJECT_FILE}") unless target
target.build_configurations.each do |configuration|
next unless configuration.name == "Release"
settings = configuration.build_settings
settings["CODE_SIGN_STYLE"] = "Manual"
settings["DEVELOPMENT_TEAM"] = TEAM_ID
settings["PROVISIONING_PROFILE_SPECIFIER"] = PROFILE_SPECIFIER
settings["CODE_SIGN_IDENTITY"] = XCODE_CODE_SIGN_IDENTITY
settings["CODE_SIGN_IDENTITY[sdk=iphoneos*]"] = XCODE_CODE_SIGN_IDENTITY
settings["CODE_SIGN_KEYCHAIN"] = ENV["SYBIL_SIGNING_KEYCHAIN_PATH"] if present?(ENV["SYBIL_SIGNING_KEYCHAIN_PATH"])
if present?(ENV["SYBIL_PROVISIONING_PROFILE_UUID"])
settings["PROVISIONING_PROFILE"] = ENV["SYBIL_PROVISIONING_PROFILE_UUID"]
settings["PROVISIONING_PROFILE[sdk=iphoneos*]"] = ENV["SYBIL_PROVISIONING_PROFILE_UUID"]
end
end
project.save
end
def local_marketing_version def local_marketing_version
app_project_settings.fetch("MARKETING_VERSION").to_s app_project_settings.fetch("MARKETING_VERSION").to_s
end end
@@ -56,12 +104,182 @@ def release_version
end end
def xcode_build_setting(key, value) def xcode_build_setting(key, value)
"#{key.to_s.shellescape}=#{value.to_s.shellescape}"
end
def env_line(key, value)
"#{key}=#{value.to_s.shellescape}" "#{key}=#{value.to_s.shellescape}"
end end
def base64url(value)
Base64.urlsafe_encode64(value).delete("=")
end
def integer_to_fixed_bytes(integer, length)
hex = integer.to_s(16)
hex = "0#{hex}" if hex.length.odd?
[hex].pack("H*").rjust(length, "\0")[-length, length]
end
def app_store_connect_private_key
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
pem = if present?(key_path)
File.read(key_path)
elsif present?(key_content)
ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true" ? Base64.decode64(key_content) : key_content
end
UI.user_error!("App Store Connect API key content is required") unless present?(pem)
OpenSSL::PKey::EC.new(pem)
end
def app_store_connect_jwt
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
UI.user_error!("App Store Connect API key id and issuer id are required") unless present?(key_id) && present?(issuer_id)
header = { alg: "ES256", kid: key_id, typ: "JWT" }
payload = { iss: issuer_id, iat: Time.now.to_i, exp: Time.now.to_i + 600, aud: "appstoreconnect-v1" }
unsigned = [base64url(header.to_json), base64url(payload.to_json)].join(".")
asn1_signature = app_store_connect_private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(unsigned))
signature_sequence = OpenSSL::ASN1.decode(asn1_signature)
raw_signature = signature_sequence.value.map { |part| integer_to_fixed_bytes(part.value, 32) }.join
[unsigned, base64url(raw_signature)].join(".")
end
def app_store_connect_request(method, path, payload = nil)
uri = URI("https://api.appstoreconnect.apple.com#{path}")
request_class = Net::HTTP.const_get(method.to_s.capitalize)
request = request_class.new(uri)
request["Authorization"] = "Bearer #{app_store_connect_jwt}"
if payload
request["Content-Type"] = "application/json"
request.body = payload.to_json
end
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
return {} if response.is_a?(Net::HTTPSuccess) && response.body.to_s.empty?
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
UI.user_error!("App Store Connect API request failed: #{method.to_s.upcase} #{path}\n#{response.body}")
end
def bundle_id_resource_id
response = app_store_connect_request(
:get,
"/v1/bundleIds?filter[identifier]=#{URI.encode_www_form_component(APP_IDENTIFIER)}&limit=1"
)
id = response.fetch("data", []).first&.fetch("id", nil)
UI.user_error!("Could not find App Store Connect bundle id resource for #{APP_IDENTIFIER}") unless present?(id)
id
end
def recreate_app_store_profile(certificate_id)
existing = app_store_connect_request(
:get,
"/v1/profiles?filter[name]=#{URI.encode_www_form_component(PROFILE_SPECIFIER)}&limit=200"
)
existing.fetch("data", []).each do |profile|
app_store_connect_request(:delete, "/v1/profiles/#{profile.fetch("id")}")
end
payload = {
data: {
type: "profiles",
attributes: {
name: PROFILE_SPECIFIER,
profileType: "IOS_APP_STORE"
},
relationships: {
bundleId: {
data: { type: "bundleIds", id: bundle_id_resource_id }
},
certificates: {
data: [{ type: "certificates", id: certificate_id }]
}
}
}
}
response = app_store_connect_request(:post, "/v1/profiles", payload)
profile_content = response.dig("data", "attributes", "profileContent")
UI.user_error!("App Store Connect profile response did not include profileContent") unless present?(profile_content)
profile_path = File.join(SIGNING_OUTPUT_DIR, "Sybil_AppStore_CI.mobileprovision")
File.binwrite(profile_path, Base64.decode64(profile_content))
install_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles")
FileUtils.mkdir_p(install_dir)
FileUtils.cp(profile_path, File.join(install_dir, "Sybil_AppStore_CI.mobileprovision"))
profile_path
end
def signing_identity_p12(p12_path, p12_password)
work_dir = File.join(SIGNING_OUTPUT_DIR, "single-identity")
FileUtils.rm_rf(work_dir)
FileUtils.mkdir_p(work_dir)
all_pem = File.join(work_dir, "all.pem")
stdout, stderr, status = Open3.capture3(
"openssl", "pkcs12",
"-in", p12_path,
"-nodes",
"-passin", "pass:#{p12_password}",
"-out", all_pem
)
UI.user_error!("Could not inspect exported p12\n#{stderr}\n#{stdout}") unless status.success?
records = File.read(all_pem).split(/(?=Bag Attributes\n)/)
cert_record = records.find do |record|
record.include?("friendlyName: #{SIGNING_CERTIFICATE_NAME}") && record.include?("-----BEGIN CERTIFICATE-----")
end
UI.user_error!("Could not find #{SIGNING_CERTIFICATE_NAME} certificate in exported p12") unless cert_record
local_key_id = cert_record[/localKeyID: (.+)/, 1].to_s.strip
UI.user_error!("Could not resolve localKeyID for #{SIGNING_CERTIFICATE_NAME}") unless present?(local_key_id)
key_record = records.find do |record|
record.include?("localKeyID: #{local_key_id}") && record.include?("-----BEGIN PRIVATE KEY-----")
end
UI.user_error!("Could not find private key for #{SIGNING_CERTIFICATE_NAME}") unless key_record
cert_path = File.join(work_dir, "identity.cer.pem")
key_path = File.join(work_dir, "identity.key.pem")
File.write(cert_path, cert_record[/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m])
File.write(key_path, key_record[/-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----/m])
root_cer = File.join(work_dir, "AppleIncRootCertificate.cer")
wwdr_cer = File.join(work_dir, "AppleWWDRCAG3.cer")
root_pem = File.join(work_dir, "AppleIncRootCertificate.pem")
wwdr_pem = File.join(work_dir, "AppleWWDRCAG3.pem")
chain_pem = File.join(work_dir, "chain.pem")
run_silent("curl", "-fsSL", "https://www.apple.com/appleca/AppleIncRootCertificate.cer", "-o", root_cer, error_message: "Could not download Apple root certificate")
run_silent("curl", "-fsSL", "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer", "-o", wwdr_cer, error_message: "Could not download Apple WWDR G3 certificate")
run_silent("openssl", "x509", "-inform", "DER", "-in", root_cer, "-out", root_pem, error_message: "Could not convert Apple root certificate")
run_silent("openssl", "x509", "-inform", "DER", "-in", wwdr_cer, "-out", wwdr_pem, error_message: "Could not convert Apple WWDR G3 certificate")
File.write(chain_pem, File.read(wwdr_pem) + File.read(root_pem))
single_p12_path = File.join(work_dir, "appstore-signing.p12")
run_silent(
"openssl", "pkcs12", "-export",
"-inkey", key_path,
"-in", cert_path,
"-certfile", chain_pem,
"-name", SIGNING_CERTIFICATE_NAME,
"-out", single_p12_path,
"-passout", "pass:#{p12_password}",
error_message: "Could not create single-identity p12"
)
FileUtils.cp(single_p12_path, p12_path)
ensure
FileUtils.rm_rf(work_dir) if present?(work_dir)
end
def app_store_connect_key_options def app_store_connect_key_options
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"] key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"] issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] unless present?(issuer_id)
return nil unless present?(key_id) && present?(issuer_id) return nil unless present?(key_id) && present?(issuer_id)
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"] key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
@@ -83,6 +301,13 @@ def app_store_connect_key_options
end end
platform :ios do platform :ios do
private_lane :load_app_store_connect_api_key do
options = app_store_connect_key_options
UI.user_error!("App Store Connect API key is required") unless options
app_store_connect_api_key(options)
end
desc "Show the version Fastlane will stamp into the next TestFlight archive" desc "Show the version Fastlane will stamp into the next TestFlight archive"
lane :version do lane :version do
UI.message("Git tag version: #{release_version}") UI.message("Git tag version: #{release_version}")
@@ -90,20 +315,104 @@ platform :ios do
UI.message("Checked-in build number: #{local_build_number}") UI.message("Checked-in build number: #{local_build_number}")
end end
desc "Create CI signing certificate/profile and write ignored secret material under build/signing"
lane :create_ci_signing do
api_key = load_app_store_connect_api_key
FileUtils.rm_rf(SIGNING_OUTPUT_DIR)
FileUtils.mkdir_p(SIGNING_OUTPUT_DIR)
cert_id = ENV["SYBIL_SIGNING_CERTIFICATE_ID"].to_s
keychain_path = nil
keychain_password = nil
p12_path = File.join(SIGNING_OUTPUT_DIR, "appstore-signing.p12")
p12_password = ENV["SYBIL_CI_P12_PASSWORD"].to_s
if p12_password.empty?
p12_password = SecureRandom.base64(24)
UI.important("Generated a p12 password for CI secrets.")
end
begin
if present?(cert_id)
UI.message("Using existing signing certificate id #{cert_id}")
export_keychain = ENV["SYBIL_SIGNING_KEYCHAIN"].to_s
export_keychain = File.expand_path("~/Library/Keychains/login.keychain-db") unless present?(export_keychain)
run_silent(
"security", "export", "-k", export_keychain, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
error_message: "Could not export the local CI signing identity"
)
else
keychain_path = File.join(SIGNING_OUTPUT_DIR, "sybil_ci_signing.keychain-db")
keychain_password = SecureRandom.base64(24)
run_silent(
"security", "create-keychain", "-p", keychain_password, keychain_path,
error_message: "Could not create temporary signing keychain"
)
run_silent(
"security", "set-keychain-settings", "-lut", "21600", keychain_path,
error_message: "Could not configure temporary signing keychain"
)
run_silent(
"security", "unlock-keychain", "-p", keychain_password, keychain_path,
error_message: "Could not unlock temporary signing keychain"
)
run_silent(
"security", "list-keychains", "-d", "user", "-s", keychain_path, *user_keychains,
error_message: "Could not add temporary signing keychain to the user search list"
)
cert(
api_key: api_key,
development: false,
force: true,
generate_apple_certs: true,
keychain_password: keychain_password,
keychain_path: keychain_path,
output_path: SIGNING_OUTPUT_DIR,
platform: "ios"
)
cert_id = lane_context[SharedValues::CERT_CERTIFICATE_ID]
UI.user_error!("Could not resolve generated certificate id") unless present?(cert_id)
run_silent(
"security", "export", "-k", keychain_path, "-t", "identities", "-f", "pkcs12", "-P", p12_password, "-o", p12_path,
error_message: "Could not export the generated CI signing identity"
)
end
UI.user_error!("Could not find exported p12 at #{p12_path}") unless File.exist?(p12_path)
signing_identity_p12(p12_path, p12_password)
profile_path = recreate_app_store_profile(cert_id)
UI.user_error!("Could not resolve generated provisioning profile path") unless present?(profile_path) && File.exist?(profile_path)
secrets_path = File.join(SIGNING_OUTPUT_DIR, "ci-secrets.env")
File.write(
secrets_path,
[
env_line("APPSTORE_CERTIFICATES_FILE_BASE64", Base64.strict_encode64(File.binread(p12_path))),
env_line("APPSTORE_CERTIFICATES_PASSWORD", p12_password),
env_line("APPSTORE_PROVISIONING_PROFILE_BASE64", Base64.strict_encode64(File.binread(profile_path))),
env_line("SYBIL_PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER)
].join("\n") + "\n"
)
ensure
system("security", "delete-keychain", keychain_path, out: File::NULL, err: File::NULL) if present?(keychain_path) && File.exist?(keychain_path)
end
UI.success("Created CI signing files in #{SIGNING_OUTPUT_DIR}")
UI.important("Add the values from #{secrets_path} as repository secrets.")
end
desc "Build Sybil and upload it to TestFlight" desc "Build Sybil and upload it to TestFlight"
lane :beta do lane :beta do
version = release_version version = release_version
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
api_key = nil api_key = load_app_store_connect_api_key
if app_store_connect_key_options
api_key = app_store_connect_api_key(app_store_connect_key_options)
end
unless present?(build_number) unless present?(build_number)
build_number = (local_build_number + 1).to_s build_number = (local_build_number + 1).to_s
if api_key
begin begin
latest = latest_testflight_build_number( latest = latest_testflight_build_number(
app_identifier: APP_IDENTIFIER, app_identifier: APP_IDENTIFIER,
@@ -117,17 +426,21 @@ platform :ios do
UI.important("Using checked-in build number + 1: #{build_number}") UI.important("Using checked-in build number + 1: #{build_number}")
end end
end end
end
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/) UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}") sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
apply_release_signing_settings
xcode_args = [ xcode_args = [
"-allowProvisioningUpdates",
xcode_build_setting("MARKETING_VERSION", version), xcode_build_setting("MARKETING_VERSION", version),
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number) xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
].join(" ") ]
if present?(ENV["SYBIL_SIGNING_KEYCHAIN_PATH"])
xcode_args << xcode_build_setting("CODE_SIGN_KEYCHAIN", ENV.fetch("SYBIL_SIGNING_KEYCHAIN_PATH"))
xcode_args << xcode_build_setting("OTHER_CODE_SIGN_FLAGS", "--keychain #{ENV.fetch("SYBIL_SIGNING_KEYCHAIN_PATH")}")
end
xcode_args = xcode_args.join(" ")
ipa_path = build_app( ipa_path = build_app(
project: PROJECT_FILE, project: PROJECT_FILE,
@@ -138,11 +451,14 @@ platform :ios do
output_directory: File.join(IOS_ROOT, "build/fastlane"), output_directory: File.join(IOS_ROOT, "build/fastlane"),
output_name: "Sybil-#{version}-#{build_number}.ipa", output_name: "Sybil-#{version}-#{build_number}.ipa",
xcargs: xcode_args, xcargs: xcode_args,
export_xcargs: "-allowProvisioningUpdates",
export_options: { export_options: {
method: "app-store-connect", method: "app-store",
destination: "export", destination: "export",
signingStyle: "automatic", signingStyle: "manual",
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_SPECIFIER
},
signingCertificate: EXPORT_SIGNING_CERTIFICATE,
teamID: TEAM_ID, teamID: TEAM_ID,
manageAppVersionAndBuildNumber: false, manageAppVersionAndBuildNumber: false,
uploadSymbols: true, uploadSymbols: true,
@@ -153,25 +469,11 @@ platform :ios do
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH] ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path) UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"] upload_to_testflight(
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"]) api_key: api_key,
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password) app_identifier: APP_IDENTIFIER,
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID) ipa: ipa_path,
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID) skip_waiting_for_build_processing: true
)
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
sh([
"xcrun altool",
"--upload-package #{ipa_path.shellescape}",
"--platform ios",
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
"--bundle-id #{APP_IDENTIFIER.shellescape}",
"--bundle-version #{build_number.shellescape}",
"--bundle-short-version-string #{version.shellescape}",
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
"--password @env:ITMS_TRANSPORTER_PASSWORD",
"--show-progress"
].join(" "))
end end
end end

View File

@@ -23,6 +23,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
Show the version Fastlane will stamp into the next TestFlight archive Show the version Fastlane will stamp into the next TestFlight archive
### ios create_ci_signing
```sh
[bundle exec] fastlane ios create_ci_signing
```
Create CI signing certificate/profile and write ignored secret material under build/signing
### ios beta ### ios beta
```sh ```sh