Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 295 additions & 0 deletions .github/workflows/testflight.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
name: Upload iOS to TestFlight

on:
workflow_dispatch:
inputs:
release_ref:
description: main or an existing vMAJOR.MINOR.PATCH tag on main
required: true
default: main
type: string
build_number:
description: Optional positive integer for CFBundleVersion
required: false
type: string

permissions:
contents: read

concurrency:
group: testflight
cancel-in-progress: false

env:
BUILD_NUMBER: ${{ inputs.build_number || github.run_number }}
# 1Password item references. Update these when the vault layout changes.
OP_CERTIFICATE: op://Letsuno CI/Apple Distribution/Cert_Ethan.p12
OP_CERTIFICATE_PASSWORD: op://Letsuno CI/Apple Distribution/password
OP_ASC_KEY: op://Letsuno CI/App Store Connect/AuthKey_73V7PMGVS5.p8
OP_ASC_KEY_ID: op://Letsuno CI/App Store Connect/key_id
OP_ASC_ISSUER_ID: op://Letsuno CI/App Store Connect/issuer_id
APPLE_TEAM_ID: W65332NA65

jobs:
upload:
name: Build and upload
if: github.ref == 'refs/heads/main'
runs-on: macos-26
timeout-minutes: 60
environment: testflight
steps:
- name: Check out release tooling
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
path: .release-tools
persist-credentials: false
ref: ${{ github.sha }}

- name: Check out release source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
path: source
persist-credentials: false
ref: ${{ inputs.release_ref }}

- name: Show runner context
run: |
xcodebuild -version
swift --version

- name: Install 1Password CLI
uses: 1password/install-cli-action@1a3160d5e9de1ae0803eaa08a88746f5ae3daa50 # v4.1.0

- name: Validate release source
shell: bash
working-directory: source
env:
RELEASE_REF: ${{ inputs.release_ref }}
run: |
set -euo pipefail

source_sha=$(git rev-parse 'HEAD^{commit}')
main_sha=$(git rev-parse 'refs/remotes/origin/main^{commit}')

if [[ "${RELEASE_REF}" == "main" ]]; then
if [[ "${source_sha}" != "${main_sha}" ]]; then
echo "::error::Checked-out main is not the current origin/main commit."
exit 1
fi
elif [[ "${RELEASE_REF}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
tag_ref="refs/tags/${RELEASE_REF}"
if ! git show-ref --verify --quiet "${tag_ref}"; then
echo "::error::Release tag ${RELEASE_REF} does not exist."
exit 1
fi
tag_sha=$(git rev-parse "${tag_ref}^{commit}")
if [[ "${source_sha}" != "${tag_sha}" ]]; then
echo "::error::Checkout did not resolve to release tag ${RELEASE_REF}."
exit 1
fi
if ! git merge-base --is-ancestor "${source_sha}" "${main_sha}"; then
echo "::error::Release tag ${RELEASE_REF} is not on main."
exit 1
fi

configured_version=$(make \
-f "${GITHUB_WORKSPACE}/.release-tools/Makefile" \
release-version)
if [[ "${RELEASE_REF}" != "v${configured_version}" ]]; then
echo "::error::Tag ${RELEASE_REF} does not match MARKETING_VERSION ${configured_version}."
exit 1
fi
else
echo "::error::release_ref must be main or vMAJOR.MINOR.PATCH."
exit 1
fi

echo "SOURCE_SHA=${source_sha}" >> "${GITHUB_ENV}"

- name: Resolve app settings
shell: bash
working-directory: source
run: |
set -euo pipefail

if [[ ! "${BUILD_NUMBER}" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::Build number must be a positive integer."
exit 1
fi

settings=$(make \
-f "${GITHUB_WORKSPACE}/.release-tools/Makefile" \
show-settings 2>/dev/null)
bundle_id=$(awk -F ' = ' \
'/^[[:space:]]+PRODUCT_BUNDLE_IDENTIFIER = / { print $2; exit }' \
<<< "${settings}")
marketing_version=$(awk -F ' = ' \
'/^[[:space:]]+MARKETING_VERSION = / { print $2; exit }' \
<<< "${settings}")

if [[ -z "${bundle_id}" || -z "${marketing_version}" ]]; then
echo "::error::Could not resolve the app bundle identifier and version."
exit 1
fi

echo "IOS_BUNDLE_ID=${bundle_id}" >> "${GITHUB_ENV}"
echo "MARKETING_VERSION=${marketing_version}" >> "${GITHUB_ENV}"

- name: Install publishing credentials
shell: bash
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
run: |
set -euo pipefail

if [[ -z "${OP_SERVICE_ACCOUNT_TOKEN}" ]]; then
echo "::error::Actions secret OP_SERVICE_ACCOUNT_TOKEN is not configured."
exit 1
fi

certificate_path="${RUNNER_TEMP}/distribution.p12"
asc_key_path="${RUNNER_TEMP}/AuthKey.p8"
keychain_path="${RUNNER_TEMP}/app-signing.keychain-db"
keychain_password=$(openssl rand -hex 32)

op read "${OP_CERTIFICATE}" --out-file "${certificate_path}"
certificate_password=$(op read "${OP_CERTIFICATE_PASSWORD}")
asc_key_id=$(op read "${OP_ASC_KEY_ID}")
asc_issuer_id=$(op read "${OP_ASC_ISSUER_ID}")
op read "${OP_ASC_KEY}" --out-file "${asc_key_path}"
chmod 600 "${asc_key_path}"

echo "::add-mask::${certificate_password}"
echo "::add-mask::${asc_key_id}"
echo "::add-mask::${asc_issuer_id}"

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 import "${certificate_path}" \
-P "${certificate_password}" \
-t cert \
-f pkcs12 \
-k "${keychain_path}" \
-T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "${keychain_password}" \
"${keychain_path}"
security list-keychains \
-d user \
-s "${keychain_path}" "${HOME}/Library/Keychains/login.keychain-db"

security find-identity -v -p codesigning "${keychain_path}"

echo "ASC_KEY_PATH=${asc_key_path}" >> "${GITHUB_ENV}"
echo "ASC_KEY_ID=${asc_key_id}" >> "${GITHUB_ENV}"
echo "ASC_ISSUER_ID=${asc_issuer_id}" >> "${GITHUB_ENV}"

- name: Archive iOS app
shell: bash
working-directory: source
run: |
set -euo pipefail

make -f "${GITHUB_WORKSPACE}/.release-tools/Makefile" archive \
DERIVED_DATA_PATH="${RUNNER_TEMP}/DerivedData" \
ARCHIVE_PATH="${RUNNER_TEMP}/UnoClient.xcarchive" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="${APPLE_TEAM_ID}" \
CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \
ALLOW_PROVISIONING_UPDATES=YES \
AUTHENTICATION_KEY_PATH="${ASC_KEY_PATH}" \
AUTHENTICATION_KEY_ID="${ASC_KEY_ID}" \
AUTHENTICATION_KEY_ISSUER_ID="${ASC_ISSUER_ID}"

- name: Export IPA
id: export
shell: bash
working-directory: source
run: |
set -euo pipefail

options_plist="${RUNNER_TEMP}/ExportOptions.plist"
jq -n \
--arg team "${APPLE_TEAM_ID}" \
'{
method: "app-store-connect",
destination: "export",
signingStyle: "automatic",
teamID: $team,
stripSwiftSymbols: true,
uploadSymbols: true,
manageAppVersionAndBuildNumber: false
}' | plutil -convert xml1 -o "${options_plist}" -

plutil -lint "${options_plist}"
plutil -p "${options_plist}"

make -f "${GITHUB_WORKSPACE}/.release-tools/Makefile" export \
ARCHIVE_PATH="${RUNNER_TEMP}/UnoClient.xcarchive" \
EXPORT_PATH="${RUNNER_TEMP}/export" \
EXPORT_OPTIONS_PLIST="${options_plist}" \
ALLOW_PROVISIONING_UPDATES=YES \
AUTHENTICATION_KEY_PATH="${ASC_KEY_PATH}" \
AUTHENTICATION_KEY_ID="${ASC_KEY_ID}" \
AUTHENTICATION_KEY_ISSUER_ID="${ASC_ISSUER_ID}"

ipa_path=$(find "${RUNNER_TEMP}/export" \
-maxdepth 1 \
-type f \
-name '*.ipa' \
-print \
-quit)
if [[ -z "${ipa_path}" ]]; then
echo "::error::Archive export did not produce an IPA."
find "${RUNNER_TEMP}/export" -maxdepth 2 -print
exit 1
fi

echo "ipa_path=${ipa_path}" >> "${GITHUB_OUTPUT}"

- name: Validate and upload to TestFlight
shell: bash
env:
IPA_PATH: ${{ steps.export.outputs.ipa_path }}
run: |
set -euo pipefail

xcrun altool \
--validate-app \
--file "${IPA_PATH}" \
--type ios \
--api-key "${ASC_KEY_ID}" \
--api-issuer "${ASC_ISSUER_ID}" \
--p8-file-path "${ASC_KEY_PATH}" \
--output-format json

xcrun altool \
--upload-app \
--file "${IPA_PATH}" \
--type ios \
--api-key "${ASC_KEY_ID}" \
--api-issuer "${ASC_ISSUER_ID}" \
--p8-file-path "${ASC_KEY_PATH}" \
--output-format json

{
echo "### TestFlight upload"
echo
echo "Uploaded ${MARKETING_VERSION} (${BUILD_NUMBER}) from ${SOURCE_SHA} to App Store Connect."
} >> "${GITHUB_STEP_SUMMARY}"

- name: Clean up publishing credentials
if: ${{ always() }}
shell: bash
run: |
security delete-keychain \
"${RUNNER_TEMP}/app-signing.keychain-db" 2>/dev/null || true

rm -f \
"${RUNNER_TEMP}/distribution.p12" \
"${RUNNER_TEMP}/AuthKey.p8"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
# macOS / editors
.DS_Store
.idea/
/RELEASING.md

## User settings
xcuserdata/

## Build products
DerivedData/
build/
*.xcresult

## Obj-C/Swift specific
Expand Down
2 changes: 1 addition & 1 deletion Configuration/Shared.xcconfig
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
CURRENT_PROJECT_VERSION = 1
MARKETING_VERSION = 0.1.0
MARKETING_VERSION = 0.1.1
SWIFT_STRICT_CONCURRENCY = complete
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
SWIFT_VERSION = 6
53 changes: 52 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,39 @@ TEST_CONFIGURATION ?= Debug
TEST_DESTINATION ?= platform=iOS Simulator,name=iPhone 17 Pro
VERSION_CONFIGURATION := Configuration/Shared.xcconfig

ARCHIVE_PATH ?= $(CURDIR)/build/UnoClient.xcarchive
EXPORT_PATH ?= $(CURDIR)/build/export
EXPORT_OPTIONS_PLIST ?= $(CURDIR)/build/ExportOptions.plist

# Signing stays unset by default so a local archive keeps the project's automatic
# signing. Distribution builds pass the identity in from the environment holding
# the certificate.
CODE_SIGN_STYLE ?=
CODE_SIGN_IDENTITY ?=
DEVELOPMENT_TEAM ?=
PROVISIONING_PROFILE_SPECIFIER ?=
CURRENT_PROJECT_VERSION ?=
ALLOW_PROVISIONING_UPDATES ?=
AUTHENTICATION_KEY_PATH ?=
AUTHENTICATION_KEY_ID ?=
AUTHENTICATION_KEY_ISSUER_ID ?=

ARCHIVE_SETTINGS = \
$(if $(CODE_SIGN_STYLE),CODE_SIGN_STYLE="$(CODE_SIGN_STYLE)") \
$(if $(CODE_SIGN_IDENTITY),CODE_SIGN_IDENTITY="$(CODE_SIGN_IDENTITY)") \
$(if $(DEVELOPMENT_TEAM),DEVELOPMENT_TEAM="$(DEVELOPMENT_TEAM)") \
$(if $(PROVISIONING_PROFILE_SPECIFIER),PROVISIONING_PROFILE_SPECIFIER="$(PROVISIONING_PROFILE_SPECIFIER)") \
$(if $(CURRENT_PROJECT_VERSION),CURRENT_PROJECT_VERSION="$(CURRENT_PROJECT_VERSION)")

PROVISIONING_ARGUMENTS = \
$(if $(filter YES,$(ALLOW_PROVISIONING_UPDATES)),-allowProvisioningUpdates) \
$(if $(AUTHENTICATION_KEY_PATH),-authenticationKeyPath "$(AUTHENTICATION_KEY_PATH)") \
$(if $(AUTHENTICATION_KEY_ID),-authenticationKeyID "$(AUTHENTICATION_KEY_ID)") \
$(if $(AUTHENTICATION_KEY_ISSUER_ID),-authenticationKeyIssuerID "$(AUTHENTICATION_KEY_ISSUER_ID)")

RESULT_BUNDLE_ARGUMENT = $(if $(RESULT_BUNDLE_PATH),-resultBundlePath "$(RESULT_BUNDLE_PATH)")

.PHONY: analyze build format-check project-check quality release-version show-settings test
.PHONY: analyze archive build export format-check ipa project-check quality release-version show-settings test

quality: format-check project-check

Expand Down Expand Up @@ -79,6 +109,27 @@ analyze:
-derivedDataPath "$(DERIVED_DATA_PATH)" \
CODE_SIGNING_ALLOWED=NO

archive:
xcodebuild clean archive \
-project "$(PROJECT)" \
-scheme "$(SCHEME)" \
-configuration "$(BUILD_CONFIGURATION)" \
-destination '$(BUILD_DESTINATION)' \
-derivedDataPath "$(DERIVED_DATA_PATH)" \
-archivePath "$(ARCHIVE_PATH)" \
$(PROVISIONING_ARGUMENTS) \
$(ARCHIVE_SETTINGS)

export:
xcodebuild -exportArchive \
-archivePath "$(ARCHIVE_PATH)" \
-exportPath "$(EXPORT_PATH)" \
-exportOptionsPlist "$(EXPORT_OPTIONS_PLIST)" \
$(PROVISIONING_ARGUMENTS)

ipa: archive
$(MAKE) export

test:
xcodebuild test \
-project "$(PROJECT)" \
Expand Down
2 changes: 2 additions & 0 deletions Support/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
Expand Down
Loading