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 = ('

App Store screenshots

') + report.write_text(report.read_text().replace('', links + '')) + PY + - name: Retain native screenshots and recordings + id: upload + if: always() && steps.evidence.outputs.directory != '' + uses: actions/upload-artifact@v7 + with: + name: app-store-capture-attempt-${{ github.run_attempt }} + path: ${{ steps.evidence.outputs.directory }} + if-no-files-found: error + retention-days: 14 + - name: Link capture evidence + if: always() && steps.upload.outputs.artifact-url != '' + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + { + echo '### App Store captures — iPhone 17 Pro Max / iOS 26.5' + echo + echo "PR head: \`$HEAD_SHA\`" + echo + echo "[Download RU/EN screenshots, journey recordings and test results]($ARTIFACT_URL)" + echo + echo 'Includes native simulator inputs and eight composed screenshots in final/. Inspect both locales before publication.' + } >> "$GITHUB_STEP_SUMMARY" + - name: Shut down capture simulator + if: always() && steps.simulator.outputs.udid != '' + run: xcrun simctl shutdown "${{ steps.simulator.outputs.udid }}" 2>/dev/null || true + - name: Remove temporary capture evidence + if: always() && steps.evidence.outputs.directory != '' + env: + ARTIFACT_DIR: ${{ steps.evidence.outputs.directory }} + run: rm -rf "$ARTIFACT_DIR" diff --git a/GrowingUp.xcodeproj/project.pbxproj b/GrowingUp.xcodeproj/project.pbxproj index ee3b3985..398c5250 100644 --- a/GrowingUp.xcodeproj/project.pbxproj +++ b/GrowingUp.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + C51000000000000000000001 /* AppStoreScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51000000000000000000002 /* AppStoreScreenshotTests.swift */; }; CA9319E726711F9C5D5FB001 /* ImagesCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9319E726711F9C5D5FB002 /* ImagesCacheTests.swift */; }; A5000000000000000000000F /* AgeWidgetContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5000000000000000000000E /* AgeWidgetContentView.swift */; }; A50000000000000000000001 /* AgeWidgetContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5000000000000000000000E /* AgeWidgetContentView.swift */; }; @@ -225,6 +226,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + C51000000000000000000002 /* AppStoreScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreScreenshotTests.swift; sourceTree = ""; }; CA9319E726711F9C5D5FB002 /* ImagesCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesCacheTests.swift; sourceTree = ""; }; A5000000000000000000000E /* AgeWidgetContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgeWidgetContentView.swift; sourceTree = ""; }; A50000000000000000000008 /* UITestWidgetPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestWidgetPreview.swift; sourceTree = ""; }; @@ -907,6 +909,7 @@ A4461E02291EB42F4B6BD048 /* GrowingUpUITests */ = { isa = PBXGroup; children = ( + C51000000000000000000002 /* AppStoreScreenshotTests.swift */, 97ABEB737086B8C5A490A11D /* JourneyTests.swift */, 3564E9887F474D5D96B7CDEA /* BirthdayAndEditingTests.swift */, A40000000000000000000002 /* RemovalConfirmationTests.swift */, @@ -1337,6 +1340,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C51000000000000000000001 /* AppStoreScreenshotTests.swift in Sources */, DA5ADAD9BAAD778F3FC5C30B /* JourneyTests.swift in Sources */, 9979F5C4DEEB47C684C61F00 /* BirthdayAndEditingTests.swift in Sources */, A40000000000000000000001 /* RemovalConfirmationTests.swift in Sources */, diff --git a/GrowingUp.xcodeproj/xcshareddata/xcschemes/GrowingUpUI.xcscheme b/GrowingUp.xcodeproj/xcshareddata/xcschemes/GrowingUpUI.xcscheme index 1a71ef9e..fb96f138 100644 --- a/GrowingUp.xcodeproj/xcshareddata/xcschemes/GrowingUpUI.xcscheme +++ b/GrowingUp.xcodeproj/xcshareddata/xcschemes/GrowingUpUI.xcscheme @@ -59,6 +59,21 @@ value = "$(GROWINGUP_RECORD_SNAPSHOTS)" isEnabled = "YES"> + + + + + + [UIImage] { + guard environment["GROWINGUP_APP_STORE_CAPTURE"] == "1" else { return (0..<18).map(fixture) } + guard let directory = environment["GROWINGUP_APP_STORE_PHOTOS"] else { throw CocoaError(.fileNoSuchFile) } + return try ["baby-girl", "young-boy", "cat", "dog"].map { name in + let url = URL(fileURLWithPath: directory).appendingPathComponent("\(name).png") + let data = try Data(contentsOf: url) + guard let image = UIImage(data: data) else { throw CocoaError(.fileReadCorruptFile) } + return image + } } private static func fixture(_ index: Int) -> UIImage { diff --git a/GrowingUp/Testing/UITestWidgetPreview.swift b/GrowingUp/Testing/UITestWidgetPreview.swift index 16c9cec3..662b0d9d 100644 --- a/GrowingUp/Testing/UITestWidgetPreview.swift +++ b/GrowingUp/Testing/UITestWidgetPreview.swift @@ -1,6 +1,7 @@ #if DEBUG import Core import SwiftUI + import UIKit import WidgetKit /// Exercises the extension's actual rendering against the isolated UI-test store. @@ -27,6 +28,7 @@ .padding(16) .frame(width: family == .systemSmall ? 170 : 364, height: family == .systemLarge ? 382 : 170) .background(.background, in: RoundedRectangle(cornerRadius: 24)) + .accessibilityElement(children: .contain) .accessibilityIdentifier("widget.preview") } else if let error { Text(error).accessibilityIdentifier("widget.preview.error") @@ -37,8 +39,15 @@ .task { do { let people = try await configurator.fetchUseCase.fetchWidgetPersons() - let persons = people.prefix(Constants.widgetPeopleLimit).enumerated().map { index, person in - WidgetPerson(id: person.id, index: index, name: person.name, birthday: person.birthday, image: nil) + var persons: [WidgetPerson] = [] + for (index, person) in people.prefix(Constants.widgetPeopleLimit).enumerated() { + let image: UIImage? + if let reference = PersonImage(id: person.widgetPicId) { + image = try await configurator.loadImage(reference) + } else { + image = nil + } + persons.append(WidgetPerson(id: person.id, index: index, name: person.name, birthday: person.birthday, image: image)) } entry = AgeEntry(date: configurator.now(), persons: persons) } catch { diff --git a/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift b/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift index cef80675..554d63bc 100644 --- a/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift +++ b/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift @@ -93,9 +93,10 @@ final class CachePersonsGatewayTests: XCTestCase { func testAsyncAddHonorsCancellationBeforeSideEffects() async { let operation = Task { - try await sut.add(parameters: parameters(appImage: PersonImage(uiImage: UIImage()), widgetImage: nil)) + // Cancel before invoking the gateway, regardless of when the child task is scheduled. + withUnsafeCurrentTask { $0?.cancel() } + return try await sut.add(parameters: parameters(appImage: PersonImage(uiImage: UIImage()), widgetImage: nil)) } - operation.cancel() do { _ = try await operation.value diff --git a/GrowingUpUITests/AppStoreScreenshotTests.swift b/GrowingUpUITests/AppStoreScreenshotTests.swift new file mode 100644 index 00000000..252ff5b6 --- /dev/null +++ b/GrowingUpUITests/AppStoreScreenshotTests.swift @@ -0,0 +1,174 @@ +import XCTest + +/// Opt-in capture of the app's real editor, crops, saved people and WidgetKit content. +@MainActor +final class AppStoreScreenshotTests: XCTestCase { + private var app = XCUIApplication() + private var configuration: UITestConfiguration! + private var locale = "en" + private var names: [String] { locale == "ru" ? ["Мия", "Лео", "Манго", "Тедди"] : ["Mia", "Leo", "Mango", "Teddy"] } + private var overview: XCUIElement { app.descendants(matching: .any).matching(identifier: "person.edit").firstMatch } + private var confetti: XCUIElement { app.descendants(matching: .any).matching(identifier: "person.confetti").firstMatch } + + func testCaptureAppStoreScreenshots() throws { + let environment = ProcessInfo.processInfo.environment + try XCTSkipUnless(environment["GROWINGUP_APP_STORE_CAPTURE"] == "1", "Run the dedicated App Store capture workflow.") + continueAfterFailure = false + locale = try XCTUnwrap(environment["GROWINGUP_APP_STORE_LOCALE"]) + XCTAssertTrue(["en", "ru"].contains(locale)) + let photos = try XCTUnwrap(environment["GROWINGUP_APP_STORE_PHOTOS"]) + launchCapture(photos: photos) + // Dates are entered using the native wheel editor, never injected into persistence. + addPerson(index: 0, year: 2026, month: 7, day: 16) + addPerson(index: 1, year: 2021, month: 1, day: 16) + addPerson(index: 2, year: 2024, month: 10, day: 16) + addPerson(index: 3, year: 2026, month: 1, day: 15) + app.launchEnvironment["GROWINGUP_BIRTHDAY_NOW"] = "2027-01-16T08:24:12Z" + verifyPersistence() + capturePeople() + captureWidgets() + app.terminate() + } + + private func launchCapture(photos: String) { + configuration = UITestConfiguration(identifier: UUID(), resetStore: true, seed: .empty, failNextSave: false, appearance: .dark) + app.launchEnvironment = [ + UITestConfiguration.environmentKey: configuration.encoded, + "GROWINGUP_APP_STORE_CAPTURE": "1", "GROWINGUP_APP_STORE_PHOTOS": photos, + "GROWINGUP_BIRTHDAY_NOW": "2027-01-16T00:00:00Z", "TZ": "UTC" + ] + app.launchArguments = [ + "-AppleLanguages", "(\(locale))", "-AppleLocale", locale == "ru" ? "ru_RU" : "en_US", + "-AppleInterfaceStyle", "Dark", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL" + ] + app.launch() + XCTAssertTrue(app.buttons["person.add"].waitForExistence(timeout: 10)) + } + + private func verifyPersistence() { + relaunch() + for index in names.indices { + selectPerson(index) + overview.tap() + XCTAssertTrue(app.images["editor.appPhoto.loaded"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.images["editor.widgetPhoto.loaded"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.switches["editor.pin"].value as? String, "1") + checkpoint("store-\(locale)-persisted-\(index)") + dismissEditor() + } + } + + private func capturePeople() { + selectPerson(0) + checkpoint("store-\(locale)-mia") + selectPerson(2) + checkpoint("store-\(locale)-mango") + selectPerson(3) + checkpoint("store-\(locale)-teddy") + // Navigate and open the popover before midnight so XCTest synchronization cannot consume the burst. + app.launchEnvironment["GROWINGUP_BIRTHDAY_NOW"] = "2027-01-15T23:59:20Z" + app.launchEnvironment["GROWINGUP_BIRTHDAY_CLOCK_RUNNING"] = "1" + relaunch() + selectPerson(1) + app.buttons["person.birthday"].tap() + XCTAssertTrue(confetti.waitForExistence(timeout: 60), "The production midnight transition must start the birthday animation.") + // The production birthday popover demonstrates native Liquid Glass while the burst is running. + checkpoint("store-\(locale)-leo-celebration") + checkpoint("store-\(locale)-leo-celebration-frame-2") + XCTAssertTrue(confetti.waitForNonExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts[locale == "ru" ? "С днём рождения!" : "Happy birthday!"].exists) + XCTAssertFalse(app.staticTexts["birthday.countdown"].exists) + checkpoint("store-\(locale)-birthday-confirmed") + app.launchEnvironment["GROWINGUP_BIRTHDAY_NOW"] = "2027-01-16T08:24:12Z" + app.launchEnvironment["GROWINGUP_BIRTHDAY_CLOCK_RUNNING"] = "0" + } + + private func captureWidgets() { + for (family, count) in [("small", 1), ("medium", 3), ("large", 4)] { + relaunch(widgetFamily: family) + let widget = app.otherElements.matching(identifier: "widget.preview").firstMatch + XCTAssertTrue(widget.waitForExistence(timeout: 10)) + XCTAssertEqual(widget.frame.width, family == "small" ? 170 : 364, accuracy: 0.5) + XCTAssertEqual(widget.frame.height, family == "large" ? 382 : 170, accuracy: 0.5) + for (index, name) in names.enumerated() { + let person = widget.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", name)).firstMatch + if index < count { + XCTAssertTrue(person.waitForExistence(timeout: 5)) + } else { + XCTAssertFalse(person.exists) + } + } + checkpoint("store-\(locale)-widget-\(family)", element: widget) + } + } + + private func addPerson(index: Int, year: Int, month: Int, day: Int) { + if index > 0 { app.swipeLeft() } + XCTAssertTrue(app.buttons["person.add"].waitForExistence(timeout: 5)) + app.buttons["person.add"].tap() + let name = app.textFields["editor.name"] + XCTAssertTrue(name.waitForExistence(timeout: 5)) + name.tap() + name.typeText(names[index]) + app.buttons["editor.birthDate"].tap() + let picker = app.datePickers["editor.birthDate.picker"] + XCTAssertTrue(picker.waitForExistence(timeout: 5)) + let wheels = picker.pickerWheels + XCTAssertEqual(wheels.count, 3) + wheels.element(boundBy: 2).adjust(toPickerWheelValue: String(year)) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: locale == "ru" ? "ru_RU" : "en_US") + let monthName = formatter.monthSymbols[month - 1].capitalized(with: formatter.locale) + wheels.element(boundBy: locale == "ru" ? 1 : 0).adjust(toPickerWheelValue: monthName) + wheels.element(boundBy: locale == "ru" ? 0 : 1).adjust(toPickerWheelValue: String(day)) + app.buttons["editor.birthDate"].tap() + XCTAssertEqual(app.switches["editor.pin"].value as? String, "1") + for slot in ["appPhoto", "widgetPhoto"] { + app.buttons["editor.\(slot)"].tap() + app.buttons["photo.source.photos"].tap() + let thumbnails = app.buttons.matching(identifier: "photo.thumbnail") + XCTAssertTrue(thumbnails.firstMatch.waitForExistence(timeout: 5)) + XCTAssertEqual(thumbnails.count, 4) + thumbnails.element(boundBy: index).tap() + XCTAssertTrue(app.buttons["crop.use"].waitForExistence(timeout: 5)) + checkpoint("store-\(locale)-crop-\(index)-\(slot)") + app.buttons["crop.use"].tap() + XCTAssertTrue(thumbnails.firstMatch.waitForNonExistence(timeout: 5)) + XCTAssertTrue(app.images["editor.\(slot).loaded"].waitForExistence(timeout: 10)) + } + app.buttons["editor.save"].tap() + XCTAssertTrue(app.buttons["editor.save"].waitForNonExistence(timeout: 10)) + XCTAssertTrue(overview.waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["person.name"].label, names[index]) + } + + private func relaunch(widgetFamily: String? = nil) { + app.terminate() + configuration = configuration.forRelaunch() + app.launchEnvironment[UITestConfiguration.environmentKey] = configuration.encoded + app.launchEnvironment["GROWINGUP_WIDGET_PREVIEW"] = widgetFamily + app.launch() + if widgetFamily == nil { XCTAssertTrue(overview.waitForExistence(timeout: 10)) } + } + + private func selectPerson(_ index: Int) { + app.open(URL(string: "growingup-app://?personIndex=\(index)")!) + let name = app.staticTexts["person.name"] + let expected = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label == %@", names[index]), object: name) + XCTAssertEqual(XCTWaiter.wait(for: [expected], timeout: 5), .completed) + } + + private func dismissEditor() { + let navigation = app.navigationBars.firstMatch + let start = navigation.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))) + XCTAssertTrue(app.buttons["editor.save"].waitForNonExistence(timeout: 5)) + } + + private func checkpoint(_ name: String, element: XCUIElement? = nil) { + let attachment = XCTAttachment(screenshot: element?.screenshot() ?? app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/GrowingUpUITests/BirthdayAndEditingTests.swift b/GrowingUpUITests/BirthdayAndEditingTests.swift index ad64b780..55dbe6ba 100644 --- a/GrowingUpUITests/BirthdayAndEditingTests.swift +++ b/GrowingUpUITests/BirthdayAndEditingTests.swift @@ -57,16 +57,15 @@ final class BirthdayAndEditingTests: XCTestCase { XCTAssertTrue(app.staticTexts["Happy birthday!"].waitForNonExistence(timeout: 5)) checkpoint("birthday-midnight-confetti") XCTAssertTrue(confetti.waitForNonExistence(timeout: 5)) - let leading = overview.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.7)) - let trailing = overview.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.7)) - trailing.press(forDuration: 0.05, thenDragTo: leading) + app.swipeLeft() XCTAssertTrue(waitFor(app.staticTexts["person.name"], predicate: NSPredicate(format: "label == %@", "Boris"), timeout: 5)) XCTAssertFalse(confetti.exists) - leading.press(forDuration: 0.05, thenDragTo: trailing) - XCTAssertTrue(waitFor(app.staticTexts["person.name"], predicate: NSPredicate(format: "label == %@", "Alice"), timeout: 5)) + app.swipeRight() + // Capture the short-lived burst before name queries spend its animation window. XCTAssertTrue(confetti.waitForExistence(timeout: 5)) XCTAssertEqual(confetti.value as? String, "2") checkpoint("birthday-return-confetti") + XCTAssertTrue(waitFor(app.staticTexts["person.name"], predicate: NSPredicate(format: "label == %@", "Alice"), timeout: 5)) XCTAssertFalse(app.buttons["editor.save"].exists) } diff --git a/Widget/AgeWidgetContentView.swift b/Widget/AgeWidgetContentView.swift index 9f2fda70..c1d211bc 100644 --- a/Widget/AgeWidgetContentView.swift +++ b/Widget/AgeWidgetContentView.swift @@ -36,6 +36,7 @@ struct AgeWidgetContentView: View { PersonCell(person: person, date: entry.date, compact: false) .frame(maxWidth: .infinity) } + .buttonStyle(.plain) } } } @@ -50,6 +51,7 @@ struct AgeWidgetContentView: View { .frame(maxWidth: .infinity) .frame(height: max(0, (geometry.size.height - 16) / 2)) } + .buttonStyle(.plain) } } } diff --git a/docs/app-store-demo-photos/baby-girl.png b/docs/app-store-demo-photos/baby-girl.png new file mode 100644 index 00000000..be8b9507 Binary files /dev/null and b/docs/app-store-demo-photos/baby-girl.png differ diff --git a/docs/app-store-demo-photos/cat.png b/docs/app-store-demo-photos/cat.png new file mode 100644 index 00000000..0fce44ba Binary files /dev/null and b/docs/app-store-demo-photos/cat.png differ diff --git a/docs/app-store-demo-photos/dog.png b/docs/app-store-demo-photos/dog.png new file mode 100644 index 00000000..524f90a4 Binary files /dev/null and b/docs/app-store-demo-photos/dog.png differ diff --git a/docs/app-store-demo-photos/prompts.json b/docs/app-store-demo-photos/prompts.json new file mode 100644 index 00000000..b54c4e7b --- /dev/null +++ b/docs/app-store-demo-photos/prompts.json @@ -0,0 +1,23 @@ +{ + "generator": "Built-in image_gen", + "generated_on": "2026-10-09", + "purpose": "Fictional demo portraits for GrowingUp App Store screenshot concepts and later simulator fixtures", + "assets": [ + { + "file": "baby-girl.png", + "prompt": "Use case: photorealistic-natural. Asset type: original demo portrait photo for GrowingUp iPhone App Store screenshots, not a mockup. Create one photorealistic natural editorial photograph, portrait orientation 2:3, high resolution. Soft natural window/daylight, candid believable texture, clean muted background. Frame subject's head and upper torso in the central half of the image, ample uncluttered darkened negative space in the top quarter for the app's white name and age overlay. Composition must also work cropped into a small circular widget avatar. Entirely fictional subject, no resemblance to a named real person. No text, logos, watermarks, app UI, collage, borders, or illustration. Subject: happy 10-month-old baby girl with light brown wispy hair, warm natural smile, wearing a simple cream knitted sweater, seated safely on a cozy sage-green sofa at home. Gentle authentic family photography, warm daylight, understated cream and sage palette." + }, + { + "file": "young-boy.png", + "prompt": "Use case: photorealistic-natural. Asset type: original demo portrait photo for GrowingUp iPhone App Store screenshots, not a mockup. Create one photorealistic natural editorial photograph, portrait orientation 2:3, high resolution. Soft natural window/daylight, candid believable texture, clean muted background. Frame subject's head and upper torso in the central half of the image, ample uncluttered darkened negative space in the top quarter for the app's white name and age overlay. Composition must also work cropped into a small circular widget avatar. Entirely fictional subject, no resemblance to a named real person. No text, logos, watermarks, app UI, collage, borders, or illustration. Subject: cheerful 5-year-old boy with curly dark hair and medium brown skin, wearing a muted blue cotton sweater, outdoors in a garden. Natural playful expression looking slightly toward camera, soft green leaves blurred behind, realistic family photography." + }, + { + "file": "cat.png", + "prompt": "Use case: photorealistic-natural. Asset type: original demo portrait photo for GrowingUp iPhone App Store screenshots, not a mockup. Create one photorealistic natural editorial photograph, portrait orientation 2:3, high resolution. Soft natural window/daylight, candid believable texture, clean muted background. Frame subject's head and upper torso in the central half of the image, ample uncluttered darkened negative space in the top quarter for the app's white name and age overlay. Composition must also work cropped into a small circular widget avatar. Entirely fictional subject, no resemblance to a named real person. No text, logos, watermarks, app UI, collage, borders, or illustration. Subject: affectionate ginger domestic shorthair cat with green eyes sitting on a soft cream blanket near a window. Portrait of head and upper body, ears fully in frame, calm curious look at camera, realistic fur detail and warm natural light, muted teal background." + }, + { + "file": "dog.png", + "prompt": "Use case: photorealistic-natural. Asset type: original demo portrait photo for GrowingUp iPhone App Store screenshots, not a mockup. Create one photorealistic natural editorial photograph, portrait orientation 2:3, high resolution. Soft natural window/daylight, candid believable texture, clean muted background. Frame subject's head and upper torso in the central half of the image, ample uncluttered darkened negative space in the top quarter for the app's white name and age overlay. Composition must also work cropped into a small circular widget avatar. Entirely fictional subject, no resemblance to a named real person. No text, logos, watermarks, app UI, collage, borders, or illustration. Subject: friendly golden retriever puppy, soft golden fur, wearing no accessories, sitting in a garden looking at camera. Head and upper body centered, mouth slightly open happy expression, ears fully in frame, soft sage-green foliage background, authentic natural pet photography." + } + ] +} diff --git a/docs/app-store-demo-photos/young-boy.png b/docs/app-store-demo-photos/young-boy.png new file mode 100644 index 00000000..5955e1a9 Binary files /dev/null and b/docs/app-store-demo-photos/young-boy.png differ diff --git a/docs/app-store-screenshots-mockup.html b/docs/app-store-screenshots-mockup.html new file mode 100644 index 00000000..1dbc7adc --- /dev/null +++ b/docs/app-store-screenshots-mockup.html @@ -0,0 +1,27 @@ + +GrowingUp — финальный вариант скриншотов +
↑GrowingUpApp Store · финальный макет

Четыре кадра для App Store

Выбранное тёмное оформление. Мия → Лео → Манго с Тедди на заднем плане → три размера виджета. Подписи переписаны Astra; редактор параметров исключён из набора.

HTML-макет перед съёмкой приложения. Фотографии сгенерированы, данные демонстрационные. Финальные экраны и системные материалы iOS будут сняты в симуляторе после одобрения этого макета.
+
Тёмный фон · четыре скриншота

01 · МияФотография девочки и её точный возраст.

02 · ЛеоВозраст мальчика и 14 дней до дня рождения.

03 · Манго и ТеддиКот на первом плане; телефон с собакой выглядывает сзади.

04 · ВиджетыМаленький — Мия; средний — трое; большой — все четверо.

Нажмите на кадр, чтобы увеличить его. Три размера виджета вмещают 1, до 3 и до 6 персонажей; в примере их четверо. Фон домашнего экрана условный — в CI будет снят настоящий контент WidgetKit. Заголовки над телефонами относятся к оформлению App Store.

+ + + \ No newline at end of file diff --git a/docs/app-store-screenshots-preview.html b/docs/app-store-screenshots-preview.html new file mode 100644 index 00000000..b8ad007d --- /dev/null +++ b/docs/app-store-screenshots-preview.html @@ -0,0 +1,45 @@ + + + + +GrowingUp — скриншоты App Store + +

GrowingUp · App Store

+

Реальные кадры приложения и виджетов · iOS 26.5 · 1320 × 2868

+ +
+
Мия — возраст ребёнка
01 · Мия
+
Лео — поздравление с конфетти
02 · Лео
+
Манго и Тедди — возраст питомцев
03 · Манго и Тедди
+
Три размера виджетов
04 · Виджеты
+
+ + diff --git a/docs/app-store-screenshots.md b/docs/app-store-screenshots.md new file mode 100644 index 00000000..a0d30d55 --- /dev/null +++ b/docs/app-store-screenshots.md @@ -0,0 +1,74 @@ +# App Store screenshots + +The approved set has four dark frames: Mia, Leo's birthday celebration, Mango +with Teddy's phone behind, and three WidgetKit sizes. It uses original generated +photos from `docs/app-store-demo-photos` and Russian/English copy. The celebration +frame says “Happy birthday!” / “С днём рождения!” because the actual app only +plays its birthday confetti on the birthday itself. + +The **App Store Screenshots** PR workflow uses an existing iPhone 17 Pro Max +simulator on iOS 26.5 with Xcode 26.6. Its opt-in XCUI journey enters names and +birthdays, selects and crops both photos, saves four pinned people, verifies +persistence, and records the native birthday popover and confetti. Widget crops +come from the extension's actual `AgeWidgetContentView` through the existing +DEBUG harness; the composed Home Screen background is marketing artwork, not a +claim of SpringBoard hosting. Normal UI journeys retain their existing fixtures. + +Download the current-head `app-store-capture-attempt-N` Actions artifact outside +the repository. Inspect both languages, the actual animation recording, and all +eight images in `final/`. Copy only the reviewed final deliverables into +`marketing/app-store/screenshots`, including `source.json`. Final marketing PNGs +are intentionally versioned at Danis's request; raw captures, recordings, +xcresult and DerivedData stay outside the repository. The final images are opaque +1320 × 2868 PNGs. Smaller iPhone categories can use App Store Connect scaling; +the iPhone-only app does not need an iPad set. +Browse the committed final images in `docs/app-store-screenshots-preview.html`. + +CI composes the images with `scripts/render-app-store-screenshots.py`, Pillow +12.3.0 and the licensed fonts in `marketing/fonts`. For a local recomposition: + +```bash +python3 scripts/render-app-store-screenshots.py /tmp/CAPTURE --output /tmp/FINAL +``` + +The output directory must be empty. `source.json` records native input hashes, +final image hashes and the source commit/run/attempt. A later change to capture +inputs requires new simulator evidence and recomposition. + +## Prepare the App Store draft + +The **App Store Metadata** workflow runs manually from reviewed, green `master` +using the existing `testflight` environment's Apple API key. Its `inventory` +operation is read-only. Its `upload` operation creates or reuses the next minor +draft after the live App Store version, prepares EN/RU screenshot placements, +and verifies processing and ordering. Both new sets must pass verification before +old inherited or managed screenshots in other iPhone groups are removed, so +smaller iPhones use the new scaled set. Other draft artwork blocks the operation; +iPad placements, previews and library image assets are retained. It does not upload an app binary or submit +a release for App Review. These operations need Danis's instruction; the current +screenshot task explicitly authorizes preparing the draft and uploading images. + +```bash +gh workflow run app-store-metadata.yml --ref master \ + -f operation=inventory -f source_sha=FULL_REVIEWED_MASTER_SHA -f version=next +gh workflow run app-store-metadata.yml --ref master \ + -f operation=upload -f source_sha=FULL_REVIEWED_MASTER_SHA -f version=2.1.0 +``` + +`2.1.0` is valid when the live store version is `2.0.0`; existing TestFlight +versions do not set this draft's marketing version. An existing different next +App Store draft is reported rather than silently changed. Existing metadata in +EN/RU is retained. The uploader refuses to replace unrelated draft screenshots. +Use a merge commit for this PR so the recorded capture commit remains an +ancestor of the reviewed source. The workflow checks that no app, capture, +photo, compositor or font input changed after that capture. + +Download and inspect the `app-store-metadata-RUN-ATTEMPT` receipt. Completion +means the draft is `PREPARE_FOR_SUBMISSION` and all eight screenshot placements +have the verified order and processed assets. Credentials stay in GitHub's +master-only environment; receipts contain no private keys or signed upload URLs. + +API references: [App Asset Library](https://developer.apple.com/documentation/appstoreconnectapi/app-asset-library), +[image uploads](https://developer.apple.com/documentation/appstoreconnectapi/uploading-and-managing-image-assets), +[placements](https://developer.apple.com/documentation/appstoreconnectapi/placing-assets-on-your-app-store-surfaces), +[screenshot dimensions](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/). diff --git a/marketing/app-store/screenshots/en-US/01-mia.png b/marketing/app-store/screenshots/en-US/01-mia.png new file mode 100644 index 00000000..94872574 Binary files /dev/null and b/marketing/app-store/screenshots/en-US/01-mia.png differ diff --git a/marketing/app-store/screenshots/en-US/02-leo-celebration.png b/marketing/app-store/screenshots/en-US/02-leo-celebration.png new file mode 100644 index 00000000..1fd69d69 Binary files /dev/null and b/marketing/app-store/screenshots/en-US/02-leo-celebration.png differ diff --git a/marketing/app-store/screenshots/en-US/03-mango-teddy.png b/marketing/app-store/screenshots/en-US/03-mango-teddy.png new file mode 100644 index 00000000..79b1ea67 Binary files /dev/null and b/marketing/app-store/screenshots/en-US/03-mango-teddy.png differ diff --git a/marketing/app-store/screenshots/en-US/04-widgets.png b/marketing/app-store/screenshots/en-US/04-widgets.png new file mode 100644 index 00000000..03f464ec Binary files /dev/null and b/marketing/app-store/screenshots/en-US/04-widgets.png differ diff --git a/marketing/app-store/screenshots/ru/01-mia.png b/marketing/app-store/screenshots/ru/01-mia.png new file mode 100644 index 00000000..ead43cb9 Binary files /dev/null and b/marketing/app-store/screenshots/ru/01-mia.png differ diff --git a/marketing/app-store/screenshots/ru/02-leo-celebration.png b/marketing/app-store/screenshots/ru/02-leo-celebration.png new file mode 100644 index 00000000..5ffff724 Binary files /dev/null and b/marketing/app-store/screenshots/ru/02-leo-celebration.png differ diff --git a/marketing/app-store/screenshots/ru/03-mango-teddy.png b/marketing/app-store/screenshots/ru/03-mango-teddy.png new file mode 100644 index 00000000..76c2efdd Binary files /dev/null and b/marketing/app-store/screenshots/ru/03-mango-teddy.png differ diff --git a/marketing/app-store/screenshots/ru/04-widgets.png b/marketing/app-store/screenshots/ru/04-widgets.png new file mode 100644 index 00000000..30898a64 Binary files /dev/null and b/marketing/app-store/screenshots/ru/04-widgets.png differ diff --git a/marketing/app-store/screenshots/source.json b/marketing/app-store/screenshots/source.json new file mode 100644 index 00000000..043bc298 --- /dev/null +++ b/marketing/app-store/screenshots/source.json @@ -0,0 +1,49 @@ +{ + "capture": { + "workflow": "app-store-screenshots.yml", + "head_sha": "f9fef06a16735a2a03c49159429b643228033357", + "checkout_sha": "63053a75d1c0c119eec80c3ffe49944975bea059", + "run_id": "37938358880", + "run_attempt": "1", + "device": "iPhone-17-Pro-Max", + "runtime": "26-5", + "xcode": "26.6", + "native_width": 1320, + "native_height": 2868, + "visual_checks": "0" + }, + "size": [ + 1320, + 2868 + ], + "inputs": { + "en-US": { + "mia": "147b47e5f343b464cede5502336bbc1154ed2a100059d248e0ee74a9a8c9fdb4", + "leo-celebration": "b358448e2e43ca3713b29b425e41a2658a4aa9563531ee7b331e339fed21b450", + "mango": "7b5ff6538b5625730a03a45183d8ee35d2fe5124044cb1f6f8825e924dbc002d", + "teddy": "42bb682f755975371afda341a840a1d76eeb5546a217f41a1e0931876bba94bb", + "widget-small": "7c55ceea784e737b022c649bc6fd92d3b5f27c7293b8f550111ff69e0ad6ab6b", + "widget-medium": "f27f9258719d511963e31d902b83d327002a243864614f8288d05d3443fae325", + "widget-large": "d54d2b15830197cf217061b6fa3e1df039f90ac0939944aeaf3b6c4767832b14" + }, + "ru": { + "mia": "9f2a54a4da62f270fbff9fe3db5a353155671f7c319720851be62f3a8be02f2c", + "leo-celebration": "b36b9ea0c3305a21bdbc6df456a237cfda5c5940b5faaaa2bed7ff952955cf24", + "mango": "2e253c140c3d657d5d336ccb553178ca573c408ea680c613fbb6b24fb56c8d16", + "teddy": "ff3476bf8eef8011a7e76500b41cd9d5b925e441f96bfc3bf4eea987164ae42a", + "widget-small": "5e52a495bf613023aa535b1b8195cf445a2a7506751eb742cafa189db6a06d05", + "widget-medium": "58b32ee6ed886a2c517be2fb67233c51f8b461bf97899a5d8b3aa57fa7471ad0", + "widget-large": "3cbaea59ffb6f9910bb5ef4991fc9494b3090997eebc7dabbbf301909c33285e" + } + }, + "outputs": { + "en-US/01-mia.png": "d1526f548e4f625bd08f403dbf0c6a1f798c62e5b803ecd574d36daaaa8c1ab4", + "en-US/02-leo-celebration.png": "4def72029247ba026f1e995b54291e897c61b6f73d2730ab706d0793df49e684", + "en-US/03-mango-teddy.png": "43016ff541375eb2389df18da768862ee67939bd98eb7b1199179f035dd4ad1f", + "en-US/04-widgets.png": "3d67c3f4762dad8faa2ae5028794a9e96eed201700a3474f7f683936e6251c2e", + "ru/01-mia.png": "3a9da7870a13dac13a88a444b8124b3ed356aa4e69a30aa8fdc25413cc499c94", + "ru/02-leo-celebration.png": "d2a11e0548776a6a5fe9410996848f45f6ebfdd9e491fff2bd88b6379c528351", + "ru/03-mango-teddy.png": "52cfae8b5dda9e9f636c9a7bb094e0d326acc4af88b795fdcc283300ea5e65f4", + "ru/04-widgets.png": "0fbcf6fc89b8213327bc59b87bb3cc6171777f93e2d1607f1e77665b5caf2ce1" + } +} diff --git a/marketing/fonts/DejaVuSans-Bold.ttf b/marketing/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 00000000..2cef1aaf Binary files /dev/null and b/marketing/fonts/DejaVuSans-Bold.ttf differ diff --git a/marketing/fonts/DejaVuSans.ttf b/marketing/fonts/DejaVuSans.ttf new file mode 100644 index 00000000..5789a29d Binary files /dev/null and b/marketing/fonts/DejaVuSans.ttf differ diff --git a/marketing/fonts/LICENSE.txt b/marketing/fonts/LICENSE.txt new file mode 100644 index 00000000..d1fcfc8e --- /dev/null +++ b/marketing/fonts/LICENSE.txt @@ -0,0 +1,78 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list +Source: https://dejavu-fonts.github.io/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/scripts/app-store-metadata.py b/scripts/app-store-metadata.py new file mode 100644 index 00000000..950954a9 --- /dev/null +++ b/scripts/app-store-metadata.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Prepare App Store screenshot placements, without uploading or submitting a build. + +Uses Apple's App Asset Library reserve/upload/commit/placement API (4.5+). +https://developer.apple.com/documentation/appstoreconnectapi/app-asset-library +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import struct +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + + +API_URL = "https://api.appstoreconnect.apple.com" +BUNDLE_ID = "pro.ziganshin.GrowingUp" +FILES = ("01-mia.png", "02-leo-celebration.png", "03-mango-teddy.png", "04-widgets.png") +LOCALES = ("en-US", "ru") +LIVE_STATES = {"READY_FOR_SALE", "READY_FOR_DISTRIBUTION"} +READY_ASSETS = {"PREPARE_FOR_SUBMISSION", "APPROVED"} +MANAGED_PREFIX = "GrowingUp-store:" +CAPTURE_INPUTS = ("GrowingUp", "Core", "Widget", "GrowingUpUITests", "GrowingUp.xcodeproj", + ".github/workflows/app-store-screenshots.yml", + "scripts/run-app-store-screenshots.sh", "scripts/render-app-store-screenshots.py", + "docs/app-store-demo-photos", "marketing/fonts") + + +def version_tuple(value): + if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", value): + raise ValueError("Expected a numeric App Store version") + return tuple(int(part) for part in value.split(".")) + ((0,) if value.count(".") == 1 else ()) + + +def select_version(versions, requested): + live = [v for v in versions if v["attributes"]["appStoreState"] in LIVE_STATES] + if not live: + raise ValueError("No live iOS App Store version; inspect inventory before creating a version") + latest = max(live, key=lambda v: version_tuple(v["attributes"]["versionString"])) + major, minor, _ = version_tuple(latest["attributes"]["versionString"]) + target = f"{major}.{minor + 1}.0" + if requested != "next" and version_tuple(requested) != version_tuple(target): + raise ValueError(f"Requested version is not the next minor of live App Store version; expected {target}") + existing = None + for version in versions: + attrs = version["attributes"] + if version_tuple(attrs["versionString"]) == version_tuple(target): + if attrs["appStoreState"] != "PREPARE_FOR_SUBMISSION": + raise ValueError(f"Target {target} is not an editable PREPARE_FOR_SUBMISSION version") + existing = version + elif version_tuple(attrs["versionString"]) > version_tuple(latest["attributes"]["versionString"]): + raise ValueError(f"Another App Store version already exists: {attrs['versionString']}; refusing to downgrade or change it") + return latest, target, existing + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class Apple: + def __init__(self): + import jwt + + key = Path(os.environ["ASC_KEY_PATH"]).read_text() + self.jwt = jwt + self.key = key + self.opener = urllib.request.build_opener(NoRedirect()) + + def request(self, method, path, data=None): + url = urllib.parse.urljoin(API_URL, path) + if urllib.parse.urlsplit(url).netloc != "api.appstoreconnect.apple.com" or not url.startswith("https://"): + raise ValueError("Refusing to send Apple credentials outside the API host") + now = int(time.time()) + token = self.jwt.encode( + {"iss": os.environ["ASC_ISSUER_ID"], "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"}, + self.key, algorithm="ES256", headers={"kid": os.environ["ASC_KEY_ID"], "typ": "JWT"}, + ) + body = json.dumps(data).encode() if data is not None else None + request = urllib.request.Request(url, body, method=method, headers={ + "Authorization": "Bearer " + token, "Content-Type": "application/json", + }) + try: + with self.opener.open(request, timeout=60) as response: + content = response.read() + return json.loads(content) if content else {} + except urllib.error.HTTPError as error: + # Do not print response details, JWTs, upload URLs or signed request headers. + try: + codes = [e.get("code", "UNKNOWN") for e in json.loads(error.read()).get("errors", [])] + except (ValueError, TypeError): + codes = [] + endpoint = urllib.parse.urlsplit(url).path + raise RuntimeError(f"Apple {method} {endpoint}: HTTP {error.code}, codes {codes}") from None + except urllib.error.URLError: + raise RuntimeError("Apple API network request failed; inspect inventory before retrying a mutation") from None + + def collection(self, path, **query): + if query: + path += "?" + urllib.parse.urlencode(query) + result = [] + while path: + page = self.request("GET", path) + result.extend(page["data"]) + path = page.get("links", {}).get("next") + return result + + def resource(self, path): + return self.request("GET", path)["data"] + + def create(self, resource_type, attributes, relationships): + return self.request("POST", f"/v1/{resource_type}", {"data": { + "type": resource_type, "attributes": attributes, "relationships": relationships, + }})["data"] + + def upload(self, operations, content): + validate_operations(operations, len(content)) + for operation in operations: + request = urllib.request.Request( + operation["url"], content[operation["offset"]:operation["offset"] + operation["length"]], + method=operation["method"], headers={h["name"]: h["value"] for h in operation["requestHeaders"]}, + ) + try: + with self.opener.open(request, timeout=90) as response: + response.read() + except (urllib.error.HTTPError, urllib.error.URLError): + raise RuntimeError("Apple binary upload failed; signed URL and headers withheld") from None + + +def relationship(resource_type, identifier): + return {"data": {"type": resource_type, "id": identifier}} + + +def validate_operations(operations, size): + cursor = 0 + for operation in sorted(operations, key=lambda op: op["offset"]): + if (operation["offset"] != cursor or operation["length"] <= 0 + or operation["method"] != "PUT" or not operation["url"].startswith("https://")): + raise ValueError("Invalid or incomplete Apple upload operations") + if any(header["name"].lower() == "authorization" for header in operation["requestHeaders"]): + raise ValueError("Apple upload operations must not contain authorization headers") + cursor += operation["length"] + if cursor != size: + raise ValueError("Apple upload operations do not cover the declared file size") + + +def screenshot(path, locale): + content = path.read_bytes() + if content[:8] != b"\x89PNG\r\n\x1a\n" or content[12:16] != b"IHDR" or len(content) < 33: + raise ValueError(f"Not a PNG screenshot: {path.name}") + width, height, _, color_type = struct.unpack(">IIBB", content[16:26]) + # Apple screenshot specifications disallow alpha. RGB and grayscale are unambiguous. + cursor = 8 + transparent = False + while cursor + 12 <= len(content): + length = struct.unpack(">I", content[cursor:cursor + 4])[0] + transparent |= content[cursor + 4:cursor + 8] == b"tRNS" + cursor += length + 12 + if color_type not in (0, 2) or transparent: + raise ValueError(f"Screenshot must be an opaque RGB/grayscale PNG: {path.name}") + digest = hashlib.sha256(content).hexdigest() + return {"path": path, "content": content, "width": width, "height": height, "sha256": digest, + "reference": f"{MANAGED_PREFIX}{locale}:{path.name}:{digest}"} + + +def resolve_spec(catalog, image): + features = [f for f in catalog["features"] if f["featureId"] == "APP_STORE_VERSIONS"] + types = [t for t in catalog["placementTypes"] if t["placementTypeId"] == "APP_SCREENSHOT"] + if len(features) != 1 or len(types) != 1: + raise ValueError("App Asset Library screenshot feature is unavailable") + placement_type = types[0] + if "APP_SCREENSHOTS_AND_PREVIEWS" not in placement_type["acceptsAssetCategories"]: + raise ValueError("Apple catalog does not accept screenshot images") + specs = [] + for spec in catalog["imageSpecs"]: + dimensions = spec["dimensions"] + if (dimensions["minWidth"] == dimensions["maxWidth"] == image["width"] + and dimensions["minHeight"] == dimensions["maxHeight"] == image["height"] + and ".png" in spec["fileExtensions"] and len(image["content"]) <= spec["maxFileSize"]): + specs.append(spec["specId"]) + groups = [] + for mapping in placement_type["specMappings"]: + group = mapping["placementGroupId"] + profile = next((p for p in catalog["placementProfileGroups"] if p["placementProfileGroupId"] == group), {}) + if profile.get("platform") != "IPHONE_APP_STORE": + continue + matching = set(mapping["specs"]) & set(specs) + for policy in features[0]["placementPolicies"]: + if policy["placementType"] == "APP_SCREENSHOT": + for limit in policy["groupLimits"]: + if group in limit["groupIds"] and limit["maxCount"] >= len(FILES) and len(matching) == 1: + groups.append((group, next(iter(matching)))) + if len(groups) != 1: + raise ValueError(f"Expected one iPhone screenshot placement group for {image['width']}x{image['height']}; found {len(groups)}") + return groups[0] + + +def validate_provenance(folder, source_sha, images): + provenance = json.loads((folder / "source.json").read_text()) + capture_sha = provenance["capture"]["head_sha"] + if not re.fullmatch(r"[0-9a-f]{40}", capture_sha): + raise ValueError("Screenshot capture provenance must contain a full source SHA") + root = folder.resolve().parents[2] + base_command = ["git", "-C", str(root)] + if subprocess.run(base_command + ["merge-base", "--is-ancestor", capture_sha, source_sha], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode: + raise ValueError("Screenshot capture source is not an ancestor of the reviewed master source") + if subprocess.run(base_command + ["diff", "--quiet", capture_sha, source_sha, "--", *CAPTURE_INPUTS], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode: + raise ValueError("App or screenshot capture inputs changed after capture; regenerate screenshots") + expected = {f"{locale}/{image['path'].name}": image["sha256"] for locale, files in images.items() for image in files} + if provenance["outputs"] != expected: + raise ValueError("Committed screenshots do not match their capture/render provenance digests") + dimensions = {(image["width"], image["height"]) for files in images.values() for image in files} + if dimensions != {tuple(provenance["size"])}: + raise ValueError("Screenshot dimensions do not match the capture/render provenance") + return {"head_sha": capture_sha, "run_id": provenance["capture"]["run_id"], + "run_attempt": provenance["capture"]["run_attempt"], "outputs": expected} + + +def placements(api, localization_id, group=None): + query = {"filter[placementType]": "APP_SCREENSHOT", "sort": "placementGroupPosition", "include": "image"} + if group: + query["filter[placementGroup]"] = group + # Fetch images explicitly: collection pagination does not retain included resources. + result = api.collection(f"/v1/appStoreVersionLocalizations/{localization_id}/placements", **query) + for placement in result: + image_id = placement["relationships"]["image"]["data"]["id"] + placement["image"] = api.resource(f"/v1/appAssetLibraryImages/{image_id}") + return result + + +def phone_placements(api, localization_id, groups): + return [placement for placement in placements(api, localization_id) + if placement["attributes"]["placementGroup"] in groups] + + +def check_replaceable(current, live): + inherited_ids = {p["image"]["id"] for p in live} + for placement in current: + image = placement["image"] + if image["id"] not in inherited_ids and not (image["attributes"].get("referenceName") or "").startswith(MANAGED_PREFIX): + raise ValueError("Draft contains unrelated screenshots; refusing to replace them") + + +def verify_image(asset, image, spec_id): + attrs = asset["attributes"] + if (attrs["state"] not in READY_ASSETS or attrs["fileSize"] != len(image["content"]) + or attrs["referenceName"] != image["reference"] or attrs["specId"] != spec_id + or attrs["imageAsset"]["width"] != image["width"] or attrs["imageAsset"]["height"] != image["height"]): + raise ValueError("Apple processed asset does not match the reviewed screenshot") + + +def verify_placements(verified, ordered, images, spec_id): + if [p["id"] for p in verified] != [p["id"] for p in ordered]: + raise ValueError("Apple screenshot order does not match Mia, Leo, Mango/Teddy, widgets") + for placement, image in zip(verified, images): + verify_image(placement["image"], image, spec_id) + if placement["attributes"]["state"] != "PARENT_PREPARE_FOR_SUBMISSION": + raise ValueError("Screenshot placement is not in the editable draft state") + + +def prepare_image(api, library_id, image, spec_id): + assets = api.collection(f"/v1/appAssetLibraries/{library_id}/images", **{"filter[referenceName]": image["reference"]}) + if len(assets) > 1: + raise ValueError("Duplicate managed screenshot reservations; inspect inventory before retrying") + if assets: + asset = assets[0] + else: + asset = api.create("appAssetLibraryImages", { + "fileName": image["path"].name, "fileSize": len(image["content"]), + "category": "APP_SCREENSHOTS_AND_PREVIEWS", "referenceName": image["reference"], + }, {"assetLibrary": relationship("appAssetLibraries", library_id)}) + asset_id = asset["id"] + if asset["attributes"]["state"] == "AWAITING_UPLOAD": + api.upload(asset["attributes"]["uploadOperations"], image["content"]) + api.request("PATCH", f"/v1/appAssetLibraryImages/{asset_id}", {"data": { + "type": "appAssetLibraryImages", "id": asset_id, "attributes": {"uploaded": True}, + }}) + deadline = time.monotonic() + 300 + while True: + asset = api.resource(f"/v1/appAssetLibraryImages/{asset_id}") + if asset["attributes"]["state"] in READY_ASSETS: + verify_image(asset, image, spec_id) + return asset + if asset["attributes"]["state"] != "UPLOAD_COMPLETE": + raise ValueError(f"Screenshot processing state is {asset['attributes']['state']}; inspect this asset before retrying") + if time.monotonic() >= deadline: + raise TimeoutError("Apple screenshot processing pending; rerun the same source to resume") + time.sleep(5) + + +def run(api, args, receipt): + apps = api.collection("/v1/apps", **{"filter[bundleId]": BUNDLE_ID}) + if len(apps) != 1: + raise ValueError("Expected exactly one GrowingUp app for the configured Apple account") + app_id = apps[0]["id"] + versions = api.collection(f"/v1/apps/{app_id}/appStoreVersions", **{"filter[platform]": "IOS"}) + receipt.update(app_id=app_id, versions=[{"id": v["id"], **{k: v["attributes"][k] for k in ("versionString", "appStoreState")}} for v in versions]) + library = api.resource(f"/v1/apps/{app_id}/assetLibrary") + reference = api.collection("/v1/appAssetLibraryRefData") + if len(reference) != 1: + raise ValueError("Expected the Apple asset specification catalog") + receipt["asset_library_id"] = library["id"] + receipt["catalog_id"] = reference[0]["id"] + if args.operation == "inventory": + receipt["localizations"] = {} + for version in versions: + localizations = api.collection(f"/v1/appStoreVersions/{version['id']}/appStoreVersionLocalizations") + receipt["localizations"][version["id"]] = [{ + "id": loc["id"], "locale": loc["attributes"]["locale"], "screenshots": [{ + "placement_id": p["id"], "group": p["attributes"]["placementGroup"], "image_id": p["image"]["id"], + "reference": p["image"]["attributes"].get("referenceName"), "state": p["image"]["attributes"]["state"], + } for p in placements(api, loc["id"])], + } for loc in localizations] + return + images = {locale: [screenshot(args.screenshots / locale / name, locale) for name in FILES] for locale in LOCALES} + receipt["capture"] = validate_provenance(args.screenshots, args.source_sha, images) + specifications = {locale: [resolve_spec(reference[0]["attributes"], image) for image in files] for locale, files in images.items()} + if len({value for values in specifications.values() for value in values}) != 1: + raise ValueError("The eight screenshots must target the same iPhone group and dimensions") + group, spec_id = specifications[LOCALES[0]][0] + phone_groups = {profile["placementProfileGroupId"] for profile in reference[0]["attributes"]["placementProfileGroups"] + if profile["platform"] == "IPHONE_APP_STORE"} + live, target, draft = select_version(versions, args.version) + receipt.update(target_version=target, live_version=live["attributes"]["versionString"], placement_group=group, spec_id=spec_id) + live_localizations = {loc["attributes"]["locale"]: loc for loc in api.collection(f"/v1/appStoreVersions/{live['id']}/appStoreVersionLocalizations")} + for locale in LOCALES: + if locale not in live_localizations: + raise ValueError(f"Live metadata for {locale} is missing; metadata authoring needs a separate instruction") + draft_localizations = {} if draft is None else {loc["attributes"]["locale"]: loc for loc in api.collection(f"/v1/appStoreVersions/{draft['id']}/appStoreVersionLocalizations")} + live_placements = {locale: phone_placements(api, live_localizations[locale]["id"], phone_groups) for locale in LOCALES} + # Check all affected iPhone groups in both locales before the first write. + for locale, localization in draft_localizations.items(): + if locale in LOCALES: + check_replaceable(phone_placements(api, localization["id"], phone_groups), live_placements[locale]) + if draft is None: + draft = api.create("appStoreVersions", {"platform": "IOS", "versionString": target, "releaseType": "MANUAL"}, {"app": relationship("apps", app_id)}) + draft_localizations = {loc["attributes"]["locale"]: loc for loc in api.collection(f"/v1/appStoreVersions/{draft['id']}/appStoreVersionLocalizations")} + receipt["version_id"] = draft["id"] + receipt["screenshots"] = [] + receipt["cleared_placements"] = [] + prepared_localizations = {} + for locale in LOCALES: + localization = draft_localizations.get(locale) + if localization is None: + attrs = {key: value for key, value in live_localizations[locale]["attributes"].items() + if key in {"description", "keywords", "marketingUrl", "supportUrl", "promotionalText"} and value is not None} + attrs["locale"] = locale + localization = api.create("appStoreVersionLocalizations", attrs, {"appStoreVersion": relationship("appStoreVersions", draft["id"])}) + localization_id = localization["id"] + current = placements(api, localization_id, group) + check_replaceable(current, live_placements[locale]) + assets = [prepare_image(api, library["id"], image, spec_id) for image in images[locale]] + wanted_ids = {asset["id"] for asset in assets} + # Only remove the explicitly targeted group's inherited or managed screenshots. + for placement in current: + if placement["image"]["id"] not in wanted_ids: + api.request("DELETE", f"/v1/appAssetLibraryPlacements/{placement['id']}") + ordered = [] + for asset, image in zip(assets, images[locale]): + matching = [p for p in current if p["image"]["id"] == asset["id"]] + if len(matching) > 1: + raise ValueError("Duplicate screenshot placements; inspect inventory") + placement = matching[0] if matching else api.create("appAssetLibraryPlacements", { + "placementType": "APP_SCREENSHOT", "placementGroup": group, + }, {"image": relationship("appAssetLibraryImages", asset["id"]), + "appStoreVersionLocalization": relationship("appStoreVersionLocalizations", localization_id)}) + ordered.append({"type": "appAssetLibraryPlacements", "id": placement["id"]}) + receipt["screenshots"].append({"locale": locale, "file": image["path"].name, "sha256": image["sha256"], + "asset_id": asset["id"], "placement_id": placement["id"], "state": asset["attributes"]["state"]}) + api.create("appAssetLibraryPlacementOrderingRequests", {"placementGroup": group}, { + "orderedPlacements": {"data": ordered}, "appStoreVersionLocalization": relationship("appStoreVersionLocalizations", localization_id), + }) + verified = placements(api, localization_id, group) + verify_placements(verified, ordered, images[locale], spec_id) + prepared_localizations[locale] = (localization_id, ordered) + # Both new main sets are verified before removing old size-specific overrides. + receipt["verified_groups"] = {} + for locale, (localization_id, ordered) in prepared_localizations.items(): + current_phone = phone_placements(api, localization_id, phone_groups) + check_replaceable(current_phone, live_placements[locale]) + for placement in current_phone: + if placement["attributes"]["placementGroup"] != group: + api.request("DELETE", f"/v1/appAssetLibraryPlacements/{placement['id']}") + receipt["cleared_placements"].append({"locale": locale, "placement_id": placement["id"], + "group": placement["attributes"]["placementGroup"], + "image_id": placement["image"]["id"]}) + remaining = phone_placements(api, localization_id, phone_groups) + if any(placement["attributes"]["placementGroup"] != group for placement in remaining): + raise ValueError("Old iPhone screenshot groups still override the new scaled screenshots") + verify_placements(remaining, ordered, images[locale], spec_id) + receipt["verified_groups"][locale] = [group] + final = api.resource(f"/v1/appStoreVersions/{draft['id']}") + if final["attributes"]["appStoreState"] != "PREPARE_FOR_SUBMISSION" or final["attributes"]["versionString"] != target: + raise ValueError("App Store version changed state during screenshot preparation") + receipt["verified_state"] = "PREPARE_FOR_SUBMISSION" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("inventory", "upload")) + parser.add_argument("--version", default="next") + parser.add_argument("--source-sha", required=True) + parser.add_argument("--screenshots", type=Path, default=Path("marketing/app-store/screenshots")) + parser.add_argument("--receipt", required=True, type=Path) + args = parser.parse_args() + if not re.fullmatch(r"[0-9a-f]{40}", args.source_sha): + parser.error("--source-sha must be a full commit SHA") + receipt = {"operation": args.operation, "source_sha": args.source_sha, "success": False} + try: + run(Apple(), args, receipt) + receipt["success"] = True + except Exception as error: + # Unexpected library exceptions may embed secrets; keep only the explicit safe errors. + receipt["error"] = str(error) if isinstance(error, (ValueError, RuntimeError, TimeoutError)) else type(error).__name__ + print(receipt["error"], file=sys.stderr) + finally: + args.receipt.write_text(json.dumps(receipt, indent=2) + "\n") + return 0 if receipt["success"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pr-visual-evidence.py b/scripts/pr-visual-evidence.py index 25b0f7bb..95f542c4 100644 --- a/scripts/pr-visual-evidence.py +++ b/scripts/pr-visual-evidence.py @@ -40,8 +40,11 @@ def media_file(directory, relative, suffix): def checked_run(repo, sha, metadata): + workflow = metadata.get("workflow", "ci.yml") + if workflow not in ("ci.yml", "app-store-screenshots.yml"): + raise ValueError("Evidence must come from CI or App Store Screenshots.") runs = json.loads(gh( - "run", "list", "--repo", repo, "--workflow", "ci.yml", "--event", "pull_request", + "run", "list", "--repo", repo, "--workflow", workflow, "--event", "pull_request", "--commit", sha, "--limit", "1", "--json", "databaseId", )) if not runs or str(runs[0]["databaseId"]) != str(metadata["run_id"]): @@ -73,8 +76,15 @@ def invalidate(endpoint, evidence): def publish(number, directory, summary, images, videos): directory = directory.resolve() metadata = json.loads((directory / "metadata.json").read_text()) - if metadata.get("journey_outcome") != "success" or metadata.get("export_outcome") != "success": - raise ValueError("Only successful, exported UI journeys can be published as acceptance evidence.") + workflow = metadata.get("workflow", "ci.yml") + if workflow == "ci.yml": + outcomes = ("journey_outcome", "export_outcome") + elif workflow == "app-store-screenshots.yml": + outcomes = ("capture_outcome", "export_outcome", "verification_outcome", "composition_outcome") + else: + raise ValueError("Evidence must come from CI or App Store Screenshots.") + if any(metadata.get(outcome) != "success" for outcome in outcomes): + raise ValueError("Only successful, exported and verified workflow evidence can be published.") if not summary.strip(): raise ValueError("Describe the behavior inspected and any limits in the summary file.") if START in summary or END in summary: @@ -136,7 +146,8 @@ def publish(number, directory, summary, images, videos): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pr", type=int) - parser.add_argument("directory", type=Path, help="one ui-* artifact directory with metadata.json") + parser.add_argument("directory", type=Path, + help="one ui-* or app-store-capture-* artifact directory with metadata.json") parser.add_argument("--summary-file", type=Path, required=True, help="agent's visual review in Markdown") parser.add_argument("--image", action="append", default=[], help="PNG path relative to the artifact directory") parser.add_argument("--video", action="append", default=[], help="short MP4 path relative to the artifact directory") diff --git a/scripts/render-app-store-screenshots.py b/scripts/render-app-store-screenshots.py new file mode 100644 index 00000000..c5c0af0a --- /dev/null +++ b/scripts/render-app-store-screenshots.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Compose approved App Store artwork exclusively from successful native captures.""" +import argparse +import hashlib +import json +import re +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +SIZE = (1320, 2868) +NAMES = ('mia', 'leo-celebration', 'mango', 'teddy', 'widget-small', 'widget-medium', 'widget-large') +COPY = { + 'ru': [ + ('Возраст вашего\nребёнка', 'С точностью до секунды.'), + ('С днём\nрождения!', 'Празднуйте вместе с GrowingUp.'), + ('Возраст вашего\nпитомца', ''), + ('Возраст на\nглавном экране', 'Три размера для семьи и питомцев.'), + ], + 'en': [ + ('Your child’s\nage', 'Down to the second.'), + ('Happy\nbirthday!', 'Celebrate with GrowingUp.'), + ('Your pet’s\nage', ''), + ('Home Screen\nwidgets', 'Three sizes for your family and pets.'), + ], +} +FILES = ('01-mia.png', '02-leo-celebration.png', '03-mango-teddy.png', '04-widgets.png') + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def native_inputs(directory, locale): + source = directory / locale + summary = json.loads((source / 'test-summary.json').read_text()) + if summary.get('passedTests', 0) < 1 or summary.get('failedTests', 0) != 0: + raise ValueError(f'{locale}: capture did not pass') + attachments = source / 'attachments' + manifest = json.loads((attachments / 'manifest.json').read_text()) + entries = [item for test in manifest for item in test['attachments']] + result = {} + export_suffix = re.compile(r'_\d+_[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$') + for name in NAMES: + checkpoint = f'store-{locale}-{name}' + matches = [item for item in entries + if export_suffix.sub('', item['suggestedHumanReadableName'].removesuffix('.png')) == checkpoint] + if len(matches) != 1: + raise ValueError(f'{checkpoint}: expected exactly one native checkpoint') + path = (attachments / matches[0]['exportedFileName']).resolve() + if path.parent != attachments.resolve(): + raise ValueError('Attachment path escapes its capture directory') + with Image.open(path) as image: + expected = {'widget-small': (510, 510), 'widget-medium': (1092, 510), + 'widget-large': (1092, 1146)}.get(name, SIZE) + if image.format != 'PNG' or image.size != expected: + raise ValueError(f'{checkpoint}: invalid native PNG dimensions') + result[name] = (image.convert('RGBA'), path) + return result + + +def background(index): + base = [(16, 36, 43), (22, 34, 41), (23, 42, 40), (24, 58, 66)][index] + glow = (38, 117, 132) + small = Image.new('RGB', (132, 287)) + pixels = [] + for y in range(287): + for x in range(132): + distance = ((x / 131 - 1) ** 2 / 1.1 + (y / 286 - .85) ** 2 / .8) ** .5 + amount = max(0, 1 - distance) * .65 + pixels.append(tuple(round(a + (b - a) * amount) for a, b in zip(base, glow))) + small.putdata(pixels) + return small.resize(SIZE, Image.Resampling.BICUBIC).convert('RGBA') + + +def font(size, bold=False): + filename = 'DejaVuSans-Bold.ttf' if bold else 'DejaVuSans.ttf' + return ImageFont.truetype(str(ROOT / 'marketing/fonts' / filename), size) + + +def marketing_copy(canvas, locale, index): + draw = ImageDraw.Draw(canvas) + x, y = 106, 206 + draw.text((x, y), 'GROWINGUP', font=font(39, True), fill='#8ad9e4') + title, subtitle = COPY[locale][index] + title_font = font(119, True) + lines = title.split('\n') + for line in lines: + if draw.textlength(line, font=title_font) > 1108: + raise ValueError(f'{locale}: headline exceeds the approved text area') + draw.multiline_text((x, 330), title, font=title_font, fill='white', spacing=20) + if subtitle: + body_font = font(44) + words, wrapped = subtitle.split(), [] + line = '' + for word in words: + candidate = f'{line} {word}'.strip() + if line and draw.textlength(candidate, font=body_font) > 1080: + wrapped.append(line) + line = word + else: + line = candidate + wrapped.append(line) + draw.multiline_text((x, 655), '\n'.join(wrapped), font=body_font, fill='#b7cbd0', spacing=14) + + +def rounded_mask(size, radius): + mask = Image.new('L', size) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255) + return mask + + +def phone(canvas, screenshot, left, top, width, angle): + height = round(width * SIZE[1] / SIZE[0]) + border = round(width * .02) + frame = Image.new('RGBA', (width, height), '#080b0e') + ImageDraw.Draw(frame).rounded_rectangle((0, 0, width - 1, height - 1), radius=width * .14, + fill='#080b0e', outline='#5b5d60', width=border) + inner_size = (width - border * 2, height - border * 2) + screen = screenshot.resize(inner_size, Image.Resampling.LANCZOS) + screen.putalpha(rounded_mask(inner_size, width * .12)) + frame.alpha_composite(screen, (border, border)) + # Simulator screenshots omit the physical camera cutout; the device frame includes it. + ImageDraw.Draw(frame).rounded_rectangle((width * .32, height * .025, width * .68, height * .069), + radius=width * .05, fill='#030405') + frame.putalpha(rounded_mask(frame.size, width * .14)) + rotated = frame.rotate(-angle, resample=Image.Resampling.BICUBIC, expand=True) + position = (round(left + (width - rotated.width) / 2), round(top + (height - rotated.height) / 2)) + shadow = Image.new('RGBA', rotated.size, '#020d11') + shadow.putalpha(rotated.getchannel('A').filter(ImageFilter.GaussianBlur(35)).point(lambda a: a * .35)) + canvas.alpha_composite(shadow, (position[0], position[1] + 30)) + canvas.alpha_composite(rotated, position) + + +def widget_screen(inputs): + screen = Image.new('RGBA', SIZE) + draw = ImageDraw.Draw(screen) + for y in range(SIZE[1]): + amount = y / SIZE[1] + color = tuple(round(a + (b - a) * amount) for a, b in zip((169, 196, 177), (64, 89, 105))) + draw.line((0, y, SIZE[0], y), fill=color) + # Retain the simulator's real status bar rather than fabricating system symbols. + header = inputs['mia'][0].crop((0, 0, SIZE[0], 190)) + screen.alpha_composite(header) + for name, width, top in [('widget-small', 500, 350), ('widget-medium', 1140, 960), + ('widget-large', 1140, 1550)]: + image = inputs[name][0] + height = round(image.height * width / image.width) + card = image.resize((width, height), Image.Resampling.LANCZOS) + native_width = 170 if name == 'widget-small' else 364 + card.putalpha(rounded_mask(card.size, width * 24 / native_width)) + screen.alpha_composite(card, ((SIZE[0] - width) // 2, top)) + return screen + + +def compose(directory, output): + metadata = json.loads((directory / 'metadata.json').read_text()) + if not re.fullmatch('[0-9a-f]{40}', metadata['head_sha']): + raise ValueError('Capture metadata needs the full source head SHA') + if (metadata['device'], metadata['runtime']) != ('iPhone-17-Pro-Max', '26-5'): + raise ValueError('Use the approved native iPhone 17 Pro Max / iOS 26.5 capture') + if output.exists() and any(output.iterdir()): + raise ValueError('Output directory must be empty to avoid stale screenshots') + output.mkdir(parents=True, exist_ok=True) + receipt = {'capture': metadata, 'size': list(SIZE), 'inputs': {}, 'outputs': {}} + for locale, store_locale in [('en', 'en-US'), ('ru', 'ru')]: + inputs = native_inputs(directory, locale) + receipt['inputs'][store_locale] = {name: sha256(path) for name, (_, path) in inputs.items()} + destination = output / store_locale + destination.mkdir() + for index, name in enumerate(FILES): + canvas = background(index) + marketing_copy(canvas, locale, index) + if index == 0: + phone(canvas, inputs['mia'][0], 119, 1004, 1135, -4) + elif index == 1: + phone(canvas, inputs['leo-celebration'][0], 92, 1004, 1135, 4) + elif index == 2: + phone(canvas, inputs['teddy'][0], -106, 1233, 766, -9) + phone(canvas, inputs['mango'][0], 158, 1004, 1000, 5) + else: + phone(canvas, widget_screen(inputs), 215, 900, 890, 0) + path = destination / name + canvas.convert('RGB').save(path, optimize=True) + receipt['outputs'][f'{store_locale}/{name}'] = sha256(path) + (output / 'source.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n') + print(f'Composed 8 native-backed App Store PNGs in {output}') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('capture', type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + compose(args.capture.resolve(), args.output.resolve()) diff --git a/scripts/run-app-store-screenshots.sh b/scripts/run-app-store-screenshots.sh new file mode 100755 index 00000000..2f5f9941 --- /dev/null +++ b/scripts/run-app-store-screenshots.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -euo pipefail + +: "${SIM_UDID:?Set SIM_UDID to an existing, booted iPhone 17 Pro Max simulator}" +: "${ARTIFACT_DIR:?Set ARTIFACT_DIR to a temporary directory outside the repository}" +: "${GROWINGUP_APP_STORE_PHOTOS:?Set GROWINGUP_APP_STORE_PHOTOS to the absolute demo photo directory}" + +for PHOTO in baby-girl young-boy cat dog; do + test -s "$GROWINGUP_APP_STORE_PHOTOS/$PHOTO.png" \ + || { echo "Missing demo photo: $GROWINGUP_APP_STORE_PHOTOS/$PHOTO.png" >&2; exit 1; } +done + +RECORDER_PID='' +stop_recording() { + if [ -n "$RECORDER_PID" ]; then + kill -INT "$RECORDER_PID" 2>/dev/null || true + wait "$RECORDER_PID" + RECORDER_PID='' + fi +} +trap stop_recording EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +CAPTURE_STATUS=0 +for LOCALE in en ru; do + LOCALE_DIR="$ARTIFACT_DIR/$LOCALE" + mkdir -p "$LOCALE_DIR" + jq --arg locale "$LOCALE" '. + {locale: $locale}' "$ARTIFACT_DIR/metadata.json" \ + > "$LOCALE_DIR/metadata.json" + + # Expand the locale into the test runner configuration before recording. + # The second build reuses Xcode's default DerivedData and is incremental. + xcodebuild build-for-testing -project GrowingUp.xcodeproj -scheme GrowingUpUI \ + -destination "id=$SIM_UDID" -parallel-testing-enabled NO \ + -only-testing:GrowingUpUITests/AppStoreScreenshotTests/testCaptureAppStoreScreenshots \ + GROWINGUP_APP_STORE_CAPTURE=1 GROWINGUP_APP_STORE_PHOTOS="$GROWINGUP_APP_STORE_PHOTOS" \ + GROWINGUP_APP_STORE_LOCALE="$LOCALE" GROWINGUP_VISUAL_CHECKS=0 GROWINGUP_RECORD_SNAPSHOTS=0 \ + CODE_SIGNING_ALLOWED=NO | tee "$LOCALE_DIR/build.log" + + xcrun simctl io "$SIM_UDID" recordVideo --codec=h264 "$LOCALE_DIR/journeys.mp4" \ + > "$LOCALE_DIR/recording.log" 2>&1 & + RECORDER_PID=$! + + for ATTEMPT in {1..30}; do + grep -q 'Recording started' "$LOCALE_DIR/recording.log" && break + kill -0 "$RECORDER_PID" 2>/dev/null \ + || { cat "$LOCALE_DIR/recording.log" >&2; exit 1; } + sleep 1 + done + grep -q 'Recording started' "$LOCALE_DIR/recording.log" \ + || { echo 'Simulator video recording did not start within 30 seconds.' >&2; exit 1; } + + TEST_STATUS=0 + xcodebuild test-without-building -project GrowingUp.xcodeproj -scheme GrowingUpUI \ + -destination "id=$SIM_UDID" -parallel-testing-enabled NO \ + -only-testing:GrowingUpUITests/AppStoreScreenshotTests/testCaptureAppStoreScreenshots \ + -resultBundlePath "$LOCALE_DIR/UI.xcresult" \ + GROWINGUP_APP_STORE_CAPTURE=1 GROWINGUP_APP_STORE_PHOTOS="$GROWINGUP_APP_STORE_PHOTOS" \ + GROWINGUP_APP_STORE_LOCALE="$LOCALE" GROWINGUP_VISUAL_CHECKS=0 GROWINGUP_RECORD_SNAPSHOTS=0 \ + CODE_SIGNING_ALLOWED=NO | tee "$LOCALE_DIR/xcodebuild.log" || TEST_STATUS=$? + + # SIGINT finalizes the MP4 container even when an assertion failed. + stop_recording + test -s "$LOCALE_DIR/journeys.mp4" + if [ "$TEST_STATUS" -ne 0 ] && [ "$CAPTURE_STATUS" -eq 0 ]; then + CAPTURE_STATUS=$TEST_STATUS + fi +done +exit "$CAPTURE_STATUS" diff --git a/scripts/tests/test_app_store_metadata.py b/scripts/tests/test_app_store_metadata.py new file mode 100644 index 00000000..003735b3 --- /dev/null +++ b/scripts/tests/test_app_store_metadata.py @@ -0,0 +1,306 @@ +import importlib.util +import json +from pathlib import Path +import struct +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location("app_store_metadata", Path(__file__).parents[1] / "app-store-metadata.py") +metadata = importlib.util.module_from_spec(spec) +spec.loader.exec_module(metadata) + + +def version(value, state="PREPARE_FOR_SUBMISSION"): + return {"id": value, "attributes": {"versionString": value, "appStoreState": state}} + + +def image_attributes(image, spec_id="spec"): + return {"state": "PREPARE_FOR_SUBMISSION", "fileSize": len(image["content"]), + "referenceName": image["reference"], "specId": spec_id, + "imageAsset": {"width": image["width"], "height": image["height"]}} + + +class ScreenshotAPI: + """Stateful Apple responses for the placement replacement contract.""" + + def __init__(self, old_groups=True, unrelated=False, fail_second_locale=False): + self.events = [] + self.assets = {} + self.localizations = {f"{state}-{locale}": [] for state in ("live", "draft") for locale in metadata.LOCALES} + self.fail_second_locale = fail_second_locale + for locale in metadata.LOCALES: + for state in ("live", "draft"): + self.add(f"{state}-{locale}", "tablet", f"tablet-{locale}", "APP_SCREENSHOT") + self.add(f"{state}-{locale}", "primary", f"preview-{locale}", "APP_PREVIEW") + if old_groups: + image_id = f"old-{locale}" if not (unrelated and state == "draft" and locale == "ru") else "unrelated" + self.add(f"{state}-{locale}", "small-phone", image_id, "APP_SCREENSHOT") + + def add(self, localization, group, image_id, kind): + self.assets[image_id] = {"id": image_id, "attributes": {"referenceName": None}} + placement = {"id": f"{localization}-{image_id}", "attributes": { + "placementGroup": group, "placementType": kind, "state": "PARENT_PREPARE_FOR_SUBMISSION"}, + "relationships": {"image": {"data": {"id": image_id}}}} + self.localizations[localization].append(placement) + + def collection(self, path, **query): + if path == "/v1/apps": + return [{"id": "app"}] + if path == "/v1/apps/app/appStoreVersions": + return [version("2.0.0", "READY_FOR_DISTRIBUTION"), version("2.1.0")] + if path == "/v1/appAssetLibraryRefData": + return [{"id": "catalog", "attributes": {"placementProfileGroups": [ + {"placementProfileGroupId": group, "platform": platform} for group, platform in ( + ("primary", "IPHONE_APP_STORE"), ("small-phone", "IPHONE_APP_STORE"), ("tablet", "IPAD_APP_STORE"))]}}] + if path.endswith("/appStoreVersionLocalizations"): + state = "live" if "/2.0.0/" in path else "draft" + return [{"id": f"{state}-{locale}", "attributes": {"locale": locale}} for locale in metadata.LOCALES] + if path.endswith("/placements"): + localization = path.split("/")[-2] + result = [placement for placement in self.localizations[localization] + if placement["attributes"]["placementType"] == query["filter[placementType]"] + and (not query.get("filter[placementGroup]") or placement["attributes"]["placementGroup"] == query["filter[placementGroup]"])] + self.events.append(("read", localization, query.get("filter[placementGroup]"), len(result))) + return result + raise AssertionError(path) + + def resource(self, path): + if path.endswith("/assetLibrary"): + return {"id": "library"} + if "/appAssetLibraryImages/" in path: + return self.assets[path.split("/")[-1]] + if path == "/v1/appStoreVersions/2.1.0": + return version("2.1.0") + raise AssertionError(path) + + def create(self, kind, attributes, relationships): + localization = relationships["appStoreVersionLocalization"]["data"]["id"] + self.events.append(("create", kind, localization)) + if kind == "appAssetLibraryPlacementOrderingRequests": + if self.fail_second_locale and localization == "draft-ru": + raise ValueError("Second locale ordering failed") + wanted = [p["id"] for p in relationships["orderedPlacements"]["data"]] + current = self.localizations[localization] + self.localizations[localization] = ([p for p in current if p["id"] not in wanted] + + [next(p for p in current if p["id"] == identifier) for identifier in wanted]) + return {"id": "ordering"} + image_id = relationships["image"]["data"]["id"] + placement = {"id": f"placement-{image_id}", "attributes": {**attributes, "state": "PARENT_PREPARE_FOR_SUBMISSION"}, + "relationships": {"image": {"data": {"id": image_id}}}} + self.localizations[localization].append(placement) + return placement + + def request(self, method, path): + if method != "DELETE" or not path.startswith("/v1/appAssetLibraryPlacements/"): + raise AssertionError((method, path)) + identifier = path.split("/")[-1] + self.events.append(("delete", identifier)) + for localization, values in self.localizations.items(): + self.localizations[localization] = [p for p in values if p["id"] != identifier] + return {} + + +class AppStoreMetadataTests(unittest.TestCase): + def run_screenshot_upload(self, api, receipt): + args = SimpleNamespace(operation="upload", screenshots=Path("marketing/app-store/screenshots"), source_sha="a" * 40, version="2.1.0") + + def image(path, locale): + return {"path": path, "content": b"png", "width": 1320, "height": 2868, + "sha256": locale + path.name, "reference": metadata.MANAGED_PREFIX + locale + path.name} + + def prepared(api, library_id, value, spec_id): + asset = {"id": value["reference"], "attributes": image_attributes(value)} + api.assets[asset["id"]] = asset + return asset + + with patch.object(metadata, "screenshot", side_effect=image), patch.object(metadata, "validate_provenance", return_value={}), \ + patch.object(metadata, "resolve_spec", return_value=("primary", "spec")), patch.object(metadata, "prepare_image", side_effect=prepared): + metadata.run(api, args, receipt) + + def test_old_phone_groups_removed_after_both_main_sets_verified_preserving_tablets_previews_assets(self): + api = ScreenshotAPI() + receipt = {} + self.run_screenshot_upload(api, receipt) + deleted = [event[1] for event in api.events if event[0] == "delete"] + self.assertEqual(deleted, ["draft-en-US-old-en-US", "draft-ru-old-ru"]) + for locale in metadata.LOCALES: + first_delete = next(i for i, event in enumerate(api.events) if event[0] == "delete") + verification = next(i for i, event in enumerate(api.events) + if event == ("read", f"draft-{locale}", "primary", 4)) + self.assertLess(verification, first_delete) + kept = {p["id"] for p in api.localizations[f"draft-{locale}"]} + self.assertIn(f"draft-{locale}-tablet-{locale}", kept) + self.assertIn(f"draft-{locale}-preview-{locale}", kept) + self.assertIn(f"old-{locale}", api.assets) + self.assertEqual(receipt["verified_groups"], {"en-US": ["primary"], "ru": ["primary"]}) + self.assertEqual(receipt["cleared_placements"], [{"locale": locale, "placement_id": f"draft-{locale}-old-{locale}", + "group": "small-phone", "image_id": f"old-{locale}"} for locale in metadata.LOCALES]) + + def test_unrelated_draft_phone_asset_stops_before_first_write(self): + api = ScreenshotAPI(unrelated=True) + with self.assertRaisesRegex(ValueError, "unrelated"): + self.run_screenshot_upload(api, {}) + self.assertFalse(any(event[0] in {"create", "delete"} for event in api.events)) + + def test_no_old_groups_needs_no_cleanup(self): + api = ScreenshotAPI(old_groups=False) + receipt = {} + self.run_screenshot_upload(api, receipt) + self.assertEqual(receipt["cleared_placements"], []) + self.assertFalse(any(event[0] == "delete" for event in api.events)) + + def test_second_main_set_failure_leaves_all_old_phone_groups(self): + api = ScreenshotAPI(fail_second_locale=True) + with self.assertRaisesRegex(ValueError, "Second locale"): + self.run_screenshot_upload(api, {}) + self.assertFalse(any(event[0] == "delete" for event in api.events)) + + def test_next_minor_comes_from_live_store_not_newer_beta_source(self): + live = version("2.0.0", "READY_FOR_DISTRIBUTION") + self.assertEqual(metadata.select_version([live], "next"), (live, "2.1.0", None)) + self.assertEqual(metadata.select_version([live, version("2.1.0")], "2.1.0")[1], "2.1.0") + + def test_version_selection_refuses_higher_draft_and_wrong_request(self): + live = version("2.0.0", "READY_FOR_SALE") + with self.assertRaisesRegex(ValueError, "Another App Store version"): + metadata.select_version([live, version("2.1.9")], "2.1.0") + with self.assertRaisesRegex(ValueError, "next minor"): + metadata.select_version([live], "2.0.1") + with self.assertRaisesRegex(ValueError, "not an editable"): + metadata.select_version([live, version("2.1.0", "WAITING_FOR_REVIEW")], "next") + + def test_upload_operations_cover_bytes_exactly_without_credentials(self): + operations = [{"method": "PUT", "url": "https://store.apple.com/part", "offset": offset, + "length": 5, "requestHeaders": []} for offset in (5, 0)] + metadata.validate_operations(operations, 10) + operations[0]["offset"] = 6 + with self.assertRaises(ValueError): + metadata.validate_operations(operations, 10) + operations[0]["offset"] = 5 + operations[0]["requestHeaders"] = [{"name": "Authorization", "value": "secret"}] + with self.assertRaisesRegex(ValueError, "authorization"): + metadata.validate_operations(operations, 10) + + def test_screenshot_digest_and_transparency(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "01-mia.png" + header = struct.pack(">I", 13) + b"IHDR" + struct.pack(">IIBBBBB", 1320, 2868, 8, 2, 0, 0, 0) + b"\0" * 4 + path.write_bytes(b"\x89PNG\r\n\x1a\n" + header) + image = metadata.screenshot(path, "ru") + self.assertEqual((image["width"], image["height"]), (1320, 2868)) + self.assertEqual(len(image["sha256"]), 64) + self.assertTrue(image["reference"].endswith(image["sha256"])) + path.write_bytes(path.read_bytes() + struct.pack(">I", 0) + b"tRNS" + b"\0" * 4) + with self.assertRaisesRegex(ValueError, "opaque"): + metadata.screenshot(path, "ru") + + def test_resolve_runtime_catalog_spec_and_limit(self): + catalog = {"features": [{"featureId": "APP_STORE_VERSIONS", "placementPolicies": [{ + "placementType": "APP_SCREENSHOT", "groupLimits": [{"groupIds": ["new-phone-group"], "maxCount": 10}], + }]}], "placementTypes": [{"placementTypeId": "APP_SCREENSHOT", "acceptsAssetCategories": ["APP_SCREENSHOTS_AND_PREVIEWS"], + "specMappings": [{"placementGroupId": "new-phone-group", "specs": ["spec"]}]}], + "placementProfileGroups": [{"placementProfileGroupId": "new-phone-group", "platform": "IPHONE_APP_STORE"}], + "imageSpecs": [{"specId": "spec", "dimensions": {"minWidth": 1320, "maxWidth": 1320, "minHeight": 2868, "maxHeight": 2868}, + "fileExtensions": [".png"], "maxFileSize": 100}]} + image = {"width": 1320, "height": 2868, "content": b"png"} + self.assertEqual(metadata.resolve_spec(catalog, image), ("new-phone-group", "spec")) + catalog["features"][0]["placementPolicies"][0]["groupLimits"][0]["maxCount"] = 3 + with self.assertRaisesRegex(ValueError, "found 0"): + metadata.resolve_spec(catalog, image) + + def test_refuses_unrelated_draft_images_but_allows_inherited_live(self): + old = {"image": {"id": "old", "attributes": {"referenceName": "old marketing"}}} + managed = {"image": {"id": "ours", "attributes": {"referenceName": metadata.MANAGED_PREFIX + "ru:01-mia:hash"}}} + metadata.check_replaceable([old, managed], [old]) + with self.assertRaisesRegex(ValueError, "unrelated"): + metadata.check_replaceable([old], []) + + def test_processed_asset_verification_checks_dimensions_spec_reference_size(self): + image = {"content": b"png", "width": 1320, "height": 2868, "reference": "managed:sha256"} + asset = {"id": "asset", "attributes": image_attributes(image)} + metadata.verify_image(asset, image, "spec") + for field, value in [("fileSize", 4), ("referenceName", "other"), ("specId", "wrong"), ("state", "FAILED")]: + bad = {"attributes": {**asset["attributes"], field: value}} + with self.assertRaises(ValueError): + metadata.verify_image(bad, image, "spec") + + def test_resume_ready_asset_never_reserves_or_transfers_again(self): + image = {"content": b"png", "width": 1320, "height": 2868, "reference": "managed:sha256"} + asset = {"id": "asset", "attributes": image_attributes(image)} + + class API: + def collection(self, path, **query): + return [asset] + + def resource(self, path): + return asset + + self.assertEqual(metadata.prepare_image(API(), "library", image, "spec"), asset) + + def test_commit_uses_current_library_contract_and_verifies_after_processing(self): + image = {"path": Path("01-mia.png"), "content": b"png", "width": 1320, "height": 2868, "reference": "managed:sha256"} + committed = [] + + class API: + def collection(self, path, **query): + return [] + + def create(self, kind, attributes, relationships): + self.asset = {"id": "asset", "attributes": {"state": "AWAITING_UPLOAD", "uploadOperations": []}} + return self.asset + + def upload(self, operations, content): + self.content = content + + def request(self, method, path, data): + committed.append(data["data"]["attributes"]) + self.asset["attributes"] = image_attributes(image) + + def resource(self, path): + return self.asset + + api = API() + metadata.prepare_image(api, "library", image, "spec") + self.assertEqual(api.content, b"png") + self.assertEqual(committed, [{"uploaded": True}]) + + def test_order_verification_rejects_reversed_apple_response(self): + images = [{"content": b"png", "width": 1320, "height": 2868, "reference": name} for name in ("mia", "leo")] + verified = [{"id": image["reference"], "image": {"attributes": image_attributes(image)}, + "attributes": {"state": "PARENT_PREPARE_FOR_SUBMISSION"}} for image in images] + ordered = [{"id": "mia"}, {"id": "leo"}] + metadata.verify_placements(verified, ordered, images, "spec") + with self.assertRaisesRegex(ValueError, "order"): + metadata.verify_placements(verified[::-1], ordered, images, "spec") + + def test_provenance_accepts_asset_commit_but_rejects_changed_capture_inputs_or_digest(self): + with tempfile.TemporaryDirectory() as directory: + folder = Path(directory) / "marketing/app-store/screenshots" + folder.mkdir(parents=True) + provenance = {"capture": {"head_sha": "a" * 40, "run_id": "12", "run_attempt": "1"}, + "size": [1320, 2868], "outputs": {"ru/01-mia.png": "digest"}} + (folder / "source.json").write_text(json.dumps(provenance)) + images = {"ru": [{"path": folder / "ru/01-mia.png", "sha256": "digest", "width": 1320, "height": 2868}]} + with patch.object(metadata.subprocess, "run") as command: + command.return_value.returncode = 0 + result = metadata.validate_provenance(folder, "b" * 40, images) + self.assertEqual(result["head_sha"], "a" * 40) + self.assertIn("marketing/fonts", command.call_args.args[0]) + self.assertIn(".github/workflows/app-store-screenshots.yml", command.call_args.args[0]) + command.return_value.returncode = 1 + with self.assertRaisesRegex(ValueError, "ancestor"): + metadata.validate_provenance(folder, "b" * 40, images) + command.return_value.returncode = 0 + images["ru"][0]["sha256"] = "tampered" + with self.assertRaisesRegex(ValueError, "digests"): + metadata.validate_provenance(folder, "b" * 40, images) + command.side_effect = [type("Result", (), {"returncode": 0})(), type("Result", (), {"returncode": 1})()] + with self.assertRaisesRegex(ValueError, "changed after capture"): + metadata.validate_provenance(folder, "b" * 40, images) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_pr_visual_evidence.py b/scripts/tests/test_pr_visual_evidence.py index 98358a93..a0a3bb60 100644 --- a/scripts/tests/test_pr_visual_evidence.py +++ b/scripts/tests/test_pr_visual_evidence.py @@ -38,11 +38,13 @@ def setUp(self): ) self.edits = [] self.uploads = [] + self.queried_workflows = [] def gh(self, *args): if args == ("api", "repos/{owner}/{repo}"): return json.dumps({"full_name": "owner/repo", "id": 1234}) if args[:2] == ("run", "list"): + self.queried_workflows.append(args[args.index("--workflow") + 1]) return json.dumps([{"databaseId": 123}]) if args == ("api", "repos/owner/repo/pulls/42"): return json.dumps(self.pr) @@ -98,6 +100,65 @@ def test_stale_head_or_attempt_never_uploads(self): self.assertEqual(self.edits, []) self.assertEqual(self.uploads, []) + def capture_metadata(self): + self.metadata.pop("journey_outcome") + self.metadata.update( + workflow="app-store-screenshots.yml", capture_outcome="success", + verification_outcome="success", composition_outcome="success", + ) + + def test_capture_publication_checks_the_capture_workflow(self): + self.capture_metadata() + self.publish("Inspected both native locale captures, widget crops and birthday animation.") + self.assertEqual(self.queried_workflows, ["app-store-screenshots.yml"] * 2) + self.assertEqual(len(self.uploads), 2) + self.assertIn("current-head", self.pr["body"]) + + def test_ci_publication_keeps_the_existing_workflow(self): + self.publish() + self.assertEqual(self.queried_workflows, ["ci.yml"] * 2) + + def test_capture_requires_every_stage_before_upload(self): + self.capture_metadata() + for outcome in ("capture_outcome", "export_outcome", "verification_outcome", "composition_outcome"): + for value in ("failure", "skipped", None): + with self.subTest(outcome=outcome, value=value): + if value is None: + self.metadata.pop(outcome) + else: + self.metadata[outcome] = value + with self.assertRaises(ValueError): + self.publish() + self.metadata[outcome] = "success" + self.assertEqual(self.edits, []) + self.assertEqual(self.uploads, []) + + def test_capture_rejects_stale_source_and_attempt(self): + self.capture_metadata() + for field, value in (("head_sha", "old-head"), ("run_attempt", "1"), ("run_id", "122")): + with self.subTest(field=field): + original = self.metadata[field] + self.metadata[field] = value + with self.assertRaises(ValueError): + self.publish() + self.metadata[field] = original + self.assertEqual(self.edits, []) + self.assertEqual(self.uploads, []) + + def test_unsupported_workflow_is_rejected_before_upload(self): + self.metadata["workflow"] = "unrelated-release.yml" + with self.assertRaisesRegex(ValueError, "CI or App Store"): + self.publish() + self.assertEqual(self.edits, []) + self.assertEqual(self.uploads, []) + + def test_capture_metadata_needs_an_explicit_workflow(self): + self.capture_metadata() + self.metadata.pop("workflow") + with self.assertRaises(ValueError): + self.publish() + self.assertEqual(self.uploads, []) + def test_failed_ci_or_export_never_uploads(self): self.run["conclusion"] = "failure" with self.assertRaises(ValueError):