diff --git a/Jenkinsfile.release b/Jenkinsfile.release new file mode 100644 index 0000000..fffbaa3 --- /dev/null +++ b/Jenkinsfile.release @@ -0,0 +1,58 @@ +pipeline { + agent { label 'um-trace' } + + parameters { + string( + name: 'IOS_BUILD_NUMBER', + defaultValue: '2', + description: 'Positive, monotonically increasing App Store build number.' + ) + } + + options { + buildDiscarder(logRotator(numToKeepStr: '10')) + disableConcurrentBuilds() + timeout(time: 60, unit: 'MINUTES') + } + + environment { + DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer' + DOTNET_ROOT = '/Users/brent/.dotnet' + DOTNET_CLI_TELEMETRY_OPTOUT = '1' + DOTNET_NOLOGO = '1' + NUGET_XMLDOC_MODE = 'skip' + PATH = "/Users/brent/.dotnet:/opt/homebrew/bin:${env.PATH}" + UM_TRACE_CODESIGN_KEY = 'Apple Distribution: Underground Magnetics, Inc. (W2N8APPQ2C)' + UM_TRACE_CODESIGN_PROVISION = 'UM Trace App Store' + } + + stages { + stage('Toolchain and Signing') { + steps { + sh './scripts/ci/verify-macos-agent.sh' + } + } + + stage('Tests') { + steps { + sh './scripts/ci/test.sh' + } + } + + stage('Signed iOS IPA') { + steps { + sh './scripts/ci/build-ios-signed.sh "$IOS_BUILD_NUMBER"' + } + } + } + + post { + always { + archiveArtifacts( + artifacts: 'artifacts/ios-signed/**/*', + allowEmptyArchive: true, + fingerprint: true + ) + } + } +} diff --git a/doc/iOS_TestFlight.md b/doc/iOS_TestFlight.md index 1f6889e..054fa60 100644 --- a/doc/iOS_TestFlight.md +++ b/doc/iOS_TestFlight.md @@ -43,6 +43,16 @@ The script deliberately supplies Xcode through `DEVELOPER_DIR`; it does not chan global `xcode-select` setting. It restores only the iOS target, builds with the iOS 26 SDK, selects the installed `UM Trace App Store` profile, and prints the generated `.ipa` path. +## Create a signed IPA in Jenkins + +The manually run `um-trace-ios-release` pipeline uses `Jenkinsfile.release`. Supply an +`IOS_BUILD_NUMBER` that is higher than every build previously uploaded to App Store Connect. The +pipeline runs the tests, signs with the distribution identity and `UM Trace App Store` profile, +verifies the resulting bundle signature and identity, and archives the IPA plus its SHA-256 file. + +This pipeline only creates a signed artifact. It does not upload or submit anything to App Store +Connect, so TestFlight release remains a separate, deliberate step. + Before uploading, verify that this command lists a valid distribution identity: ```sh diff --git a/scripts/ci/build-ios-signed.sh b/scripts/ci/build-ios-signed.sh new file mode 100755 index 0000000..d979e4c --- /dev/null +++ b/scripts/ci/build-ios-signed.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +build_number="${1:-}" +codesign_key="${UM_TRACE_CODESIGN_KEY:-Apple Distribution: Underground Magnetics, Inc. (W2N8APPQ2C)}" +codesign_profile="${UM_TRACE_CODESIGN_PROVISION:-UM Trace App Store}" +profile_dir="${HOME}/Library/MobileDevice/Provisioning Profiles" +artifacts_dir="${repo_dir}/artifacts/ios-signed" +artifact_name="UMTrace-ios-build-${build_number}.ipa" + +if [[ ! "${build_number}" =~ ^[1-9][0-9]*$ ]]; then + echo "A positive numeric iOS build number is required." >&2 + exit 2 +fi + +if ! security find-identity -v -p codesigning | grep -Fq "${codesign_key}"; then + echo "Signing identity is unavailable: ${codesign_key}" >&2 + exit 3 +fi + +profile_found=false +if [[ -d "${profile_dir}" ]]; then + for profile in "${profile_dir}"/*.mobileprovision; do + [[ -f "${profile}" ]] || continue + installed_name="$(openssl smime -inform der -verify -noverify -in "${profile}" 2>/dev/null | plutil -extract Name raw -o - - 2>/dev/null || true)" + if [[ "${installed_name}" == "${codesign_profile}" ]]; then + profile_found=true + break + fi + done +fi + +if [[ "${profile_found}" != true ]]; then + echo "Provisioning profile is unavailable: ${codesign_profile}" >&2 + exit 3 +fi + +UM_TRACE_CODESIGN_KEY="${codesign_key}" \ +UM_TRACE_CODESIGN_PROVISION="${codesign_profile}" \ + "${repo_dir}/scripts/publish-testflight.sh" "${build_number}" + +source_ipa="$(find "${repo_dir}/FieldLogger/bin/Release/net9.0-ios/ios-arm64" -type f -name '*.ipa' -print -quit)" +if [[ -z "${source_ipa}" || ! -f "${source_ipa}" ]]; then + echo "The signed IPA was not created." >&2 + exit 4 +fi + +mkdir -p "${artifacts_dir}" +ditto "${source_ipa}" "${artifacts_dir}/${artifact_name}" + +verification_dir="$(mktemp -d)" +ditto -x -k "${artifacts_dir}/${artifact_name}" "${verification_dir}" +app_bundle="$(find "${verification_dir}/Payload" -maxdepth 1 -type d -name '*.app' -print -quit)" + +if [[ -z "${app_bundle}" ]]; then + echo "The IPA does not contain an app bundle." >&2 + exit 4 +fi + +codesign --verify --deep --strict --verbose=2 "${app_bundle}" + +bundle_id="$(plutil -extract CFBundleIdentifier raw -o - "${app_bundle}/Info.plist")" +signed_build_number="$(plutil -extract CFBundleVersion raw -o - "${app_bundle}/Info.plist")" +if [[ "${bundle_id}" != "com.umagul.trace" || "${signed_build_number}" != "${build_number}" ]]; then + echo "Signed bundle metadata is incorrect: ${bundle_id} (${signed_build_number})." >&2 + exit 5 +fi + +( + cd "${artifacts_dir}" + shasum -a 256 "${artifact_name}" > "${artifact_name}.sha256" +) + +echo "Verified signed iOS IPA: ${artifacts_dir}/${artifact_name}" diff --git a/scripts/publish-testflight.sh b/scripts/publish-testflight.sh index 8eda0cf..fc35c98 100755 --- a/scripts/publish-testflight.sh +++ b/scripts/publish-testflight.sh @@ -33,6 +33,12 @@ export DEVELOPER_DIR="${developer_dir}" # referenced headless library's assets file. Restore that library explicitly before publish. "${dotnet_bin}" restore "${repo_dir}/src/FieldLogger.Sync/FieldLogger.Sync.csproj" +"${dotnet_bin}" clean "${repo_dir}/FieldLogger/FieldLogger.csproj" \ + -f net9.0-ios \ + -c Release \ + -p:TargetFrameworks=net9.0-ios \ + -p:RuntimeIdentifier=ios-arm64 + "${dotnet_bin}" publish "${repo_dir}/FieldLogger/FieldLogger.csproj" \ -p:TargetFrameworks=net9.0-ios \ -f net9.0-ios \