Compare commits

..

38 Commits

Author SHA1 Message Date
60469f05b5 Tolerate login keychain preference failure
Some checks failed
TestFlight / testflight (push) Failing after 23s
2026-06-25 23:27:13 -07:00
d834ed7931 Create CI login keychain when missing
Some checks failed
TestFlight / testflight (push) Failing after 19s
2026-06-25 23:25:42 -07:00
f98a002f52 Use explicit runner login keychain
Some checks failed
TestFlight / testflight (push) Failing after 17s
2026-06-25 23:23:13 -07:00
b0c0a2d55e Reset CI keychain search list
Some checks failed
TestFlight / testflight (push) Failing after 19s
2026-06-25 23:21:42 -07:00
3262f4ff80 Detect runner login keychain path
Some checks failed
TestFlight / testflight (push) Failing after 19s
2026-06-25 23:20:06 -07:00
585be09eb7 Target login keychain path for CI signing
Some checks failed
TestFlight / testflight (push) Failing after 19s
2026-06-25 23:18:01 -07:00
387896741c Use runner login keychain for CI signing
Some checks failed
TestFlight / testflight (push) Failing after 21s
2026-06-25 23:16:22 -07:00
f6a10af7a9 Use signing certificate identity hash
Some checks failed
TestFlight / testflight (push) Failing after 24s
2026-06-25 23:13:08 -07:00
8aab86e2a6 Avoid changing default keychain in CI
Some checks failed
TestFlight / testflight (push) Failing after 25s
2026-06-25 23:10:35 -07:00
eb4b233e33 Resolve CI signing keychain path
Some checks failed
TestFlight / testflight (push) Failing after 18s
2026-06-25 23:08:35 -07:00
cbd7a68e57 Make CI signing keychain visible to Xcode
Some checks failed
TestFlight / testflight (push) Failing after 21s
2026-06-25 23:06:00 -07:00
04c15e8f12 Use absolute iOS paths in Fastlane
Some checks failed
TestFlight / testflight (push) Failing after 25s
2026-06-25 22:50:30 -07:00
ca28ebc0a0 Use disposable match keychain in CI
Some checks failed
TestFlight / testflight (push) Failing after 16s
2026-06-25 22:48:59 -07:00
87787642b5 Preserve Ruby path for TestFlight workflow
Some checks failed
TestFlight / testflight (push) Failing after 22s
2026-06-25 22:46:14 -07:00
4124a31a34 Use Ruby 3.1 for TestFlight workflow
Some checks failed
TestFlight / testflight (push) Failing after 21s
2026-06-25 22:43:27 -07:00
a68f1e50ca Reset iOS TestFlight deployment
Some checks failed
TestFlight / testflight (push) Failing after 14s
2026-06-25 22:41:00 -07:00
272ad0bbf0 ios: pass signing settings to archive
Some checks failed
TestFlight Release / testflight (push) Failing after 17s
2026-06-25 22:19:25 -07:00
de7b448bc5 ios: avoid system default keychain writes
Some checks failed
TestFlight Release / testflight (push) Failing after 16s
2026-06-25 22:16:24 -07:00
3c7fc51fdb ios: set ci keychain in default domain
Some checks failed
TestFlight Release / testflight (push) Failing after 10s
2026-06-25 22:14:25 -07:00
0062f37b9f ios: sign with disposable login keychain
Some checks failed
TestFlight Release / testflight (push) Failing after 17s
2026-06-25 22:12:17 -07:00
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
11 changed files with 208 additions and 682 deletions

View File

@@ -1,218 +0,0 @@
name: TestFlight Release
on:
push:
tags:
- "release/v*.*.*"
permissions:
contents: write
jobs:
testflight:
runs-on: xcode
env:
SIGNING_KEYCHAIN: sybil_signing_temp
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate release tag
run: |
set -euo pipefail
tag_name="${GITHUB_REF#refs/tags/}"
if [[ ! "$tag_name" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Release tag must match release/vN.N.N; got ${tag_name}" >&2
exit 1
fi
release_version="${tag_name#release/v}"
{
echo "TAG_NAME=${tag_name}"
echo "RELEASE_VERSION=${release_version}"
} >> "${GITHUB_ENV}"
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
- name: Install Ruby gems
working-directory: ios
run: bundle install
- name: Install release tools
run: |
set -euo pipefail
missing_tools=()
for tool in xcodegen jq; do
if ! command -v "${tool}" >/dev/null 2>&1; then
missing_tools+=("${tool}")
fi
done
if [[ "${#missing_tools[@]}" -eq 0 ]]; then
exit 0
fi
if ! command -v brew >/dev/null 2>&1; then
echo "Missing required tools: ${missing_tools[*]}; Homebrew is not available to install them" >&2
exit 1
fi
brew install "${missing_tools[@]}"
- name: Install signing secrets
env:
APPSTORE_CERTIFICATES_FILE_BASE64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
APPSTORE_CERTIFICATES_PASSWORD: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPSTORE_PROVISIONING_PROFILE_BASE64 }}
run: |
set -euo pipefail
: "${APPSTORE_CERTIFICATES_FILE_BASE64:?APPSTORE_CERTIFICATES_FILE_BASE64 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)"
keychain_path="${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db"
mkdir -p "${HOME}/Library/Keychains" "${HOME}/Library/MobileDevice/Provisioning Profiles" ios/build/secrets
printf '%s' "${APPSTORE_CERTIFICATES_FILE_BASE64}" | base64 --decode > ios/build/secrets/appstore-signing.p12
printf '%s' "${APPSTORE_PROVISIONING_PROFILE_BASE64}" | base64 --decode > "${HOME}/Library/MobileDevice/Provisioning Profiles/Sybil_AppStore_CI.mobileprovision"
curl -fsSL https://www.apple.com/appleca/AppleIncRootCertificate.cer -o ios/build/secrets/AppleIncRootCertificate.cer
curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer -o ios/build/secrets/AppleWWDRCAG3.cer
security create-keychain -p "${keychain_password}" "${keychain_path}"
security set-keychain-settings -lut 21600 "${keychain_path}"
security unlock-keychain -p "${keychain_password}" "${keychain_path}"
security list-keychains -d user -s "${keychain_path}" $(security list-keychains -d user | sed 's/[ "]//g')
security add-trusted-cert -r trustRoot -k "${keychain_path}" ios/build/secrets/AppleIncRootCertificate.cer
security import ios/build/secrets/AppleWWDRCAG3.cer \
-k "${keychain_path}" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/xcodebuild
security import ios/build/secrets/appstore-signing.p12 \
-k "${keychain_path}" \
-P "${APPSTORE_CERTIFICATES_PASSWORD}" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/xcodebuild
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain_path}"
security find-identity -v -p codesigning "${keychain_path}"
echo "SYBIL_SIGNING_KEYCHAIN_PATH=${keychain_path}" >> "${GITHUB_ENV}"
- name: Build and upload to TestFlight
working-directory: ios
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_HIDE_CHANGELOG: "1"
FASTLANE_SKIP_UPDATE_CHECK: "1"
SYBIL_PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
run: |
set -euo pipefail
SYBIL_VERSION_TAG="${TAG_NAME}" bundle exec fastlane ios beta
- name: Locate IPA
run: |
set -euo pipefail
ipa_path="$(find ios/build/fastlane -maxdepth 1 -type f -name '*.ipa' -print | sort | tail -n 1)"
if [[ -z "${ipa_path}" ]]; then
echo "No IPA found under ios/build/fastlane" >&2
exit 1
fi
{
echo "IPA_PATH=${ipa_path}"
echo "IPA_NAME=$(basename "${ipa_path}")"
} >> "${GITHUB_ENV}"
- name: Publish Gitea release asset
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_API_URL: ${{ github.api_url }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
api_url="${RELEASE_API_URL:-https://code.buzzert.dev/api/v1}"
repository="${RELEASE_REPOSITORY:-buzzert/Sybil-2}"
sha="${RELEASE_SHA:-${GITHUB_SHA:-}}"
release_name="Sybil v${RELEASE_VERSION}"
release_body="Automated TestFlight release for ${TAG_NAME}."
release_payload="$(jq -nc \
--arg tag "${TAG_NAME}" \
--arg name "${release_name}" \
--arg body "${release_body}" \
--arg target "${sha}" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false} +
(if $target == "" then {} else {target_commitish: $target} end)')"
response_file="$(mktemp)"
status="$(curl -sS -o "${response_file}" -w "%{http_code}" \
-X POST "${api_url}/repos/${repository}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data "${release_payload}")"
if [[ "${status}" == "201" ]]; then
release_id="$(jq -r '.id' "${response_file}")"
elif [[ "${status}" == "409" ]]; then
release_id="$(curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api_url}/repos/${repository}/releases?limit=100" |
jq -r --arg tag "${TAG_NAME}" '.[] | select(.tag_name == $tag) | .id' |
head -n 1)"
else
cat "${response_file}" >&2
exit 1
fi
if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then
echo "Could not resolve Gitea release id for ${TAG_NAME}" >&2
exit 1
fi
existing_asset_id="$(curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api_url}/repos/${repository}/releases/${release_id}/assets" |
jq -r --arg name "${IPA_NAME}" '.[] | select(.name == $name) | .id' |
head -n 1)"
if [[ -n "${existing_asset_id}" && "${existing_asset_id}" != "null" ]]; then
curl -fsS -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api_url}/repos/${repository}/releases/${release_id}/assets/${existing_asset_id}"
fi
asset_name="$(jq -rn --arg value "${IPA_NAME}" '$value | @uri')"
curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${IPA_PATH}" \
"${api_url}/repos/${repository}/releases/${release_id}/assets?name=${asset_name}" >/dev/null
echo "Published ${IPA_NAME} to ${release_name}"
- name: Clean up temporary keychain
if: always()
run: |
security delete-keychain "${HOME}/Library/Keychains/${SIGNING_KEYCHAIN}.keychain-db" || true

View File

@@ -0,0 +1,50 @@
name: TestFlight
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
testflight:
runs-on: xcode
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.7"
bundler-cache: true
working-directory: ios
- name: Install XcodeGen
run: |
set -euo pipefail
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Upload to TestFlight
working-directory: ios
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
run: |
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
ruby --version
bundle exec fastlane ios beta

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.env
ios/fastlane/README.md
ios/fastlane/report.xml

View File

@@ -5,7 +5,10 @@ FASTLANE_HIDE_CHANGELOG=1
SYBIL_APP_STORE_APPLE_ID=6759442828
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=

View File

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

View File

@@ -1,3 +1,3 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.227"
gem "fastlane"

View File

@@ -225,7 +225,7 @@ PLATFORMS
ruby
DEPENDENCIES
fastlane (~> 2.227)
fastlane
BUNDLED WITH
2.5.23

View File

@@ -1,9 +0,0 @@
require "dotenv"
Dotenv.load(File.expand_path("../.env", __dir__))
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?

View File

@@ -1,58 +0,0 @@
# TestFlight Release CI
Gitea Actions publishes iOS releases from tags that match:
```sh
release/vN.N.N
```
For example:
```sh
git tag release/v1.10.0
git push origin release/v1.10.0
```
The release job runs on the `xcode` runner label, imports the signing p12 into
a temporary keychain, installs the App Store provisioning profile, builds and
uploads the app with fastlane, then creates or updates the matching Gitea
release with the generated IPA as an asset. The job deletes the temporary
signing keychain in an `always()` cleanup step.
Required repository secrets:
```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_PASSWORD
APPSTORE_PROVISIONING_PROFILE_BASE64
```
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.
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
upload, with `contents: write` permissions. In Gitea this covers release asset
publication.

View File

@@ -1,391 +1,190 @@
require "dotenv"
require "base64"
require "fileutils"
require "json"
require "net/http"
require "open3"
require "openssl"
require "securerandom"
require "shellwords"
require "uri"
require "yaml"
Dotenv.load(File.expand_path("../.env", __dir__))
default_platform(:ios)
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
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")
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"]
APP_IDENTIFIER = "net.buzzert.sybil2"
SCHEME = "Sybil"
TEAM_ID = "DQQH5H6GBD"
PROFILE_NAME = "Sybil AppStore CI"
SIGNING_IDENTITY_NAME = "Apple Distribution: James Magahern (DQQH5H6GBD)"
SIGNING_IDENTITY_SHA1 = "6B74B268C4761720FB2051D01D8BB3E47B55D9F5"
CI_KEYCHAIN_DIR = "/private/var/lib/act_runner/Library/Keychains"
CI_LOGIN_KEYCHAIN = File.join(CI_KEYCHAIN_DIR, "login.keychain")
CI_LOGIN_KEYCHAIN_DB = "#{CI_LOGIN_KEYCHAIN}-db"
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
MATCH_BRANCH = "master"
IOS_ROOT = File.expand_path("..", __dir__)
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
SIGNING_OUTPUT_DIR = File.join(IOS_ROOT, "build/signing")
SCHEME = "Sybil"
TARGET = "SybilApp"
def present?(value)
!value.to_s.strip.empty?
end
def capture(command)
stdout, stderr, status = Open3.capture3(command)
return stdout.strip if status.success?
def release_version
tag = ENV["SYBIL_VERSION_TAG"].to_s
tag = ENV["GITHUB_REF_NAME"].to_s if !present?(tag)
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
version = tag.sub(%r{\Arelease/}, "").sub(/\Av/, "")
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
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
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
end
def local_marketing_version
app_project_settings.fetch("MARKETING_VERSION").to_s
end
def local_build_number
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
end
def normalize_version_tag(tag)
version = tag.to_s.strip.sub(%r{\Arelease/}, "").sub(/\Av/, "")
unless version.match?(/\A\d+\.\d+\.\d+\z/)
UI.user_error!("Release tag #{tag.inspect} must look like release/v1.10.0")
UI.user_error!("Release tag must look like v1.2.3; got #{tag.inspect}")
end
version
end
def release_version
tag = ENV["SYBIL_VERSION_TAG"]
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
normalize_version_tag(tag)
end
def ci_login_keychain_path
return CI_LOGIN_KEYCHAIN_DB if ENV["CI"]
def xcode_build_setting(key, value)
"#{key}=#{value.to_s.shellescape}"
end
candidates = [
"/private/var/lib/act_runner/Library/Keychains/login.keychain-db",
"/var/lib/act_runner/Library/Keychains/login.keychain-db",
"/Users/runner/Library/Keychains/login.keychain-db",
File.expand_path("~/Library/Keychains/login.keychain-db"),
File.expand_path("~/Library/Keychains/login.keychain")
]
existing_candidate = candidates.find { |path| File.file?(path) }
return existing_candidate if existing_candidate
def env_line(key, value)
"#{key}=#{value.to_s.shellescape}"
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 app_store_connect_key_options
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)
return nil unless present?(key_id) && present?(issuer_id)
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
if present?(key_path)
{
key_id: key_id,
issuer_id: issuer_id,
key_filepath: key_path
}
elsif present?(key_content)
{
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
}
end
keychains = sh("security list-keychains -d user", log: false).shellsplit
keychains.find { |path| File.basename(path).start_with?("login.keychain") } || keychains.first || "login.keychain"
end
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
private_lane :cleanup_ci_signing_identity do
next unless ENV["CI"]
app_store_connect_api_key(options)
keychain_path = ENV["MATCH_KEYCHAIN_NAME"].to_s
keychain_path = ci_login_keychain_path unless present?(keychain_path)
sh("security delete-identity -Z #{SIGNING_IDENTITY_SHA1.shellescape} #{keychain_path.shellescape} || true", log: false)
end
desc "Show the version Fastlane will stamp into the next TestFlight archive"
lane :version do
UI.message("Git tag version: #{release_version}")
UI.message("Checked-in app version: #{local_marketing_version}")
UI.message("Checked-in build number: #{local_build_number}")
end
private_lane :prepare_ci_keychain do
next unless ENV["CI"]
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.")
FileUtils.mkdir_p(CI_KEYCHAIN_DIR)
unless File.file?(CI_LOGIN_KEYCHAIN_DB)
sh("security create-keychain -p #{CI_KEYCHAIN_PASSWORD.shellescape} #{CI_LOGIN_KEYCHAIN.shellescape}", log: false)
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)
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.")
ENV["MATCH_KEYCHAIN_NAME"] = ci_login_keychain_path
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
sh("security unlock-keychain -p #{CI_KEYCHAIN_PASSWORD.shellescape} #{ENV.fetch("MATCH_KEYCHAIN_NAME").shellescape}", log: false)
sh("security set-keychain-settings -t 3600 #{ENV.fetch("MATCH_KEYCHAIN_NAME").shellescape}", log: false)
sh("security login-keychain -s #{ENV.fetch("MATCH_KEYCHAIN_NAME").shellescape} || true", log: false)
sh("security list-keychains -d user -s #{ENV.fetch("MATCH_KEYCHAIN_NAME").shellescape}", log: false)
cleanup_ci_signing_identity
end
desc "Build Sybil and upload it to TestFlight"
private_lane :verify_ci_signing_identity do
next unless ENV["CI"]
keychain_path = ENV.fetch("MATCH_KEYCHAIN_NAME")
identities = sh("security find-identity -v -p codesigning #{keychain_path.shellescape}", log: false)
UI.message(identities)
unless identities.include?(SIGNING_IDENTITY_NAME)
UI.user_error!("The runner login keychain does not contain the expected Apple Distribution signing identity")
end
end
private_lane :app_store_api_key do
app_store_connect_api_key(
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"),
is_key_content_base64: true
)
end
private_lane :sync_match_signing do |options|
%w[
APP_STORE_CONNECT_API_KEY
APP_STORE_CONNECT_API_KEY_PATH
SIGH_API_KEY
SIGH_API_KEY_PATH
].each { |key| ENV.delete(key) }
match_options = {
type: "appstore",
readonly: options.fetch(:readonly),
app_identifier: APP_IDENTIFIER,
team_id: TEAM_ID,
profile_name: PROFILE_NAME,
git_branch: MATCH_BRANCH,
git_full_name: "Sybil Release Bot",
git_user_email: "james.magahern@me.com",
api_key: app_store_api_key
}
match_options[:git_url] = ENV.fetch("MATCH_GIT_URL")
match_options[:keychain_name] = ENV["MATCH_KEYCHAIN_NAME"] if present?(ENV["MATCH_KEYCHAIN_NAME"])
match_options[:keychain_password] = ENV["MATCH_KEYCHAIN_PASSWORD"] if present?(ENV["MATCH_KEYCHAIN_PASSWORD"])
match(match_options)
end
desc "Create or update match signing assets"
lane :setup_signing do
prepare_ci_keychain
sync_match_signing(readonly: false)
end
desc "Build and upload to TestFlight"
lane :beta do
version = release_version
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
api_key = load_app_store_connect_api_key
prepare_ci_keychain
unless present?(build_number)
build_number = (local_build_number + 1).to_s
begin
latest = latest_testflight_build_number(
app_identifier: APP_IDENTIFIER,
version: version,
api_key: api_key,
initial_build_number: local_build_number
).to_i
build_number = [latest + 1, local_build_number + 1].max.to_s
rescue StandardError => e
UI.important("Could not look up TestFlight build number: #{e.message}")
UI.important("Using checked-in build number + 1: #{build_number}")
end
end
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
api_key = app_store_api_key
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
xcode_args = [
xcode_build_setting("MARKETING_VERSION", version),
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number),
xcode_build_setting("CODE_SIGN_STYLE", "Manual"),
xcode_build_setting("DEVELOPMENT_TEAM", TEAM_ID),
xcode_build_setting("PROVISIONING_PROFILE_SPECIFIER", PROFILE_SPECIFIER),
xcode_build_setting("CODE_SIGN_IDENTITY", SIGNING_CERTIFICATE_NAME)
]
if present?(ENV["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(
project: PROJECT_FILE,
scheme: SCHEME,
clean: true,
sdk: "iphoneos",
export_method: "app-store",
output_directory: File.join(IOS_ROOT, "build/fastlane"),
output_name: "Sybil-#{version}-#{build_number}.ipa",
xcargs: xcode_args,
export_options: {
method: "app-store",
destination: "export",
signingStyle: "manual",
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_SPECIFIER
},
signingCertificate: SIGNING_CERTIFICATE_NAME,
teamID: TEAM_ID,
manageAppVersionAndBuildNumber: false,
uploadSymbols: true,
stripSwiftSymbols: true
}
increment_version_number(
version_number: release_version,
xcodeproj: PROJECT_FILE
)
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)
latest_build_number = latest_testflight_build_number(
app_identifier: APP_IDENTIFIER,
api_key: api_key,
initial_build_number: 0
)
increment_build_number(
build_number: latest_build_number + 1,
xcodeproj: PROJECT_FILE
)
build_options = {
project: PROJECT_FILE,
scheme: SCHEME,
export_method: "app-store",
export_options: {
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_NAME
}
}
}
if ENV["CI"]
build_options[:xcargs] = [
"CODE_SIGN_IDENTITY=#{SIGNING_IDENTITY_SHA1.shellescape}",
"OTHER_CODE_SIGN_FLAGS=#{("--keychain #{ENV.fetch("MATCH_KEYCHAIN_NAME")}").shellescape}"
].join(" ")
end
begin
sync_match_signing(readonly: true)
verify_ci_signing_identity
build_app(build_options)
ensure
cleanup_ci_signing_identity
end
upload_to_testflight(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
ipa: ipa_path,
skip_waiting_for_build_processing: true
)
end

View File

@@ -1,48 +0,0 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## iOS
### ios version
```sh
[bundle exec] fastlane ios version
```
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
```sh
[bundle exec] fastlane ios beta
```
Build Sybil and upload it to TestFlight
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).