name: iOS Credential Health on: schedule: - cron: "17 6 * * 1" workflow_dispatch: permissions: contents: read concurrency: group: ios-credential-health cancel-in-progress: true jobs: gate: runs-on: ubuntu-24.04 outputs: enabled: ${{ steps.gate.outputs.enabled }} steps: - id: gate env: ENABLED: ${{ vars.APP_STORE_AUTOMATION_ENABLED || 'false' }} run: | enabled=false [[ "$ENABLED" == true ]] && enabled=true echo "enabled=$enabled" >> "$GITHUB_OUTPUT" if [[ "$enabled" != true ]]; then echo "App Store automation is disabled; credential health did not access its environment." >> "$GITHUB_STEP_SUMMARY" fi validate: needs: gate if: needs.gate.outputs.enabled == 'true' runs-on: macos-15 timeout-minutes: 15 environment: app-store-signing env: 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 }} APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }} steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22 - name: Validate API key and app access run: node scripts/mobile/app-store-connect.mjs verify-credentials - name: Validate certificate and profile identity and expiry 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 cert_p12="$RUNNER_TEMP/distribution.p12" cert_pem="$RUNNER_TEMP/distribution.pem" cert_der="$RUNNER_TEMP/distribution.der" profile="$RUNNER_TEMP/distribution.mobileprovision" profile_plist="$RUNNER_TEMP/distribution-profile.plist" keychain="$RUNNER_TEMP/credential-health.keychain-db" keychain_password="$(openssl rand -hex 24)" node -e "const fs=require('fs');fs.writeFileSync(process.argv[1],Buffer.from(process.env.IOS_DISTRIBUTION_CERTIFICATE_P12_BASE64.replace(/\\s/g,''),'base64'))" "$cert_p12" 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" chmod 600 "$cert_p12" "$profile" security create-keychain -p "$keychain_password" "$keychain" security unlock-keychain -p "$keychain_password" "$keychain" security import "$cert_p12" -P "$IOS_DISTRIBUTION_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain" security list-keychains -d user -s "$keychain" security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null security find-identity -v -p codesigning "$keychain" | grep -q 'Apple Distribution' || { echo "Distribution P12 does not contain a usable private signing identity." >&2 exit 1 } openssl pkcs12 -in "$cert_p12" -clcerts -nokeys -passin env:IOS_DISTRIBUTION_CERTIFICATE_PASSWORD -out "$cert_pem" openssl x509 -in "$cert_pem" -noout -subject -issuer -dates openssl x509 -in "$cert_pem" -checkend 2592000 -noout || { echo "Distribution certificate expires within 30 days." >&2; exit 1; } openssl x509 -in "$cert_pem" -outform DER -out "$cert_der" security cms -D -i "$profile" > "$profile_plist" CERT_DER="$cert_der" PROFILE_PLIST="$profile_plist" python3 <<'PY' import datetime, hashlib, os, plistlib, sys with open(os.environ["PROFILE_PLIST"], "rb") as handle: profile = plistlib.load(handle) with open(os.environ["CERT_DER"], "rb") as handle: cert_sha = hashlib.sha1(handle.read()).hexdigest().upper() expiration = profile.get("ExpirationDate") if expiration and expiration.tzinfo is None: expiration = expiration.replace(tzinfo=datetime.timezone.utc) warning = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=30) ent = profile.get("Entitlements", {}) checks = { "team": os.environ["APPLE_TEAM_ID"] in profile.get("TeamIdentifier", []), "bundle": ent.get("application-identifier") == f'{os.environ["APPLE_TEAM_ID"]}.{os.environ["IOS_BUNDLE_ID"]}', "distribution": ent.get("get-task-allow") is False and not profile.get("ProvisionedDevices"), "profile expiry beyond 30 days": expiration is not None and expiration > warning, "certificate belongs to profile": cert_sha in {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])}, } failed = [name for name, ok in checks.items() if not ok] if failed: print("Credential health failed:", *[f"- {name}" for name in failed], sep="\n", file=sys.stderr) sys.exit(1) print(f"Provisioning profile is healthy through {expiration.isoformat()}.") PY - name: Clean temporary credential files if: always() run: | security delete-keychain "$RUNNER_TEMP/credential-health.keychain-db" 2>/dev/null || true rm -f "$RUNNER_TEMP"/distribution.{p12,pem,der,mobileprovision} "$RUNNER_TEMP/distribution-profile.plist"