## 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
180 lines
8.5 KiB
YAML
180 lines
8.5 KiB
YAML
name: iOS App Store Candidate
|
|
|
|
on:
|
|
push:
|
|
tags: ["ios-v*"]
|
|
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
|
|
concurrency:
|
|
group: ios-app-store-candidate
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
resolve:
|
|
name: Resolve exact tested build
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
outputs:
|
|
enabled: ${{ steps.resolve.outputs.enabled }}
|
|
source_sha: ${{ steps.resolve.outputs.source_sha }}
|
|
version: ${{ steps.resolve.outputs.version }}
|
|
build_number: ${{ steps.manifest.outputs.build_number }}
|
|
app_store_build_id: ${{ steps.manifest.outputs.app_store_build_id }}
|
|
steps:
|
|
- name: Checkout tagged source
|
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
ref: ${{ github.sha }}
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
|
with:
|
|
node-version: 22
|
|
|
|
- name: Validate protected tag and release version
|
|
id: resolve
|
|
shell: bash
|
|
env:
|
|
AUTOMATION_ENABLED: ${{ vars.APP_STORE_AUTOMATION_ENABLED || 'false' }}
|
|
run: |
|
|
set -euo pipefail
|
|
[[ "$GITHUB_REF_NAME" =~ ^ios-v([0-9]+\.[0-9]+\.[0-9]+)$ ]] || { echo "Tag must be ios-vX.Y.Z." >&2; exit 1; }
|
|
version="${BASH_REMATCH[1]}"
|
|
source_sha="$(git rev-parse HEAD)"
|
|
manifest_version="$(node -p "JSON.parse(require('fs').readFileSync('ios/release.json')).marketingVersion")"
|
|
[[ "$version" == "$manifest_version" ]] || { echo "Tag version $version does not match ios/release.json $manifest_version." >&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 merge-base --is-ancestor "$source_sha" origin/master || { echo "Tagged commit is not reachable from master." >&2; exit 1; }
|
|
enabled=false
|
|
[[ "$AUTOMATION_ENABLED" == true ]] && enabled=true
|
|
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
|
|
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
if [[ "$enabled" != true ]]; then
|
|
echo "### Candidate promotion safely disabled" >> "$GITHUB_STEP_SUMMARY"
|
|
echo 'No App Store environment or credentials were accessed. Enable only after the signed canary.' >> "$GITHUB_STEP_SUMMARY"
|
|
fi
|
|
|
|
- name: Download exact TestFlight release manifest
|
|
if: steps.resolve.outputs.enabled == 'true'
|
|
id: manifest
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
SOURCE_SHA: ${{ steps.resolve.outputs.source_sha }}
|
|
EXPECTED_VERSION: ${{ steps.resolve.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
artifact_name="ios-release-manifest-$SOURCE_SHA"
|
|
response="$RUNNER_TEMP/ios-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 sha "$SOURCE_SHA" '[.artifacts[] | select(.expired == false) | select(.workflow_run.head_sha == $sha)] | sort_by(.created_at) | last | .id // empty' "$response")"
|
|
[[ "$artifact_id" =~ ^[0-9]+$ ]] || { echo "No successful TestFlight release manifest exists for $SOURCE_SHA." >&2; exit 1; }
|
|
mkdir -p output/candidate
|
|
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/manifest.zip"
|
|
unzip -q "$RUNNER_TEMP/manifest.zip" -d output/candidate
|
|
MANIFEST=output/candidate/ios-release-manifest.json node <<'NODE'
|
|
const fs = require("node:fs");
|
|
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST, "utf8"));
|
|
const checks = {
|
|
schema: manifest.schemaVersion === 1,
|
|
repository: manifest.repository === process.env.GITHUB_REPOSITORY,
|
|
source: manifest.sourceSha === process.env.SOURCE_SHA,
|
|
version: manifest.marketingVersion === process.env.EXPECTED_VERSION,
|
|
bundle: manifest.bundleId === "io.truckwash.app",
|
|
build: /^[1-9][0-9]*$/.test(manifest.buildNumber),
|
|
appStoreBuild: typeof manifest.appStoreBuildId === "string" && manifest.appStoreBuildId.length > 0,
|
|
};
|
|
const failed = Object.entries(checks).filter(([, ok]) => !ok).map(([name]) => name);
|
|
if (failed.length) throw new Error(`Invalid iOS release manifest: ${failed.join(", ")}`);
|
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `build_number=${manifest.buildNumber}\napp_store_build_id=${manifest.appStoreBuildId}\n`);
|
|
NODE
|
|
|
|
promote:
|
|
name: Sync storefront and prepare manual review
|
|
needs: resolve
|
|
if: needs.resolve.outputs.enabled == 'true'
|
|
runs-on: macos-15
|
|
timeout-minutes: 60
|
|
environment: app-store-candidate
|
|
env:
|
|
IOS_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }}
|
|
IOS_MARKETING_VERSION: ${{ needs.resolve.outputs.version }}
|
|
IOS_BUILD_NUMBER: ${{ needs.resolve.outputs.build_number }}
|
|
EXPECTED_APP_STORE_BUILD_ID: ${{ needs.resolve.outputs.app_store_build_id }}
|
|
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 exact candidate source
|
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
with:
|
|
ref: ${{ env.IOS_SOURCE_SHA }}
|
|
persist-credentials: false
|
|
|
|
- name: Setup Ruby and pinned Fastlane
|
|
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1
|
|
with:
|
|
ruby-version: "3.3"
|
|
bundler-cache: true
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
|
with:
|
|
node-version: 22
|
|
|
|
- name: Validate complete candidate storefront
|
|
run: node scripts/mobile/validate-app-store.mjs --strict
|
|
|
|
- name: Verify public storefront URLs
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
for file in support_url privacy_url marketing_url; do
|
|
url="$(tr -d '\r\n' < "fastlane/metadata/da-DK/$file.txt")"
|
|
curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 --output /dev/null "$url"
|
|
done
|
|
|
|
- name: Verify exact processed TestFlight build
|
|
run: node scripts/mobile/app-store-connect.mjs verify-candidate
|
|
|
|
- name: Sync metadata and screenshots without App Review submission
|
|
run: bundle exec fastlane ios prepare_candidate
|
|
|
|
- name: Read back exact App Store candidate
|
|
id: readback
|
|
run: node scripts/mobile/app-store-connect.mjs verify-store-version
|
|
|
|
- name: Write candidate handoff
|
|
env:
|
|
APP_STORE_STATE: ${{ steps.readback.outputs.app_store_state }}
|
|
APP_STORE_VERSION_ID: ${{ steps.readback.outputs.app_store_version_id }}
|
|
run: |
|
|
echo "### iOS $IOS_MARKETING_VERSION candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- Source: \`$IOS_SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- Exact tested build: \`$IOS_BUILD_NUMBER\` (\`$EXPECTED_APP_STORE_BUILD_ID\`)" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- App Store state: \`$APP_STORE_STATE\`" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- App Store version ID: \`$APP_STORE_VERSION_ID\`" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- [Open the app in App Store Connect](https://appstoreconnect.apple.com/apps/$APP_STORE_CONNECT_APP_ID/appstore)" >> "$GITHUB_STEP_SUMMARY"
|
|
echo "- App Review submission and public release remain manual in App Store Connect." >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
disabled:
|
|
name: Promotion disabled
|
|
needs: resolve
|
|
if: needs.resolve.outputs.enabled != 'true'
|
|
runs-on: ubuntu-24.04
|
|
steps:
|
|
- run: echo "App Store candidate promotion is disabled; no environment or credentials were accessed."
|