name: iOS Internal TestFlight on: workflow_run: workflows: [Frontend Release] types: [completed] branches: [master] workflow_dispatch: inputs: source_sha: description: Full master commit SHA with a verified Frontend Release proof required: true type: string confirmation: description: Type UPLOAD IOS INTERNAL BUILD required: true type: string permissions: contents: read actions: read concurrency: group: ios-internal-testflight cancel-in-progress: false jobs: prepare: name: Resolve verified release if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'workflow_run' && github.event.workflow_run.head_branch == 'master' && github.event.workflow_run.head_repository.full_name == github.repository) runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: source_sha: ${{ steps.resolve.outputs.source_sha }} enabled: ${{ steps.resolve.outputs.enabled }} current: ${{ steps.resolve.outputs.current }} steps: - name: Checkout repository history uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 persist-credentials: false - name: Resolve immutable source and rollout gate id: resolve shell: bash env: EVENT_NAME: ${{ github.event_name }} WORKFLOW_SOURCE_SHA: ${{ github.event.workflow_run.head_sha || '' }} INPUT_SOURCE_SHA: ${{ inputs.source_sha || '' }} CONFIRMATION: ${{ inputs.confirmation || '' }} AUTOMATION_ENABLED: ${{ vars.APP_STORE_AUTOMATION_ENABLED || 'false' }} run: | set -euo pipefail source_sha="$WORKFLOW_SOURCE_SHA" if [[ "$EVENT_NAME" == workflow_dispatch ]]; then [[ "$GITHUB_REF" == refs/heads/master ]] || { echo "Dispatch this workflow from master." >&2; exit 1; } [[ "$CONFIRMATION" == "UPLOAD IOS INTERNAL BUILD" ]] || { echo "Invalid confirmation." >&2; exit 1; } source_sha="${INPUT_SOURCE_SHA,,}" fi [[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "A full lowercase source SHA is required." >&2; exit 1; } git show-ref --verify --quiet refs/remotes/origin/master || { echo "origin/master was not included in the full checkout." >&2; exit 1; } git cat-file -e "${source_sha}^{commit}" git merge-base --is-ancestor "$source_sha" origin/master || { echo "Source is not reachable from master." >&2; exit 1; } current=false [[ "$(git rev-parse origin/master)" == "$source_sha" ]] && current=true enabled=false [[ "$AUTOMATION_ENABLED" == true ]] && enabled=true echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" echo "current=$current" >> "$GITHUB_OUTPUT" echo "enabled=$enabled" >> "$GITHUB_OUTPUT" if [[ "$enabled" != true ]]; then echo "### iOS automation is safely disabled" >> "$GITHUB_STEP_SUMMARY" echo 'Set repository variable `APP_STORE_AUTOMATION_ENABLED=true` only after the signing/API credential canary passes.' >> "$GITHUB_STEP_SUMMARY" elif [[ "$current" != true ]]; then echo "### Stale release skipped" >> "$GITHUB_STEP_SUMMARY" echo "The verified SHA is no longer current master." >> "$GITHUB_STEP_SUMMARY" fi deliver: name: Sign, upload, process, and distribute needs: prepare if: needs.prepare.outputs.enabled == 'true' && needs.prepare.outputs.current == 'true' runs-on: macos-15 timeout-minutes: 120 environment: app-store-signing env: DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer IOS_SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} IOS_PROJECT_PATH: ${{ vars.IOS_PROJECT || 'ios/App/App.xcodeproj' }} IOS_SCHEME: ${{ vars.IOS_SCHEME || 'App' }} IOS_BUNDLE_ID: ${{ vars.IOS_BUNDLE_ID || 'io.truckwash.app' }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} APP_STORE_CONNECT_API_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ vars.APP_STORE_CONNECT_ISSUER_ID || '' }} APP_STORE_CONNECT_APP_ID: ${{ vars.APP_STORE_CONNECT_APP_ID }} TESTFLIGHT_INTERNAL_GROUP_ID: ${{ vars.TESTFLIGHT_INTERNAL_GROUP_ID }} APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }} steps: - name: Checkout verified source uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: ref: ${{ env.IOS_SOURCE_SHA }} fetch-depth: 1 persist-credentials: false - name: Download and verify frontend release proof shell: bash env: GH_TOKEN: ${{ github.token }} TRIGGERING_RELEASE_RUN_ID: ${{ github.event.workflow_run.id || '' }} run: | set -euo pipefail artifact_name="frontend-release-proof-$IOS_SOURCE_SHA" response="$RUNNER_TEMP/proof-artifacts.json" curl --fail --silent --show-error --location \ -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts?name=$artifact_name&per_page=100" > "$response" artifact_id="$(jq -r --arg run "$TRIGGERING_RELEASE_RUN_ID" ' [.artifacts[] | select(.expired == false) | select(($run == "") or ((.workflow_run.id|tostring) == $run))] | sort_by(.created_at) | last | .id // empty' "$response")" [[ "$artifact_id" =~ ^[0-9]+$ ]] || { echo "No verified Frontend Release proof found for $IOS_SOURCE_SHA." >&2; exit 1; } mkdir -p output/frontend-release-proof curl --fail --silent --show-error --location \ -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" -o "$RUNNER_TEMP/proof.zip" unzip -q "$RUNNER_TEMP/proof.zip" -d output/frontend-release-proof PROOF_PATH=output/frontend-release-proof/frontend-release-proof.json node <<'NODE' const fs = require("node:fs"); const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8")); const checks = { schema: proof.schemaVersion === 1, repository: proof.repository === process.env.GITHUB_REPOSITORY, source: proof.sourceSha === process.env.IOS_SOURCE_SHA, publicGate: proof.livePublicGate === "passed", credentialedGate: proof.liveCredentialedGate === "passed", managerGate: proof.releaseManagerGate === "passed", serverVersion: proof.serverVersionUpdated === true, }; const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label); if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`); NODE - name: Verify Xcode 26 and iOS 26 SDK shell: bash run: | set -euo pipefail [[ -x "$DEVELOPER_DIR/usr/bin/xcodebuild" ]] || { echo "Xcode 26.3 is not installed at $DEVELOPER_DIR." >&2; exit 1; } xcode_version="$(xcodebuild -version | sed -n '1p')" sdk_version="$(xcrun --sdk iphoneos --show-sdk-version)" [[ "$xcode_version" =~ ^Xcode\ 26\. ]] || { echo "Xcode 26.x required; found $xcode_version." >&2; exit 1; } [[ "$sdk_version" =~ ^26\. ]] || { echo "iPhoneOS 26 SDK required; found $sdk_version." >&2; exit 1; } echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV" echo "IOS_SDK_VERSION=$sdk_version" >> "$GITHUB_ENV" - name: Setup Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22 cache: npm - name: Setup Ruby and pinned Fastlane uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1 with: ruby-version: "3.3" bundler-cache: true - name: Install web dependencies run: npm ci --legacy-peer-deps - name: Validate storefront and resolve version id: version shell: bash run: | set -euo pipefail node scripts/mobile/validate-app-store.mjs version="$(node -p "JSON.parse(require('fs').readFileSync('ios/release.json')).marketingVersion")" bundle="$(node -p "JSON.parse(require('fs').readFileSync('ios/release.json')).bundleId")" [[ "$bundle" == "$IOS_BUNDLE_ID" ]] echo "IOS_MARKETING_VERSION=$version" >> "$GITHUB_ENV" echo "MOBILE_VERSION_NAME=$version" >> "$GITHUB_ENV" - name: Resolve build number from App Store Connect id: app-store run: node scripts/mobile/app-store-connect.mjs next-build-number - name: Export resolved build number env: BUILD_NUMBER: ${{ steps.app-store.outputs.build_number }} run: | [[ "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] echo "IOS_BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" echo "MOBILE_VERSION_CODE=$BUILD_NUMBER" >> "$GITHUB_ENV" - name: Validate complete Apple environment env: IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64 }} IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} IOS_APP_STORE_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROFILE_BASE64 }} UPLOAD_IOS_TO_APP_STORE: "true" run: node scripts/mobile/check-store-upload-env.mjs --ios - name: Build and sync production iOS shell run: | npm run build npx cap sync ios npm run mobile:permissions:check xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME" - name: Validate native release settings shell: bash run: | set -euo pipefail settings="$RUNNER_TEMP/ios-release-build-settings.txt" xcodebuild -showBuildSettings -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME" -configuration Release CODE_SIGNING_ALLOWED=NO > "$settings" grep -Eq "^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = ${IOS_BUNDLE_ID//./\.}$" "$settings" grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash$' "$settings" grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' "$settings" if grep -q 'isa = PBXShellScriptBuildPhase;' "$IOS_PROJECT_PATH/project.pbxproj"; then echo "Unexpected Xcode shell-script build phase detected." >&2 exit 1 fi - name: Install and validate Apple distribution signing assets shell: bash env: IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64 }} IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} IOS_APP_STORE_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROFILE_BASE64 }} run: | set -euo pipefail certificate_path="$RUNNER_TEMP/apple-distribution.p12" profile_path="$RUNNER_TEMP/app-store.mobileprovision" profile_plist="$RUNNER_TEMP/app-store-profile.plist" keychain_path="$RUNNER_TEMP/app-store-signing.keychain-db" keychain_password="$(openssl rand -base64 48 | tr -d '\n')" echo "::add-mask::$keychain_password" echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" node -e "const fs=require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64.replace(/\\s/g,''),'base64'))" "$certificate_path" node -e "const fs=require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_APP_STORE_PROFILE_BASE64.replace(/\\s/g,''),'base64'))" "$profile_path" chmod 600 "$certificate_path" "$profile_path" security cms -D -i "$profile_path" > "$profile_plist" 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 "$IOS_DISTRIBUTION_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"') security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain_path" identity_sha="$(security find-identity -v -p codesigning "$keychain_path" | awk '/Apple Distribution/ {print $2; exit}')" [[ "$identity_sha" =~ ^[0-9A-Fa-f]{40}$ ]] || { echo "P12 lacks an Apple Distribution identity." >&2; exit 1; } IOS_SIGNING_IDENTITY_SHA="$identity_sha" PROFILE_PLIST="$profile_plist" python3 <<'PY' import datetime, hashlib, os, plistlib, re, sys with open(os.environ["PROFILE_PLIST"], "rb") as handle: profile = plistlib.load(handle) entitlements = profile.get("Entitlements", {}) expiration = profile.get("ExpirationDate") if expiration and expiration.tzinfo is None: expiration = expiration.replace(tzinfo=datetime.timezone.utc) team = os.environ["APPLE_TEAM_ID"] bundle = os.environ["IOS_BUNDLE_ID"] hashes = {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])} checks = { "team": team in profile.get("TeamIdentifier", []), "application identifier": entitlements.get("application-identifier") == f"{team}.{bundle}", "team entitlement": entitlements.get("com.apple.developer.team-identifier") == team, "distribution entitlement": entitlements.get("get-task-allow") is False, "App Store profile has no devices": not profile.get("ProvisionedDevices"), "non-enterprise profile": profile.get("ProvisionsAllDevices") is not True, "expiration": expiration is not None and expiration > datetime.datetime.now(datetime.timezone.utc), "certificate belongs to profile": os.environ["IOS_SIGNING_IDENTITY_SHA"].upper() in hashes, "safe profile name": isinstance(profile.get("Name"), str) and not re.search(r"[\r\n]", profile["Name"]), } failed = [name for name, passed in checks.items() if not passed] if failed: print("Distribution signing validation failed:", *[f"- {name}" for name in failed], sep="\n", file=sys.stderr) sys.exit(1) PY profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$profile_plist")" profile_name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$profile_plist")" profile_install="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" mkdir -p "$(dirname "$profile_install")" echo "IOS_PROFILE_INSTALL_PATH=$profile_install" >> "$GITHUB_ENV" cp "$profile_path" "$profile_install" echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV" echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV" - name: Archive and export App Store IPA shell: bash run: | set -euo pipefail archive="$RUNNER_TEMP/TruckWash.xcarchive" export_dir="$RUNNER_TEMP/ios-export" export_options="$RUNNER_TEMP/ExportOptions.plist" xcodebuild -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME" -configuration Release \ -destination 'generic/platform=iOS' -archivePath "$archive" archive \ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' \ PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" MARKETING_VERSION="$IOS_MARKETING_VERSION" \ CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" DEBUG_INFORMATION_FORMAT='dwarf-with-dsym' EXPORT_OPTIONS="$export_options" python3 <<'PY' import os, plistlib options = {"method":"app-store-connect","signingStyle":"manual","teamID":os.environ["APPLE_TEAM_ID"],"provisioningProfiles":{os.environ["IOS_BUNDLE_ID"]:os.environ["IOS_PROFILE_NAME"]},"stripSwiftSymbols":True,"manageAppVersionAndBuildNumber":False} with open(os.environ["EXPORT_OPTIONS"], "wb") as handle: plistlib.dump(options, handle) PY xcodebuild -exportArchive -archivePath "$archive" -exportPath "$export_dir" -exportOptionsPlist "$export_options" shopt -s nullglob ipa_files=("$export_dir"/*.ipa) [[ ${#ipa_files[@]} -eq 1 ]] || { echo "Expected one IPA; found ${#ipa_files[@]}." >&2; exit 1; } echo "IOS_ARCHIVE_PATH=$archive" >> "$GITHUB_ENV" echo "IOS_IPA_PATH=${ipa_files[0]}" >> "$GITHUB_ENV" - name: Inspect signed IPA shell: bash run: | set -euo pipefail inspect="$RUNNER_TEMP/ios-inspect" unzip -q "$IOS_IPA_PATH" -d "$inspect" shopt -s nullglob apps=("$inspect"/Payload/*.app) [[ ${#apps[@]} -eq 1 ]] || { echo "Expected one Payload app." >&2; exit 1; } app="${apps[0]}" codesign --verify --deep --strict "$app" codesign -d --entitlements :- "$app" > "$RUNNER_TEMP/entitlements.plist" security cms -D -i "$app/embedded.mobileprovision" > "$RUNNER_TEMP/embedded-profile.plist" [[ -f "$app/PrivacyInfo.xcprivacy" ]] [[ -f "$app/da.lproj/InfoPlist.strings" ]] [[ -f "$app/en.lproj/InfoPlist.strings" ]] APP_PATH="$app" python3 <<'PY' import os, plistlib, sys app = os.environ["APP_PATH"] with open(f"{app}/Info.plist", "rb") as handle: info = plistlib.load(handle) with open(os.path.join(os.environ["RUNNER_TEMP"], "entitlements.plist"), "rb") as handle: ent = plistlib.load(handle) with open(os.path.join(os.environ["RUNNER_TEMP"], "embedded-profile.plist"), "rb") as handle: profile = plistlib.load(handle) checks = { "bundle": info.get("CFBundleIdentifier") == os.environ["IOS_BUNDLE_ID"], "version": info.get("CFBundleShortVersionString") == os.environ["IOS_MARKETING_VERSION"], "build": info.get("CFBundleVersion") == os.environ["IOS_BUILD_NUMBER"], "minimum iOS": info.get("MinimumOSVersion") == "15.0", "profile": profile.get("UUID") == os.environ["IOS_PROFILE_UUID"], "non-debug signature": ent.get("get-task-allow") is not True, "signature application id": ent.get("application-identifier") == f'{os.environ["APPLE_TEAM_ID"]}.{os.environ["IOS_BUNDLE_ID"]}', } failed = [name for name, passed in checks.items() if not passed] if failed: print("IPA validation failed:", *[f"- {name}" for name in failed], sep="\n", file=sys.stderr) sys.exit(1) PY - name: Recheck live master before upload env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail live_master_sha="$(curl --fail --silent --show-error --location \ -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/master" | jq -r '.object.sha')" [[ "$live_master_sha" == "$IOS_SOURCE_SHA" ]] || { echo "master advanced while the signed build was queued; refusing upload." >&2 exit 1 } - name: Upload and wait for App Store processing env: TESTFLIGHT_WHAT_TO_TEST: Automatisk intern build fra verificeret master ${{ env.IOS_SOURCE_SHA }}. run: bundle exec fastlane ios upload_internal - name: Assign exact processed build to Internal QA id: distribute env: TESTFLIGHT_WHAT_TO_TEST: Automatisk intern build fra verificeret master ${{ env.IOS_SOURCE_SHA }}. run: node scripts/mobile/app-store-connect.mjs wait-and-distribute - name: Assemble signed release evidence shell: bash env: APP_STORE_BUILD_ID: ${{ steps.distribute.outputs.app_store_build_id }} run: | set -euo pipefail artifact="output/ios-release" mkdir -p "$artifact" cp "$IOS_IPA_PATH" "$artifact/TruckWash-$IOS_MARKETING_VERSION-$IOS_BUILD_NUMBER.ipa" shopt -s nullglob dsyms=("$IOS_ARCHIVE_PATH"/dSYMs/*.dSYM) [[ ${#dsyms[@]} -gt 0 ]] || { echo "Release archive contains no dSYM bundles." >&2; exit 1; } ditto -c -k --sequesterRsrc --keepParent "$IOS_ARCHIVE_PATH/dSYMs" "$artifact/TruckWash-$IOS_MARKETING_VERSION-$IOS_BUILD_NUMBER.dSYM.zip" node scripts/mobile/create-ios-release-manifest.mjs (cd "$artifact" && shasum -a 256 -- * > SHA256SUMS) - name: Upload signed IPA uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: truck-wash-ios-${{ env.IOS_SOURCE_SHA }} path: output/ios-release/*.ipa if-no-files-found: error retention-days: 30 - name: Upload release manifest, dSYM, and checksums uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ios-release-manifest-${{ env.IOS_SOURCE_SHA }} path: | output/ios-release/ios-release-manifest.json output/ios-release/*.dSYM.zip output/ios-release/SHA256SUMS if-no-files-found: error retention-days: 90 - name: Clean up Apple signing material if: always() shell: bash run: | if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then security delete-keychain "$IOS_KEYCHAIN_PATH" || true; fi if [[ -n "${IOS_PROFILE_INSTALL_PATH:-}" ]]; then rm -f "$IOS_PROFILE_INSTALL_PATH"; fi rm -f "$RUNNER_TEMP/apple-distribution.p12" "$RUNNER_TEMP/app-store.mobileprovision" "$RUNNER_TEMP/app-store-profile.plist" disabled: name: Automation disabled needs: prepare if: needs.prepare.outputs.enabled != 'true' runs-on: ubuntu-24.04 steps: - run: echo "App Store automation is disabled; no signing environment or secrets were accessed."