## Summary - Consolidate the intended changes from #194–#199 onto current master. - Upgrade checkout 7.0.1, upload-artifact 7.0.1, setup-android 4.0.1, setup-java 5.6.0, github-script 9.0.0, and Fastlane 2.237.0. - Pin every upgraded workflow action to its verified immutable commit SHA. - Exclude the abandoned dependency-aware test-graph ancestor entirely. ## Verification - All five action families matched live upstream tag commits (`github-script` uses the peeled annotated-tag commit). - Workflow YAML parse passed. - AI workflow generated-output check passed. - 22 focused mobile/Playwright workflow unit tests passed. - `git diff --check` passed. - Ruby/Bundler is unavailable locally; Linux/macOS Fastlane resolution remains a required CI gate.
625 lines
28 KiB
YAML
625 lines
28 KiB
YAML
name: iOS Device Debug IPA
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
source_ref:
|
|
description: Same-repository branch, tag, or commit to build
|
|
required: true
|
|
default: master
|
|
type: string
|
|
expected_sha:
|
|
description: Full 40-character SHA that source_ref must resolve to
|
|
required: true
|
|
type: string
|
|
confirmation:
|
|
description: Type SIGN IOS DEBUG IPA
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ios-device-debug-${{ github.run_id }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
resolve:
|
|
name: Resolve and verify source
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 10
|
|
outputs:
|
|
source_sha: ${{ steps.resolve.outputs.source_sha }}
|
|
steps:
|
|
- name: Validate dispatch confirmation
|
|
shell: bash
|
|
env:
|
|
CONFIRMATION: ${{ inputs.confirmation }}
|
|
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
|
SOURCE_REF: ${{ inputs.source_ref }}
|
|
WORKFLOW_REF: ${{ github.ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ "$WORKFLOW_REF" != "refs/heads/master" ]]; then
|
|
echo "The signing workflow must be dispatched from the master workflow ref" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$CONFIRMATION" != "SIGN IOS DEBUG IPA" ]]; then
|
|
echo "confirmation must exactly match SIGN IOS DEBUG IPA" >&2
|
|
exit 1
|
|
fi
|
|
if [[ ! "$EXPECTED_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
|
echo "expected_sha must be a full 40-character commit SHA" >&2
|
|
exit 1
|
|
fi
|
|
if [[ -z "$SOURCE_REF" || "$SOURCE_REF" =~ [[:space:]] ]]; then
|
|
echo "source_ref must be non-empty and contain no whitespace" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$SOURCE_REF" == refs/pull/* || "$SOURCE_REF" == pull/* ]]; then
|
|
echo "Pull-request refs are not eligible for device-debug signing" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Checkout same-repository history
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Resolve immutable commit
|
|
id: resolve
|
|
shell: bash
|
|
env:
|
|
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
|
SOURCE_REF: ${{ inputs.source_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
expected_sha="${EXPECTED_SHA,,}"
|
|
|
|
if [[ "$SOURCE_REF" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
|
|
candidate="$SOURCE_REF"
|
|
elif [[ "$SOURCE_REF" == refs/heads/* ]]; then
|
|
candidate="refs/remotes/origin/${SOURCE_REF#refs/heads/}"
|
|
elif [[ "$SOURCE_REF" == refs/tags/* ]]; then
|
|
candidate="$SOURCE_REF"
|
|
elif git show-ref --verify --quiet "refs/remotes/origin/$SOURCE_REF"; then
|
|
candidate="refs/remotes/origin/$SOURCE_REF"
|
|
elif git show-ref --verify --quiet "refs/tags/$SOURCE_REF"; then
|
|
candidate="refs/tags/$SOURCE_REF"
|
|
else
|
|
echo "source_ref does not identify a same-repository branch, tag, or fetched commit" >&2
|
|
exit 1
|
|
fi
|
|
|
|
source_sha="$(git rev-parse --verify "${candidate}^{commit}" 2>/dev/null || true)"
|
|
source_sha="${source_sha,,}"
|
|
if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "source_ref could not be resolved to a commit" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$source_sha" != "$expected_sha" ]]; then
|
|
echo "source_ref resolved to a SHA different from expected_sha" >&2
|
|
exit 1
|
|
fi
|
|
|
|
reachable=false
|
|
while IFS= read -r repository_ref; do
|
|
if git merge-base --is-ancestor "$source_sha" "$repository_ref" 2>/dev/null; then
|
|
reachable=true
|
|
break
|
|
fi
|
|
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin refs/tags)
|
|
if [[ "$reachable" != true ]]; then
|
|
echo "The requested commit is not reachable from a same-repository branch or tag" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
|
echo "Resolved source_ref to $source_sha" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
build:
|
|
name: Build development-signed IPA
|
|
needs: resolve
|
|
runs-on: macos-26
|
|
timeout-minutes: 90
|
|
environment:
|
|
name: mobile-device-debug
|
|
env:
|
|
IOS_PROJECT_PATH: ios/App/App.xcodeproj
|
|
IOS_SCHEME: App
|
|
IOS_DEBUG_BUNDLE_ID: ${{ vars.IOS_DEBUG_BUNDLE_ID || 'io.truckwash.app.debug' }}
|
|
IOS_DEBUG_API_URL: ${{ vars.IOS_DEBUG_API_URL || 'https://api-v2.truckwash.io/master/api' }}
|
|
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
|
MOBILE_VERSION_NAME: 0.0.${{ github.run_number }}
|
|
RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
|
steps:
|
|
- name: Checkout resolved source
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.resolve.outputs.source_sha }}
|
|
fetch-depth: 1
|
|
persist-credentials: false
|
|
|
|
- name: Verify runner and resolve build number
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
xcode_version_output="$(xcodebuild -version)"
|
|
IFS= read -r xcode_version <<< "$xcode_version_output"
|
|
xcode_major="$(awk '{split($2, version, "."); print version[1]}' <<< "$xcode_version")"
|
|
if [[ ! "$xcode_major" =~ ^[0-9]+$ ]] || (( xcode_major < 26 )); then
|
|
echo "Xcode 26 or newer is required; found $xcode_version" >&2
|
|
exit 1
|
|
fi
|
|
|
|
build_number="$((10#$GITHUB_RUN_NUMBER * 100 + 10#$GITHUB_RUN_ATTEMPT))"
|
|
if [[ ! "$build_number" =~ ^[1-9][0-9]{0,17}$ ]]; then
|
|
echo "Derived build number is outside Apple's supported integer format" >&2
|
|
exit 1
|
|
fi
|
|
echo "MOBILE_VERSION_CODE=$build_number" >> "$GITHUB_ENV"
|
|
echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV"
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v5
|
|
with:
|
|
node-version: 22
|
|
cache: npm
|
|
|
|
- name: Install dependencies
|
|
run: npm ci --legacy-peer-deps
|
|
|
|
- name: Verify stable API and Capacitor iOS CORS
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
api_base="${IOS_DEBUG_API_URL%/}"
|
|
curl --fail --silent --show-error --location \
|
|
--connect-timeout 10 --max-time 20 \
|
|
--header 'Accept: application/json' \
|
|
--output /dev/null \
|
|
"$api_base/ping"
|
|
|
|
cors_headers="$RUNNER_TEMP/ios-debug-cors-headers.txt"
|
|
cors_body="$RUNNER_TEMP/ios-debug-cors-body.txt"
|
|
cors_status="$(curl --silent --show-error \
|
|
--connect-timeout 10 --max-time 20 \
|
|
--request OPTIONS \
|
|
--header 'Origin: capacitor://localhost' \
|
|
--header 'Access-Control-Request-Method: POST' \
|
|
--header 'Access-Control-Request-Headers: authorization,content-type' \
|
|
--dump-header "$cors_headers" \
|
|
--output "$cors_body" \
|
|
--write-out '%{http_code}' \
|
|
"$api_base/ping")"
|
|
if [[ ! "$cors_status" =~ ^2[0-9][0-9]$ ]]; then
|
|
echo "Stable API rejected the Capacitor iOS CORS preflight with HTTP $cors_status" >&2
|
|
sed -n '1,20p' "$cors_body" >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -Eiq '^access-control-allow-origin:[[:space:]]*capacitor://localhost[[:space:]]*$' "$cors_headers"; then
|
|
echo "Stable API did not allow the exact capacitor://localhost origin" >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -Eiq '^access-control-allow-credentials:[[:space:]]*true[[:space:]]*$' "$cors_headers"; then
|
|
echo "Stable API did not allow credentialed Capacitor requests" >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -Eiq '^access-control-allow-methods:.*[[:space:],]POST([[:space:],]|$)' "$cors_headers"; then
|
|
echo "Stable API did not allow POST from the Capacitor origin" >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Authorization([[:space:],]|$)' "$cors_headers"; then
|
|
echo "Stable API did not allow the Authorization header from the Capacitor origin" >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -Eiq '^access-control-allow-headers:.*[[:space:],]Content-Type([[:space:],]|$)' "$cors_headers"; then
|
|
echo "Stable API did not allow the Content-Type header from the Capacitor origin" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Build stable production web payload
|
|
env:
|
|
RELEASE_COMMIT_SHA: ${{ env.RESOLVED_SOURCE_SHA }}
|
|
VITE_API_URL: ${{ env.IOS_DEBUG_API_URL }}
|
|
VITE_RELEASE_MANAGER_CONTROL_API_URL: ${{ env.IOS_DEBUG_API_URL }}
|
|
VITE_RELEASE_PUBLIC_GATEWAY_API_URL: https://api-v2.truckwash.io
|
|
run: |
|
|
npm run build
|
|
node -e "const manifest = require('./dist/release-manifest.json'); if (manifest.commit_sha !== process.env.RESOLVED_SOURCE_SHA) { throw new Error('Web release manifest source SHA mismatch'); }"
|
|
|
|
- name: Sync and validate iOS shell
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
npx cap sync ios
|
|
npm run mobile:permissions:check
|
|
if grep -q 'isa = PBXShellScriptBuildPhase;' "$IOS_PROJECT_PATH/project.pbxproj"; then
|
|
echo "Unexpected Xcode shell-script build phase detected" >&2
|
|
exit 1
|
|
fi
|
|
xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
|
|
|
|
- name: Validate native Debug and Release settings
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
debug_settings="$RUNNER_TEMP/ios-debug-build-settings.txt"
|
|
release_settings="$RUNNER_TEMP/ios-release-build-settings.txt"
|
|
xcodebuild -showBuildSettings \
|
|
-project "$IOS_PROJECT_PATH" \
|
|
-scheme "$IOS_SCHEME" \
|
|
-configuration Debug \
|
|
CODE_SIGNING_ALLOWED=NO > "$debug_settings"
|
|
xcodebuild -showBuildSettings \
|
|
-project "$IOS_PROJECT_PATH" \
|
|
-scheme "$IOS_SCHEME" \
|
|
-configuration Release \
|
|
CODE_SIGNING_ALLOWED=NO > "$release_settings"
|
|
|
|
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app\.debug$' "$debug_settings"
|
|
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash Debug$' "$debug_settings"
|
|
grep -Eq '^[[:space:]]*PRODUCT_NAME = TruckWashDebug$' "$debug_settings"
|
|
grep -Eq '^[[:space:]]*CAPACITOR_DEBUG = true$' "$debug_settings"
|
|
grep -Eq '^[[:space:]]*DEBUG_INFORMATION_FORMAT = dwarf-with-dsym$' "$debug_settings"
|
|
grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' "$debug_settings"
|
|
grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app$' "$release_settings"
|
|
grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash$' "$release_settings"
|
|
grep -Eq '^[[:space:]]*PRODUCT_NAME = App$' "$release_settings"
|
|
|
|
- name: Install and validate Apple development signing assets
|
|
shell: bash
|
|
env:
|
|
IOS_DEBUG_CERTIFICATE_BASE64: ${{ secrets.IOS_DEBUG_CERTIFICATE_BASE64 }}
|
|
IOS_DEBUG_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DEBUG_CERTIFICATE_PASSWORD }}
|
|
IOS_DEBUG_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_DEBUG_PROVISION_PROFILE_BASE64 }}
|
|
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
|
|
run: |
|
|
set -euo pipefail
|
|
node scripts/mobile/check-ios-debug-signing-env.mjs
|
|
|
|
certificate_path="$RUNNER_TEMP/ios-debug-development.p12"
|
|
profile_path="$RUNNER_TEMP/ios-debug-development.mobileprovision"
|
|
profile_plist="$RUNNER_TEMP/ios-debug-development-profile.plist"
|
|
keychain_path="$RUNNER_TEMP/ios-debug-signing.keychain-db"
|
|
keychain_password="$(openssl rand -base64 48 | tr -d '\n')"
|
|
echo "::add-mask::$keychain_password"
|
|
|
|
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_CERTIFICATE_BASE64.replace(/\\s/g, ''), 'base64'))" "$certificate_path"
|
|
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_PROVISION_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_DEBUG_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"
|
|
|
|
signing_identity_sha="$(security find-identity -v -p codesigning "$keychain_path" | awk '/Apple Development/ {print $2; exit}')"
|
|
if [[ ! "$signing_identity_sha" =~ ^[0-9A-Fa-f]{40}$ ]]; then
|
|
echo "The PKCS#12 file does not contain a valid Apple Development signing identity" >&2
|
|
exit 1
|
|
fi
|
|
|
|
IOS_SIGNING_IDENTITY_SHA="$signing_identity_sha" PROFILE_PLIST="$profile_plist" python3 <<'PY'
|
|
import datetime
|
|
import hashlib
|
|
import os
|
|
import plistlib
|
|
import re
|
|
import sys
|
|
|
|
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
|
|
profile = plistlib.load(handle)
|
|
|
|
team_id = os.environ["APPLE_TEAM_ID"]
|
|
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
|
|
entitlements = profile.get("Entitlements", {})
|
|
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
|
|
provisioned = set(profile.get("ProvisionedDevices", []))
|
|
expiration = profile.get("ExpirationDate")
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
if expiration and expiration.tzinfo is None:
|
|
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
|
|
|
|
checks = {
|
|
"profile team identifier": team_id in profile.get("TeamIdentifier", []),
|
|
"application identifier": entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
|
|
"entitlement team identifier": entitlements.get("com.apple.developer.team-identifier") == team_id,
|
|
"development entitlement": entitlements.get("get-task-allow") is True,
|
|
"profile expiration": expiration is not None and expiration > now,
|
|
"registered devices": bool(allowed) and allowed <= provisioned,
|
|
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
|
|
"developer certificate": bool(profile.get("DeveloperCertificates")),
|
|
"profile UUID": isinstance(profile.get("UUID"), str) and re.fullmatch(r"[0-9A-Fa-f-]{36}", profile["UUID"]) is not None,
|
|
"safe profile name": isinstance(profile.get("Name"), str) and not any(char in profile["Name"] for char in "\r\n"),
|
|
}
|
|
identity_sha = os.environ["IOS_SIGNING_IDENTITY_SHA"].upper()
|
|
certificate_hashes = {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])}
|
|
checks["certificate belongs to profile"] = identity_sha in certificate_hashes
|
|
|
|
failures = [label for label, passed in checks.items() if not passed]
|
|
if failures:
|
|
print("Development provisioning profile validation failed:", file=sys.stderr)
|
|
for failure in failures:
|
|
print(f"- {failure}", 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_expiration="$(PROFILE_PLIST="$profile_plist" python3 - <<'PY'
|
|
import datetime
|
|
import os
|
|
import plistlib
|
|
|
|
with open(os.environ["PROFILE_PLIST"], "rb") as handle:
|
|
expiration = plistlib.load(handle)["ExpirationDate"]
|
|
if expiration.tzinfo is None:
|
|
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
|
|
print(expiration.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z"))
|
|
PY
|
|
)"
|
|
profile_install_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
|
|
profile_install_path="$profile_install_dir/$profile_uuid.mobileprovision"
|
|
mkdir -p "$profile_install_dir"
|
|
cp "$profile_path" "$profile_install_path"
|
|
|
|
echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
|
|
echo "IOS_PROFILE_INSTALL_PATH=$profile_install_path" >> "$GITHUB_ENV"
|
|
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
|
|
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
|
|
echo "IOS_PROFILE_EXPIRATION=$profile_expiration" >> "$GITHUB_ENV"
|
|
|
|
- name: Archive Debug app with Apple Development signing
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
archive_path="$RUNNER_TEMP/TruckWashDebug.xcarchive"
|
|
xcodebuild \
|
|
-project "$IOS_PROJECT_PATH" \
|
|
-scheme "$IOS_SCHEME" \
|
|
-configuration Debug \
|
|
-destination "generic/platform=iOS" \
|
|
-archivePath "$archive_path" \
|
|
archive \
|
|
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
|
CODE_SIGN_STYLE=Manual \
|
|
CODE_SIGN_IDENTITY="Apple Development" \
|
|
PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \
|
|
PRODUCT_BUNDLE_IDENTIFIER="$IOS_DEBUG_BUNDLE_ID" \
|
|
MARKETING_VERSION="$MOBILE_VERSION_NAME" \
|
|
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" \
|
|
DEBUG_INFORMATION_FORMAT="dwarf-with-dsym" \
|
|
ONLY_ACTIVE_ARCH=NO
|
|
echo "IOS_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV"
|
|
|
|
- name: Export development IPA
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
export_options="$RUNNER_TEMP/ios-debug-ExportOptions.plist"
|
|
export_path="$RUNNER_TEMP/ios-debug-export"
|
|
EXPORT_OPTIONS="$export_options" python3 <<'PY'
|
|
import os
|
|
import plistlib
|
|
|
|
options = {
|
|
"method": "development",
|
|
"signingStyle": "manual",
|
|
"teamID": os.environ["APPLE_TEAM_ID"],
|
|
"provisioningProfiles": {
|
|
os.environ["IOS_DEBUG_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 "$IOS_ARCHIVE_PATH" \
|
|
-exportPath "$export_path" \
|
|
-exportOptionsPlist "$export_options"
|
|
|
|
shopt -s nullglob
|
|
ipa_files=("$export_path"/*.ipa)
|
|
if [[ ${#ipa_files[@]} -ne 1 ]]; then
|
|
echo "Expected exactly one exported IPA; found ${#ipa_files[@]}" >&2
|
|
exit 1
|
|
fi
|
|
echo "IOS_EXPORTED_IPA=${ipa_files[0]}" >> "$GITHUB_ENV"
|
|
|
|
- name: Validate exported IPA and embedded signature
|
|
shell: bash
|
|
env:
|
|
IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }}
|
|
run: |
|
|
set -euo pipefail
|
|
inspect_dir="$RUNNER_TEMP/ios-debug-inspect"
|
|
mkdir -p "$inspect_dir"
|
|
unzip -q "$IOS_EXPORTED_IPA" -d "$inspect_dir"
|
|
shopt -s nullglob
|
|
app_bundles=("$inspect_dir"/Payload/*.app)
|
|
if [[ ${#app_bundles[@]} -ne 1 ]]; then
|
|
echo "Expected exactly one Payload app; found ${#app_bundles[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
app_path="${app_bundles[0]}"
|
|
app_info="$app_path/Info.plist"
|
|
embedded_profile="$RUNNER_TEMP/ios-debug-embedded-profile.plist"
|
|
signature_entitlements="$RUNNER_TEMP/ios-debug-signature-entitlements.plist"
|
|
signature_details="$RUNNER_TEMP/ios-debug-signature-details.txt"
|
|
security cms -D -i "$app_path/embedded.mobileprovision" > "$embedded_profile"
|
|
codesign --verify --deep --strict "$app_path"
|
|
codesign -d --entitlements :- "$app_path" > "$signature_entitlements"
|
|
codesign -dvv "$app_path" > /dev/null 2> "$signature_details"
|
|
grep -Fq "TeamIdentifier=$APPLE_TEAM_ID" "$signature_details"
|
|
grep -Eq '^Authority=Apple Development:' "$signature_details"
|
|
|
|
executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_info")"
|
|
architectures="$(lipo -archs "$app_path/$executable_name")"
|
|
if [[ " $architectures " != *" arm64 "* ]]; then
|
|
echo "Exported executable does not contain arm64" >&2
|
|
exit 1
|
|
fi
|
|
|
|
APP_INFO="$app_info" PROFILE_PLIST="$embedded_profile" SIGNATURE_ENTITLEMENTS="$signature_entitlements" python3 <<'PY'
|
|
import datetime
|
|
import os
|
|
import plistlib
|
|
import sys
|
|
|
|
def load(path):
|
|
with open(path, "rb") as handle:
|
|
return plistlib.load(handle)
|
|
|
|
info = load(os.environ["APP_INFO"])
|
|
profile = load(os.environ["PROFILE_PLIST"])
|
|
signature = load(os.environ["SIGNATURE_ENTITLEMENTS"])
|
|
profile_entitlements = profile.get("Entitlements", {})
|
|
team_id = os.environ["APPLE_TEAM_ID"]
|
|
bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"]
|
|
allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()}
|
|
provisioned = set(profile.get("ProvisionedDevices", []))
|
|
expiration = profile.get("ExpirationDate")
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
if expiration and expiration.tzinfo is None:
|
|
expiration = expiration.replace(tzinfo=datetime.timezone.utc)
|
|
|
|
checks = {
|
|
"bundle identifier": info.get("CFBundleIdentifier") == bundle_id,
|
|
"display name": info.get("CFBundleDisplayName") == "Truck Wash Debug",
|
|
"debug executable": info.get("CFBundleExecutable") == "TruckWashDebug",
|
|
"marketing version": info.get("CFBundleShortVersionString") == os.environ["MOBILE_VERSION_NAME"],
|
|
"build number": info.get("CFBundleVersion") == os.environ["MOBILE_VERSION_CODE"],
|
|
"minimum iOS": info.get("MinimumOSVersion") == "15.0",
|
|
"profile UUID": profile.get("UUID") == os.environ["IOS_PROFILE_UUID"],
|
|
"profile team": team_id in profile.get("TeamIdentifier", []),
|
|
"profile application identifier": profile_entitlements.get("application-identifier") == f"{team_id}.{bundle_id}",
|
|
"development profile": profile_entitlements.get("get-task-allow") is True,
|
|
"signature application identifier": signature.get("application-identifier") == f"{team_id}.{bundle_id}",
|
|
"signature team identifier": signature.get("com.apple.developer.team-identifier") == team_id,
|
|
"debuggable signature": signature.get("get-task-allow") is True,
|
|
"profile expiration": expiration is not None and expiration > now,
|
|
"registered devices": bool(allowed) and allowed <= provisioned,
|
|
"non-enterprise profile": profile.get("ProvisionsAllDevices") is not True,
|
|
}
|
|
failures = [label for label, passed in checks.items() if not passed]
|
|
if failures:
|
|
print("Exported development IPA validation failed:", file=sys.stderr)
|
|
for failure in failures:
|
|
print(f"- {failure}", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
|
|
- name: Assemble debug artifact
|
|
shell: bash
|
|
env:
|
|
SOURCE_REF: ${{ inputs.source_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
short_sha="${RESOLVED_SOURCE_SHA:0:12}"
|
|
artifact_name="truck-wash-debug-${MOBILE_VERSION_NAME}-${short_sha}"
|
|
artifact_dir="$RUNNER_TEMP/device-debug-artifact"
|
|
ipa_filename="$artifact_name.ipa"
|
|
dsym_filename="$artifact_name.dSYM.zip"
|
|
mkdir -p "$artifact_dir"
|
|
cp "$IOS_EXPORTED_IPA" "$artifact_dir/$ipa_filename"
|
|
|
|
shopt -s nullglob
|
|
dsym_bundles=("$IOS_ARCHIVE_PATH"/dSYMs/*.dSYM)
|
|
if [[ ${#dsym_bundles[@]} -eq 0 ]]; then
|
|
echo "The Debug archive did not contain any dSYM bundles" >&2
|
|
exit 1
|
|
fi
|
|
ditto -c -k --sequesterRsrc --keepParent "$IOS_ARCHIVE_PATH/dSYMs" "$artifact_dir/$dsym_filename"
|
|
|
|
capacitor_version="$(node -p "require('./node_modules/@capacitor/core/package.json').version")"
|
|
BUILT_AT_UTC="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
|
|
CAPACITOR_VERSION="$capacitor_version" \
|
|
DSYM_FILENAME="$dsym_filename" \
|
|
IPA_FILENAME="$ipa_filename" \
|
|
MANIFEST_PATH="$artifact_dir/manifest.json" \
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
|
|
manifest = {
|
|
"schema_version": 1,
|
|
"repository": os.environ["GITHUB_REPOSITORY"],
|
|
"source_ref": os.environ["SOURCE_REF"],
|
|
"source_sha": os.environ["RESOLVED_SOURCE_SHA"],
|
|
"workflow_run": int(os.environ["GITHUB_RUN_NUMBER"]),
|
|
"workflow_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
|
|
"built_at_utc": os.environ["BUILT_AT_UTC"],
|
|
"api_url": os.environ["IOS_DEBUG_API_URL"],
|
|
"release_manager_control_api_url": os.environ["IOS_DEBUG_API_URL"],
|
|
"bundle_id": os.environ["IOS_DEBUG_BUNDLE_ID"],
|
|
"display_name": "Truck Wash Debug",
|
|
"executable_name": "TruckWashDebug",
|
|
"version": os.environ["MOBILE_VERSION_NAME"],
|
|
"build": os.environ["MOBILE_VERSION_CODE"],
|
|
"minimum_ios": "15.0",
|
|
"capacitor_version": os.environ["CAPACITOR_VERSION"],
|
|
"xcode_version": os.environ["XCODE_VERSION"],
|
|
"signing_method": "development",
|
|
"profile_expiration_utc": os.environ["IOS_PROFILE_EXPIRATION"],
|
|
"ipa_filename": os.environ["IPA_FILENAME"],
|
|
"dsym_filename": os.environ["DSYM_FILENAME"],
|
|
}
|
|
with open(os.environ["MANIFEST_PATH"], "w", encoding="utf-8") as handle:
|
|
json.dump(manifest, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
PY
|
|
|
|
(
|
|
cd "$artifact_dir"
|
|
shasum -a 256 "$ipa_filename" "$dsym_filename" manifest.json > SHA256SUMS
|
|
)
|
|
echo "IOS_DEBUG_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV"
|
|
echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV"
|
|
|
|
- name: Upload device-debug artifact
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }}
|
|
path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }}
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
- name: Clean up Apple signing assets
|
|
if: always()
|
|
shell: bash
|
|
run: |
|
|
if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then
|
|
security delete-keychain "$IOS_KEYCHAIN_PATH" || true
|
|
else
|
|
security delete-keychain "$RUNNER_TEMP/ios-debug-signing.keychain-db" || true
|
|
fi
|
|
if [[ -n "${IOS_PROFILE_INSTALL_PATH:-}" ]]; then
|
|
rm -f "$IOS_PROFILE_INSTALL_PATH"
|
|
fi
|
|
rm -f \
|
|
"$RUNNER_TEMP/ios-debug-development.p12" \
|
|
"$RUNNER_TEMP/ios-debug-development.mobileprovision" \
|
|
"$RUNNER_TEMP/ios-debug-development-profile.plist" \
|
|
"$RUNNER_TEMP/ios-debug-embedded-profile.plist" \
|
|
"$RUNNER_TEMP/ios-debug-signature-entitlements.plist" \
|
|
"$RUNNER_TEMP/ios-debug-signature-details.txt"
|