diff --git a/.github/workflows/app-store-metadata.yml b/.github/workflows/app-store-metadata.yml new file mode 100644 index 00000000..74c44426 --- /dev/null +++ b/.github/workflows/app-store-metadata.yml @@ -0,0 +1,99 @@ +name: App Store Metadata +run-name: App Store screenshots — ${{ inputs.operation }} — ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + operation: + description: Inspect Apple inventory, or prepare draft screenshot placements (never submit) + type: choice + options: [inventory, upload] + default: inventory + required: true + source_sha: + description: Reviewed full master commit SHA with successful CI and committed screenshots + type: string + required: true + version: + description: next, or the next minor version of the live App Store version + type: string + default: next + +permissions: + contents: read + +concurrency: + group: growingup-app-store-metadata + cancel-in-progress: false + +jobs: + metadata: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: testflight + permissions: + contents: read + actions: read + env: + SOURCE_SHA: ${{ inputs.source_sha }} + OPERATION: ${{ inputs.operation }} + VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify reviewed master source and green CI + env: + GH_TOKEN: ${{ github.token }} + run: | + [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'source_sha must be a full commit SHA'; exit 1; } + git merge-base --is-ancestor "$SOURCE_SHA" origin/master + CI=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --branch master --event push --commit "$SOURCE_SHA" --limit 1 --json status,conclusion) + jq -e '.[0].status == "completed" and .[0].conclusion == "success"' <<< "$CI" >/dev/null + case "$OPERATION" in inventory|upload) ;; *) exit 1 ;; esac + METADATA_DIRECTORY=$(mktemp -d "$RUNNER_TEMP/app-store-metadata.XXXXXX") + chmod 700 "$METADATA_DIRECTORY" + echo "METADATA_TEMP=$METADATA_DIRECTORY" >> "$GITHUB_ENV" + git worktree add --detach "$METADATA_DIRECTORY/source" "$SOURCE_SHA" + test -f "$METADATA_DIRECTORY/source/scripts/app-store-metadata.py" + - name: Install isolated API signing dependency + run: | + python3 -m venv "$METADATA_TEMP/venv" + "$METADATA_TEMP/venv/bin/pip" install 'PyJWT==2.10.1' 'cryptography==46.0.3' + - name: Inspect or prepare App Store draft screenshots + env: + ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: | + python3 - <<'PY' + import os + from pathlib import Path + for name in ('ASC_PRIVATE_KEY', 'ASC_KEY_ID', 'ASC_ISSUER_ID'): + if not os.environ.get(name, '').strip(): + raise SystemExit('Missing environment secret: ' + name) + path = Path(os.environ['METADATA_TEMP']) / 'AuthKey.p8' + path.write_text(os.environ['ASC_PRIVATE_KEY'] + '\n') + path.chmod(0o600) + PY + export ASC_KEY_PATH="$METADATA_TEMP/AuthKey.p8" + unset ASC_PRIVATE_KEY + "$METADATA_TEMP/venv/bin/python" "$METADATA_TEMP/source/scripts/app-store-metadata.py" "$OPERATION" \ + --version "$VERSION" --source-sha "$SOURCE_SHA" \ + --screenshots "$METADATA_TEMP/source/marketing/app-store/screenshots" \ + --receipt "$METADATA_TEMP/receipt.json" + - name: Retain nonsecret Apple inventory or verified screenshot receipt + if: always() && env.METADATA_TEMP != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: app-store-metadata-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ env.METADATA_TEMP }}/receipt.json + if-no-files-found: warn + retention-days: 90 + - name: Remove credentials and temporary source + if: always() && env.METADATA_TEMP != '' + run: | + rm -rf "$METADATA_TEMP" + git worktree prune diff --git a/.github/workflows/app-store-screenshots.yml b/.github/workflows/app-store-screenshots.yml new file mode 100644 index 00000000..35dbe154 --- /dev/null +++ b/.github/workflows/app-store-screenshots.yml @@ -0,0 +1,215 @@ +name: App Store Screenshots + +permissions: + contents: read + +on: + pull_request: + paths: + - '.github/workflows/app-store-screenshots.yml' + - 'scripts/run-app-store-screenshots.sh' + - 'GrowingUp/**' + - 'GrowingUpUITests/**' + - 'GrowingUp.xcodeproj/**' + - 'Core/**' + - 'Widget/**' + - 'docs/app-store-demo-photos/**' + - 'scripts/render-app-store-screenshots.py' + - 'marketing/**' + workflow_dispatch: + +concurrency: + group: app-store-screenshots-${{ github.ref }} + cancel-in-progress: true + +jobs: + capture: + name: App Store Capture — iPhone 17 Pro Max / iOS 26.5 + runs-on: macos-26 + timeout-minutes: 35 + steps: + - uses: actions/checkout@v7 + - name: Prepare capture directory + id: evidence + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + ARTIFACT_DIR=$(mktemp -d "$RUNNER_TEMP/app-store-capture.XXXXXX") + echo "directory=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" + jq -n --arg head "$HEAD_SHA" --arg checkout "$(git rev-parse HEAD)" \ + --arg run "$GITHUB_RUN_ID" --arg attempt "$GITHUB_RUN_ATTEMPT" \ + '{workflow: "app-store-screenshots.yml", head_sha: $head, checkout_sha: $checkout, + run_id: $run, run_attempt: $attempt, + device: "iPhone-17-Pro-Max", runtime: "26-5", xcode: "26.6", + native_width: 1320, native_height: 2868, visual_checks: "0"}' \ + > "$ARTIFACT_DIR/metadata.json" + - name: Select pinned Xcode + run: | + test -d /Applications/Xcode_26.6.app \ + || { echo 'Xcode 26.6 is unavailable on this runner'; ls /Applications; exit 1; } + sudo xcode-select -switch /Applications/Xcode_26.6.app + xcodebuild -version + - name: Prepare existing iPhone 17 Pro Max simulator + id: simulator + run: | + RUNTIME=com.apple.CoreSimulator.SimRuntime.iOS-26-5 + DEVICE_TYPE=com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max + UDID=$(xcrun simctl list devices available --json \ + | jq -r --arg runtime "$RUNTIME" --arg deviceType "$DEVICE_TYPE" \ + '.devices[$runtime][]? | select(.isAvailable and .deviceTypeIdentifier == $deviceType) | .udid' \ + | head -n 1) + if [ -z "$UDID" ]; then + echo 'No existing iPhone 17 Pro Max simulator on iOS 26.5; no simulator will be created.' + xcrun simctl list devices available + xcrun simctl list runtimes + exit 1 + fi + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + xcrun simctl shutdown "$UDID" 2>/dev/null || true + xcrun simctl erase "$UDID" + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + xcrun simctl status_bar "$UDID" override --time '9:41' --dataNetwork wifi \ + --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100 + - name: Capture English and Russian app journeys + id: capture + env: + SIM_UDID: ${{ steps.simulator.outputs.udid }} + ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }} + GROWINGUP_APP_STORE_PHOTOS: ${{ github.workspace }}/docs/app-store-demo-photos + run: scripts/run-app-store-screenshots.sh + - name: Export screenshots and test results + id: export + if: always() && steps.evidence.outputs.directory != '' + env: + ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }} + run: | + for LOCALE in en ru; do + LOCALE_DIR="$ARTIFACT_DIR/$LOCALE" + if [ -d "$LOCALE_DIR/UI.xcresult" ]; then + xcrun xcresulttool export attachments --path "$LOCALE_DIR/UI.xcresult" \ + --output-path "$LOCALE_DIR/attachments" + xcrun xcresulttool get test-results summary --path "$LOCALE_DIR/UI.xcresult" \ + > "$LOCALE_DIR/test-summary.json" + xcrun xcresulttool get test-results tests --path "$LOCALE_DIR/UI.xcresult" \ + > "$LOCALE_DIR/tests.json" + fi + done + - name: Verify required capture evidence + id: verify + if: always() && steps.evidence.outputs.directory != '' + env: + ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }} + run: | + python3 - <<'PY' + import json + import os + import re + import struct + from pathlib import Path + + directory = Path(os.environ['ARTIFACT_DIR']) + for locale in ('en', 'ru'): + source = directory / locale + assert (source / 'journeys.mp4').stat().st_size > 0, f'{locale}: missing recording' + summary = json.loads((source / 'test-summary.json').read_text()) + assert summary.get('passedTests', 0) > 0, f'{locale}: no passed capture test' + assert summary.get('failedTests', 0) == 0, f'{locale}: failed capture test' + manifest = json.loads((source / 'attachments/manifest.json').read_text()) + attachments = [item for test in manifest for item in test['attachments']] + export_suffix = re.compile(r'_\d+_[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$') + for checkpoint in ('mia', 'leo-celebration', 'mango', 'teddy', + 'widget-small', 'widget-medium', 'widget-large'): + name = f'store-{locale}-{checkpoint}' + matches = [item for item in attachments + if export_suffix.sub('', item['suggestedHumanReadableName'].removesuffix('.png')) == name] + assert len(matches) == 1, f'{name}: expected one checkpoint, found {len(matches)}' + data = (source / 'attachments' / matches[0]['exportedFileName']).read_bytes() + assert data[:8] == b'\x89PNG\r\n\x1a\n', f'{name}: invalid PNG' + dimensions = struct.unpack('>II', data[16:24]) + if not checkpoint.startswith('widget-'): + assert dimensions == (1320, 2868), f'{name}: unexpected native size {dimensions}' + else: + expected = {'widget-small': (510, 510), 'widget-medium': (1092, 510), + 'widget-large': (1092, 1146)}[checkpoint] + assert dimensions == expected, f'{name}: unexpected widget crop {dimensions}' + PY + - name: Compose approved App Store frames from real captures + id: compose + env: + ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }} + run: | + python3 -m venv "$ARTIFACT_DIR/render-venv" + trap 'rm -rf "$ARTIFACT_DIR/render-venv"' EXIT + "$ARTIFACT_DIR/render-venv/bin/python" -m pip install 'Pillow==12.3.0' + "$ARTIFACT_DIR/render-venv/bin/python" scripts/render-app-store-screenshots.py \ + "$ARTIFACT_DIR" --output "$ARTIFACT_DIR/final" + test -s "$ARTIFACT_DIR/final/source.json" + test "$(find "$ARTIFACT_DIR/final/en-US" "$ARTIFACT_DIR/final/ru" -name '*.png' | wc -l | tr -d ' ')" = 8 + - name: Create portable capture reports + if: always() && steps.evidence.outputs.directory != '' + env: + ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }} + CAPTURE_OUTCOME: ${{ steps.capture.outcome }} + EXPORT_OUTCOME: ${{ steps.export.outcome }} + VERIFY_OUTCOME: ${{ steps.verify.outcome }} + COMPOSE_OUTCOME: ${{ steps.compose.outcome }} + run: | + jq --arg capture "$CAPTURE_OUTCOME" --arg export "$EXPORT_OUTCOME" \ + --arg verification "$VERIFY_OUTCOME" \ + --arg composition "$COMPOSE_OUTCOME" \ + '. + {capture_outcome: $capture, export_outcome: $export, verification_outcome: $verification, + composition_outcome: $composition}' \ + "$ARTIFACT_DIR/metadata.json" > "$ARTIFACT_DIR/metadata-next.json" + mv "$ARTIFACT_DIR/metadata-next.json" "$ARTIFACT_DIR/metadata.json" + for LOCALE in en ru; do + if [ -f "$ARTIFACT_DIR/$LOCALE/metadata.json" ]; then + jq --arg locale "$LOCALE" '. + {locale: $locale}' "$ARTIFACT_DIR/metadata.json" \ + > "$ARTIFACT_DIR/$LOCALE/metadata.json" + python3 scripts/ui-evidence-report.py "$ARTIFACT_DIR/$LOCALE" + fi + done + python3 scripts/ui-evidence-report.py "$ARTIFACT_DIR" + python3 - <<'PY' + import os + from pathlib import Path + report = Path(os.environ['ARTIFACT_DIR']) / 'index.html' + links = ('