From a68f1e50ca1ae88c63e359eda4c00e01f0c01d80 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 25 Jun 2026 22:41:00 -0700 Subject: [PATCH] Reset iOS TestFlight deployment --- .gitea/workflows/testflight-release.yml | 289 ------------- .gitea/workflows/testflight.yml | 47 +++ .gitignore | 3 +- ios/Gemfile | 2 +- ios/Gemfile.lock | 2 +- ios/fastlane/Appfile | 9 - ios/fastlane/CI.md | 76 ---- ios/fastlane/Fastfile | 512 ++++-------------------- ios/fastlane/README.md | 48 --- 9 files changed, 118 insertions(+), 870 deletions(-) delete mode 100644 .gitea/workflows/testflight-release.yml create mode 100644 .gitea/workflows/testflight.yml delete mode 100644 ios/fastlane/Appfile delete mode 100644 ios/fastlane/CI.md delete mode 100644 ios/fastlane/README.md diff --git a/.gitea/workflows/testflight-release.yml b/.gitea/workflows/testflight-release.yml deleted file mode 100644 index c03b5aa..0000000 --- a/.gitea/workflows/testflight-release.yml +++ /dev/null @@ -1,289 +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)" - 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/login.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[@]}" - security list-keychains -s "${keychain_name}" "${base_keychains[@]}" - else - security list-keychains -d user -s "${keychain_name}" - security list-keychains -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" - { - echo "SYBIL_SIGNING_KEYCHAIN_PATH=${keychain_path}" - echo "SYBIL_SIGNING_KEYCHAIN_NAME=${keychain_name}" - echo "SYBIL_SIGNING_KEYCHAIN_PASSWORD=${keychain_password}" - echo "SYBIL_PREVIOUS_DEFAULT_KEYCHAIN=${previous_default_keychain}" - 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 - 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 - - 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 list-keychains -s "${SYBIL_SIGNING_KEYCHAIN_PATH}" $(security list-keychains | sed 's/[ "]//g') - 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 - - - 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: | - 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:-}" diff --git a/.gitea/workflows/testflight.yml b/.gitea/workflows/testflight.yml new file mode 100644 index 0000000..3a2f5e1 --- /dev/null +++ b/.gitea/workflows/testflight.yml @@ -0,0 +1,47 @@ +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.3" + 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: bundle exec fastlane ios beta diff --git a/.gitignore b/.gitignore index 30bd623..60541b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env - +ios/fastlane/README.md +ios/fastlane/report.xml diff --git a/ios/Gemfile b/ios/Gemfile index 81a4d85..7a118b4 100644 --- a/ios/Gemfile +++ b/ios/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "fastlane", "~> 2.227" +gem "fastlane" diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index 5f2b8f2..4130921 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -225,7 +225,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane (~> 2.227) + fastlane BUNDLED WITH 2.5.23 diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile deleted file mode 100644 index 6941f1b..0000000 --- a/ios/fastlane/Appfile +++ /dev/null @@ -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? diff --git a/ios/fastlane/CI.md b/ios/fastlane/CI.md deleted file mode 100644 index 33004c4..0000000 --- a/ios/fastlane/CI.md +++ /dev/null @@ -1,76 +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, creates the runner user's -login keychain from Gitea secrets, makes that keychain the user default for the -duration of the job, installs the App Store provisioning profile in both the -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 user login keychain -and installed profiles in an `always()` cleanup step. No signing material is -installed into the system keychain. - -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. - -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 -upload, with `contents: write` permissions. In Gitea this covers release asset -publication. diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 6c9efd8..6ad3367 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -1,485 +1,107 @@ -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"] -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__) -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") +APP_IDENTIFIER = "net.buzzert.sybil2" SCHEME = "Sybil" -TARGET = "SybilApp" +TEAM_ID = "DQQH5H6GBD" +PROFILE_NAME = "Sybil AppStore CI" +MATCH_BRANCH = "master" 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 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 - 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 xcode_build_setting(key, value) - "#{key.to_s.shellescape}=#{value.to_s.shellescape}" -end - -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 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 - 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 -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 - - app_store_connect_api_key(options) + 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 - 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}") + 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(match_options) 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.") + desc "Create or update match signing assets" + lane :setup_signing do + setup_ci + sync_match_signing(readonly: false) end - desc "Build Sybil and upload it to TestFlight" + 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 + setup_ci if ENV["CI"] - unless present?(build_number) - build_number = (local_build_number + 1).to_s + api_key = app_store_api_key - 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 + sh("xcodegen --spec project.yml") - UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/) + increment_version_number( + version_number: release_version, + xcodeproj: "Sybil.xcodeproj" + ) - sh("xcodegen --spec #{PROJECT_SPEC.shellescape}") - apply_release_signing_settings + latest_build_number = latest_testflight_build_number( + app_identifier: APP_IDENTIFIER, + api_key: api_key, + initial_build_number: 0 + ) - 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", XCODE_CODE_SIGN_IDENTITY) - ] - if present?(ENV["SYBIL_PROVISIONING_PROFILE_UUID"]) - xcode_args << xcode_build_setting("PROVISIONING_PROFILE", ENV.fetch("SYBIL_PROVISIONING_PROFILE_UUID")) - end - 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(" ") + increment_build_number( + build_number: latest_build_number + 1, + xcodeproj: "Sybil.xcodeproj" + ) - ipa_path = build_app( - project: PROJECT_FILE, + sync_match_signing(readonly: true) + + build_app( 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: EXPORT_SIGNING_CERTIFICATE, - teamID: TEAM_ID, - manageAppVersionAndBuildNumber: false, - uploadSymbols: true, - stripSwiftSymbols: true + APP_IDENTIFIER => PROFILE_NAME + } } ) - 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) - upload_to_testflight( api_key: api_key, - app_identifier: APP_IDENTIFIER, - ipa: ipa_path, skip_waiting_for_build_processing: true ) end diff --git a/ios/fastlane/README.md b/ios/fastlane/README.md deleted file mode 100644 index 0b44874..0000000 --- a/ios/fastlane/README.md +++ /dev/null @@ -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).