## What changed - adds production iOS identity, localized storefront metadata, native privacy declarations, App Store-safe artwork, and account-deletion UX - mirrors the live Danish Google Play title, short description, and long description in the App Store metadata source - generates Android launcher/store icons from the opaque iOS marketing master so both platforms use the same white background - adds guarded GitHub Actions workflows for storefront readiness, credential health, signed TestFlight uploads, and App Store candidate preparation - adds pinned Fastlane configuration with a committed dependency lock, release manifest tooling, and an operational App Store runbook - preserves the upstream iOS safe-area implementation while retaining opaque App Store icon assets ## Why The repository previously supported development-signed device bundles but had no production App Store identity, reproducible storefront source of truth, or protected signed-release pipeline. Apple also requires in-app account deletion for apps that support account creation. The Android icon master was transparent, which rendered as black on dark store/device surfaces. ## Impact Automation remains fail-closed behind `APP_STORE_AUTOMATION_ENABLED=false`. No build can upload to TestFlight or change App Store metadata until the switch is deliberately enabled after merge and the remaining release gates are satisfied. ## Validation - focused App Store, iOS icon, and cross-platform icon-background tests pass - every generated Android store/launcher icon is opaque with pure-white corners; iOS marketing artwork is checked the same way - Android icon drift check passes for all 19 generated files - production Vite build and the broader focused release checks completed successfully - storefront metadata is valid; only the two expected screenshot-set warnings remain - App Store Readiness is green at head `4445fecc` - Apple Distribution certificate and App Store profile were independently verified for `HP3FJ4GVL7.io.truckwash.app` - live App Store Connect API authentication succeeded for app `6792777794` - App Store record, free Denmark-only availability, and automatic `Internal QA` TestFlight group are configured - EU trader status, Content Rights, 4+ age rating, and the published App Privacy label are completed in App Store Connect - iPhone and iPad accessibility declarations are configured honestly as pre-release drafts ## Remaining external gates - reviewed iPhone and iPad screenshot sets are still required - an App Review login must be supplied without creating or exposing customer credentials - the first signed TestFlight candidate must run after merge and deliberate automation enablement
424 lines
22 KiB
YAML
424 lines
22 KiB
YAML
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."
|