From efa88bd87f99fb85600829e4f96c1887225ff6a3 Mon Sep 17 00:00:00 2001 From: ThePhaseless Date: Tue, 6 Oct 2026 14:59:15 +0200 Subject: [PATCH] feat(release): render and upload the store screenshots flutter-screenshots.yml runs the app's :android (Linux, Noto Color Emoji) and :ios (macOS, San Francisco) mise tasks. With the new screenshots-task input, production releases replace the Google Play listing and App Store version screenshots that differ, per language. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/flutter-release.yml | 41 +- .github/workflows/flutter-screenshots.yml | 69 ++ README.md | 11 + scripts/app_store_submit.py | 118 ++++ scripts/google_play.py | 43 ++ tests/specs/app-store-connect-api-4.5.json | 697 ++++++++++++++++++++- tests/support.py | 2 +- tests/test_app_store_submit.py | 112 +++- tests/test_google_play.py | 73 ++- 9 files changed, 1154 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/flutter-screenshots.yml diff --git a/.github/workflows/flutter-release.yml b/.github/workflows/flutter-release.yml index 4b033f3..4af0b05 100644 --- a/.github/workflows/flutter-release.yml +++ b/.github/workflows/flutter-release.yml @@ -48,6 +48,9 @@ on: pages: description: Deploy the web builds to GitHub Pages once Pages is enabled for the repository. type: boolean + screenshots-task: + description: Prefix of the app's mise tasks that render the store screenshots (flutter-screenshots.yml); production releases then replace the store screenshots that differ. Empty for none. + type: string environment: description: Environment of the jobs that use signing or store credentials; empty for none. type: string @@ -455,10 +458,10 @@ jobs: google-play: name: Google Play - needs: [plan, publish, promote] + needs: [plan, publish, promote, screenshots] if: >- ${{ !cancelled() && inputs.android-package != '' && vars.GOOGLE_PLAY_SERVICE_ACCOUNT != '' - && needs.plan.outputs.android == 'true' + && needs.plan.outputs.android == 'true' && needs.screenshots.result != 'failure' && (needs.publish.result == 'success' || needs.promote.result == 'success') }} runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -500,6 +503,11 @@ jobs: PREFIX: ${{ inputs.artifact-prefix }} TAG: ${{ needs.plan.outputs.tag }} run: gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "${PREFIX}-${TAG}.aab" --dir dist + - if: ${{ needs.screenshots.result == 'success' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: screenshots-android + path: screenshots - id: auth uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: @@ -521,6 +529,7 @@ jobs: TAG: ${{ needs.plan.outputs.tag }} VERSION_CODE: ${{ needs.plan.outputs.build-number }} NOTES_LANGUAGE: ${{ inputs.play-notes-language }} + SCREENSHOTS_DIR: ${{ needs.screenshots.result == 'success' && 'screenshots' || '' }} run: python3 .infra/scripts/google_play.py ios-archive: @@ -691,10 +700,10 @@ jobs: # Gives a pre-release's TestFlight build to its testers; submits a release or # a promoted pre-release for App Review. name: App Store Connect - needs: [plan, ios-upload, promote] + needs: [plan, ios-upload, promote, screenshots] if: >- ${{ !cancelled() && inputs.bundle-id != '' && vars.APP_STORE_CONNECT_KEY_ID != '' - && needs.plan.outputs.ios == 'true' + && needs.plan.outputs.ios == 'true' && needs.screenshots.result != 'failure' && (needs.ios-upload.result == 'success' || needs.promote.result == 'success') }} runs-on: ubuntu-24.04 # Waits for App Store Connect to process a new upload. @@ -727,6 +736,11 @@ jobs: path: .infra persist-credentials: false sparse-checkout: scripts + - if: ${{ needs.screenshots.result == 'success' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: screenshots-ios + path: screenshots - name: Finish the upload env: KEY_ID: ${{ vars.APP_STORE_CONNECT_KEY_ID }} @@ -740,6 +754,7 @@ jobs: VERSION: ${{ needs.plan.outputs.version }} BUILD_NUMBER: ${{ needs.plan.outputs.build-number }} USES_NON_EXEMPT_ENCRYPTION: ${{ vars.APP_STORE_USES_NON_EXEMPT_ENCRYPTION }} + SCREENSHOTS_DIR: ${{ needs.screenshots.result == 'success' && 'screenshots' || '' }} run: | KEY_PATH="$RUNNER_TEMP/AuthKey_${KEY_ID}.p8" export KEY_PATH @@ -748,6 +763,24 @@ jobs: unset KEY # the script only needs the file python3 .infra/scripts/app_store_submit.py + screenshots: + # Rendered from the tag for the store listings, which go with production. + needs: plan + if: >- + ${{ inputs.screenshots-task != '' && needs.plan.outputs.channel == 'prod' + && needs.plan.outputs.mode != 'skip' }} + permissions: + contents: read # clone the repository + uses: $/.github/workflows/flutter-screenshots.yml + with: + app-dir: ${{ inputs.app-dir }} + task: ${{ inputs.screenshots-task }} + platforms: >- + ${{ needs.plan.outputs.android != 'true' && 'ios' + || needs.plan.outputs.ios != 'true' && 'android' || 'all' }} + mise-version: ${{ inputs.mise-version }} + macos-runner: ${{ inputs.macos-runner }} + pages: name: GitHub Pages needs: [plan, publish, promote] diff --git a/.github/workflows/flutter-screenshots.yml b/.github/workflows/flutter-screenshots.yml new file mode 100644 index 0000000..437b344 --- /dev/null +++ b/.github/workflows/flutter-screenshots.yml @@ -0,0 +1,69 @@ +name: Flutter store screenshots + +# Reusable: runs the app's mise tasks :android (on Linux, with Noto Color +# Emoji) and :ios (on macOS, with the system fonts), which render +# /build/screenshots////*.png; uploads +# them as the artifacts screenshots-android and screenshots-ios. + +on: + workflow_call: + inputs: + app-dir: + description: Directory of the Flutter app, relative to the repository root. + type: string + default: . + task: + description: Prefix of the app's mise tasks that render the screenshots. + type: string + default: screenshots + platforms: + description: "`all` (the default), `android` or `ios`." + type: string + default: all + mise-version: + description: The mise version to install; empty (the default) installs the newest release. + type: string + macos-runner: + description: Runner label of the iOS job. + type: string + default: macos-26 + +permissions: {} + +jobs: + render: + name: Render (${{ matrix.platform }}) + strategy: + matrix: + platform: [android, ios] + exclude: + - platform: ${{ inputs.platforms == 'android' && 'ios' || inputs.platforms == 'ios' && 'android' || 'none' }} + runs-on: ${{ matrix.platform == 'ios' && inputs.macos-runner || 'ubuntu-24.04' }} + timeout-minutes: 30 + permissions: + contents: read # clone the repository + env: + MISE_ENABLE_TOOLS: flutter + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - if: ${{ matrix.platform == 'android' }} + name: Install the Android emoji font + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq fonts-noto-color-emoji + - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + with: + version: ${{ inputs.mise-version }} + cache: false + - name: Render + env: + TASK: ${{ inputs.task }}:${{ matrix.platform }} + run: mise run "$TASK" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: screenshots-${{ matrix.platform }} + path: ${{ inputs.app-dir }}/build/screenshots/${{ matrix.platform }}/ + if-no-files-found: error + retention-days: 7 diff --git a/README.md b/README.md index d25a034..c978bd5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Infrastructure of SilverNETGroupSGGW: | --- | --- | | [`flutter-ci.yml`](.github/workflows/flutter-ci.yml) | Lints and tests the app, checks generated code, builds the release APKs and the web app, optionally the iOS app | | [`flutter-release.yml`](.github/workflows/flutter-release.yml) | Builds a GitHub release, attaches the builds to it and publishes them to the stores and Pages; promotes a tested beta without rebuilding it | +| [`flutter-screenshots.yml`](.github/workflows/flutter-screenshots.yml) | Renders the store screenshots with the app's mise tasks: Android on Linux, iOS on macOS | | [`flutter-pages.yml`](.github/workflows/flutter-pages.yml) | Serves the newest release's web build at the site root and the newest build of any kind, usually a beta, under `/beta/` | Each app keeps short caller workflows and implements a few `mise` tasks (the @@ -153,6 +154,7 @@ workflow only gets what it uses. | `publish-track`, `publish-platforms` | | Manual runs: where to publish the release's builds (below) | | `play-notes-language` | `en-US` | Language of the Google Play release notes; one of the store listing's languages | | `pages` | `false` | Deploy the web builds to GitHub Pages once Pages is enabled for the repository | +| `screenshots-task` | | Prefix of the mise tasks that render the store screenshots (below); empty for none | | `environment` | | Environment of the jobs that use signing or store credentials, e.g. one with required reviewers | | `build-tools` | `flutter,java` | mise tools the Android and web builds install | | `macos-runner`, `xcode-version`, `mise-version` | `macos-26`, `26.6`, newest release | | @@ -219,6 +221,15 @@ them. public or private TestFlight beta for the open or closed testing track, the internal testers for `internal`; other tracks skip iOS. `platforms` limits it to `android` or `ios`. Pages is not changed. +- **Screenshots:** with `screenshots-task`, production releases and + promotions run the app's `:android` (Linux, with Noto Color Emoji) + and `:ios` (macOS, with the San Francisco font) mise tasks, which + write `/build/screenshots////*.png`. + Every listing language (`en-US`, or else `en`) gets its folder, in file + name order, where it differs from the store: Google Play's listing for the + devices `phone`, `tablet-7` and `tablet-10`, and the App Store version for + `iphone-6.9` (the required large iPhone size, 1320×2868) and `ipad-13` + (2064×2752). Testing tracks keep the listing. - **Release notes:** the release description becomes the store release notes (Google Play: up to 500 characters; App Store: "What's New" in every language). diff --git a/scripts/app_store_submit.py b/scripts/app_store_submit.py index d066baa..304f306 100644 --- a/scripts/app_store_submit.py +++ b/scripts/app_store_submit.py @@ -2,6 +2,7 @@ Run by flutter-release.yml with its settings in environment variables.""" import base64 +import hashlib import json import os import subprocess @@ -11,6 +12,7 @@ import urllib.parse import urllib.request from datetime import datetime +from pathlib import Path API = os.environ.get("ASC_API", "https://api.appstoreconnect.apple.com") EDITABLE = { @@ -41,6 +43,12 @@ PUBLIC_GROUP = "Public beta" # Testers invited by e-mail in App Store Connect. PRIVATE_GROUP = "Private beta" +# Screenshot folders (SCREENSHOTS_DIR///*.png) by display +# type; the API files the 6.9" iPhone screenshots under APP_IPHONE_67. +SCREENSHOT_TYPES = { + "iphone-6.9": "APP_IPHONE_67", + "ipad-13": "APP_IPAD_PRO_3GEN_129", +} def b64url(data): @@ -509,6 +517,113 @@ def open_submission(app_id): return submissions[0]["id"], versions +def screenshot_folder(device, locale): + """The folder for a locale: en-US, else en; None if neither.""" + root = Path(os.environ["SCREENSHOTS_DIR"]) / device + for name in (locale, locale.split("-")[0]): + if (root / name).is_dir(): + return root / name + return None + + +def upload_screenshot(set_id, file): + """Uploads a screenshot to the end of the set and returns its ID.""" + data = file.read_bytes() + screenshot = call("POST", "/v1/appScreenshots", body={"data": { + "type": "appScreenshots", + "attributes": {"fileName": file.name, "fileSize": len(data)}, + "relationships": {"appScreenshotSet": {"data": { + "type": "appScreenshotSets", "id": set_id, + }}}, + }})["data"] + for operation in screenshot["attributes"]["uploadOperations"]: + start = operation["offset"] + request = urllib.request.Request( + operation["url"], + data=data[start : start + operation["length"]], + method=operation["method"], + headers={h["name"]: h["value"] for h in operation["requestHeaders"] or []}, + ) + try: + with urllib.request.urlopen(request, timeout=300): + pass + except urllib.error.HTTPError as error: + raise ApiError(error.code, error.read().decode(errors="replace")) from None + call("PATCH", f"/v1/appScreenshots/{screenshot['id']}", body={"data": { + "type": "appScreenshots", + "id": screenshot["id"], + "attributes": { + "uploaded": True, + "sourceFileChecksum": hashlib.md5(data).hexdigest(), + }, + }}) + return screenshot["id"] + + +def wait_for_screenshots(screenshot_ids): + """Waits up to 10 minutes for App Store Connect to process the uploads.""" + for screenshot_id in screenshot_ids: + for _ in range(40): + state = call("GET", f"/v1/appScreenshots/{screenshot_id}", { + "fields[appScreenshots]": "fileName,assetDeliveryState", + })["data"]["attributes"] + delivery = state["assetDeliveryState"] or {} + if delivery.get("state") == "COMPLETE": + break + if delivery.get("state") == "FAILED": + sys.exit(f"Screenshot {state['fileName']} failed: {delivery.get('errors')}") + time.sleep(15) + else: + sys.exit(f"Screenshot {state['fileName']} was not processed in 10 minutes.") + + +def update_screenshots(version_id): + """Replaces the version's screenshot sets that differ from SCREENSHOTS_DIR.""" + localizations = call( + "GET", + f"/v1/appStoreVersions/{version_id}/appStoreVersionLocalizations", + {"fields[appStoreVersionLocalizations]": "locale", "limit": "200"}, + )["data"] + uploaded = [] + for localization in sorted(localizations, key=lambda l: l["attributes"]["locale"]): + locale = localization["attributes"]["locale"] + sets = call( + "GET", + f"/v1/appStoreVersionLocalizations/{localization['id']}/appScreenshotSets", + {"fields[appScreenshotSets]": "screenshotDisplayType", "limit": "50"}, + )["data"] + for device, display_type in SCREENSHOT_TYPES.items(): + folder = screenshot_folder(device, locale) + files = sorted(folder.glob("*.png")) if folder else [] + if not files: + continue + checksums = [hashlib.md5(file.read_bytes()).hexdigest() for file in files] + screenshot_set = next(( + s for s in sets + if s["attributes"]["screenshotDisplayType"] == display_type + ), None) + if screenshot_set: + current = call( + "GET", f"/v1/appScreenshotSets/{screenshot_set['id']}/appScreenshots", + {"fields[appScreenshots]": "sourceFileChecksum", "limit": "10"}, + )["data"] + if [c["attributes"]["sourceFileChecksum"] for c in current] == checksums: + continue + for screenshot in current: + call("DELETE", f"/v1/appScreenshots/{screenshot['id']}") + else: + screenshot_set = call("POST", "/v1/appScreenshotSets", body={"data": { + "type": "appScreenshotSets", + "attributes": {"screenshotDisplayType": display_type}, + "relationships": {"appStoreVersionLocalization": {"data": { + "type": "appStoreVersionLocalizations", "id": localization["id"], + }}}, + }})["data"] + uploaded += [upload_screenshot(screenshot_set["id"], f) for f in files] + print(f"Replaced the {locale} {display_type} screenshots with {folder}.") + wait_for_screenshots(uploaded) + + def main(): bundle_id = os.environ["BUNDLE_ID"] version = os.environ["VERSION"] @@ -601,6 +716,9 @@ def main(): "attributes": {"whatsNew": notes}, }}) + if os.environ.get("SCREENSHOTS_DIR"): + update_screenshots(version_id) + if submission_id is None: submission_id = call("POST", "/v1/reviewSubmissions", body={"data": { "type": "reviewSubmissions", diff --git a/scripts/google_play.py b/scripts/google_play.py index 39a7c19..4802294 100644 --- a/scripts/google_play.py +++ b/scripts/google_play.py @@ -8,6 +8,7 @@ import sys import urllib.error import urllib.request +from pathlib import Path ROOT = os.environ.get("PLAY_API", "https://androidpublisher.googleapis.com") PACKAGE = os.environ["PACKAGE_NAME"] @@ -17,6 +18,12 @@ NOTES_LIMIT = 500 TRACK = os.environ["TRACK"] BETA_TRACK = os.environ["BETA_TRACK"] +# Screenshot folders (SCREENSHOTS_DIR///*.png) by image type. +SCREENSHOT_TYPES = { + "phone": "phoneScreenshots", + "tablet-7": "sevenInchScreenshots", + "tablet-10": "tenInchScreenshots", +} class ApiError(Exception): @@ -109,6 +116,39 @@ def upload(edit, version_code): ) +def screenshot_folder(device, language): + """The folder for a listing language: en-US, else en; None if neither.""" + root = Path(os.environ["SCREENSHOTS_DIR"]) / device + for name in (language, language.split("-")[0]): + if (root / name).is_dir(): + return root / name + return None + + +def update_screenshots(edit): + """Replaces the listings' screenshots that differ from SCREENSHOTS_DIR.""" + listings = call("GET", f"{API}/edits/{edit}/listings").get("listings", []) + for language in sorted(listing["language"] for listing in listings): + for device, image_type in SCREENSHOT_TYPES.items(): + folder = screenshot_folder(device, language) + files = sorted(folder.glob("*.png")) if folder else [] + if not files: + continue + images = [file.read_bytes() for file in files] + url = f"{API}/edits/{edit}/listings/{language}/{image_type}" + current = call("GET", url).get("images", []) + if [image.get("sha256") for image in current] == [ + hashlib.sha256(image).hexdigest() for image in images + ]: + continue + call("DELETE", url) + for image in images: + call("POST", f"{UPLOAD_API}/edits/{edit}/listings/{language}/" + f"{image_type}?uploadType=media", data=image, + content_type="image/png") + print(f"Replaced the {language} {image_type} with {folder}.") + + def main(): mode = os.environ["MODE"] tag = os.environ["TAG"] @@ -148,6 +188,9 @@ def main(): {"language": os.environ["NOTES_LANGUAGE"], "text": notes} ] check_no_staged_rollout(edit, track) + # The listing goes with production; testers see the same one. + if track == "production" and os.environ.get("SCREENSHOTS_DIR"): + update_screenshots(edit) call( "PUT", f"{API}/edits/{edit}/tracks/{track}", diff --git a/tests/specs/app-store-connect-api-4.5.json b/tests/specs/app-store-connect-api-4.5.json index 2e3013b..deccb0f 100644 --- a/tests/specs/app-store-connect-api-4.5.json +++ b/tests/specs/app-store-connect-api-4.5.json @@ -1,6 +1,229 @@ { "components": { "schemas": { + "AppScreenshotCreateRequest": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "fileName": { + "type": "string" + }, + "fileSize": { + "type": "integer" + } + }, + "required": [ + "fileName", + "fileSize" + ], + "type": "object" + }, + "relationships": { + "properties": { + "appScreenshotSet": { + "properties": { + "data": { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "appScreenshotSets" + ], + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ], + "type": "object" + } + }, + "required": [ + "appScreenshotSet" + ], + "type": "object" + }, + "type": { + "enum": [ + "appScreenshots" + ], + "type": "string" + } + }, + "required": [ + "relationships", + "attributes", + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ], + "title": "AppScreenshotCreateRequest", + "type": "object" + }, + "AppScreenshotSetCreateRequest": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "screenshotDisplayType": { + "$ref": "#/components/schemas/ScreenshotDisplayType" + } + }, + "required": [ + "screenshotDisplayType" + ], + "type": "object" + }, + "relationships": { + "properties": { + "appCustomProductPageLocalization": { + "properties": { + "data": { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "appCustomProductPageLocalizations" + ], + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + } + }, + "type": "object" + }, + "appStoreVersionExperimentTreatmentLocalization": { + "properties": { + "data": { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "appStoreVersionExperimentTreatmentLocalizations" + ], + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + } + }, + "type": "object" + }, + "appStoreVersionLocalization": { + "properties": { + "data": { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "appStoreVersionLocalizations" + ], + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": { + "enum": [ + "appScreenshotSets" + ], + "type": "string" + } + }, + "required": [ + "attributes", + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ], + "title": "AppScreenshotSetCreateRequest", + "type": "object" + }, + "AppScreenshotUpdateRequest": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "sourceFileChecksum": { + "nullable": true, + "type": "string" + }, + "uploaded": { + "nullable": true, + "type": "boolean" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "appScreenshots" + ], + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ], + "title": "AppScreenshotUpdateRequest", + "type": "object" + }, "AppStoreVersionBuildLinkageRequest": { "properties": { "data": { @@ -1573,6 +1796,44 @@ ], "title": "ReviewSubmissionUpdateRequest", "type": "object" + }, + "ScreenshotDisplayType": { + "enum": [ + "APP_IPHONE_67", + "APP_IPHONE_61", + "APP_IPHONE_65", + "APP_IPHONE_58", + "APP_IPHONE_55", + "APP_IPHONE_47", + "APP_IPHONE_40", + "APP_IPHONE_35", + "APP_IPAD_PRO_3GEN_129", + "APP_IPAD_PRO_3GEN_11", + "APP_IPAD_PRO_129", + "APP_IPAD_105", + "APP_IPAD_97", + "APP_DESKTOP", + "APP_WATCH_ULTRA", + "APP_WATCH_SERIES_10", + "APP_WATCH_SERIES_7", + "APP_WATCH_SERIES_4", + "APP_WATCH_SERIES_3", + "APP_APPLE_TV", + "APP_APPLE_VISION_PRO", + "IMESSAGE_APP_IPHONE_67", + "IMESSAGE_APP_IPHONE_61", + "IMESSAGE_APP_IPHONE_65", + "IMESSAGE_APP_IPHONE_58", + "IMESSAGE_APP_IPHONE_55", + "IMESSAGE_APP_IPHONE_47", + "IMESSAGE_APP_IPHONE_40", + "IMESSAGE_APP_IPAD_PRO_3GEN_129", + "IMESSAGE_APP_IPAD_PRO_3GEN_11", + "IMESSAGE_APP_IPAD_PRO_129", + "IMESSAGE_APP_IPAD_105", + "IMESSAGE_APP_IPAD_97" + ], + "type": "string" } } }, @@ -1585,22 +1846,448 @@ }, "openapi": "3.0.1", "paths": { - "/v1/appStoreVersionLocalizations/{id}": { - "patch": { - "operationId": "appStoreVersionLocalizations_updateInstance", + "/v1/appScreenshotSets": { + "post": { + "operationId": "appScreenshotSets_createInstance", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppStoreVersionLocalizationUpdateRequest" + "$ref": "#/components/schemas/AppScreenshotSetCreateRequest" } } }, - "description": "AppStoreVersionLocalization representation", + "description": "AppScreenshotSet representation", "required": true } } }, + "/v1/appScreenshotSets/{id}/appScreenshots": { + "get": { + "operationId": "appScreenshotSets_appScreenshots_getToManyRelated", + "parameters": [ + { + "description": "the fields to include for returned resources of type appScreenshots", + "explode": false, + "in": "query", + "name": "fields[appScreenshots]", + "schema": { + "items": { + "enum": [ + "fileSize", + "fileName", + "sourceFileChecksum", + "imageAsset", + "assetToken", + "assetType", + "uploadOperations", + "assetDeliveryState", + "appScreenshotSet" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "the fields to include for returned resources of type appScreenshotSets", + "explode": false, + "in": "query", + "name": "fields[appScreenshotSets]", + "schema": { + "items": { + "enum": [ + "screenshotDisplayType", + "appStoreVersionLocalization", + "appCustomProductPageLocalization", + "appStoreVersionExperimentTreatmentLocalization", + "appScreenshots" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "maximum resources per page", + "in": "query", + "name": "limit", + "schema": { + "maximum": 200, + "type": "integer" + }, + "style": "form" + }, + { + "description": "comma-separated list of relationships to include", + "explode": false, + "in": "query", + "name": "include", + "schema": { + "items": { + "enum": [ + "appScreenshotSet" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + } + ] + } + }, + "/v1/appScreenshots": { + "post": { + "operationId": "appScreenshots_createInstance", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppScreenshotCreateRequest" + } + } + }, + "description": "AppScreenshot representation", + "required": true + } + } + }, + "/v1/appScreenshots/{id}": { + "delete": { + "operationId": "appScreenshots_deleteInstance" + }, + "get": { + "operationId": "appScreenshots_getInstance", + "parameters": [ + { + "description": "the fields to include for returned resources of type appScreenshots", + "explode": false, + "in": "query", + "name": "fields[appScreenshots]", + "required": false, + "schema": { + "items": { + "enum": [ + "fileSize", + "fileName", + "sourceFileChecksum", + "imageAsset", + "assetToken", + "assetType", + "uploadOperations", + "assetDeliveryState", + "appScreenshotSet" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "the fields to include for returned resources of type appScreenshotSets", + "explode": false, + "in": "query", + "name": "fields[appScreenshotSets]", + "required": false, + "schema": { + "items": { + "enum": [ + "screenshotDisplayType", + "appStoreVersionLocalization", + "appCustomProductPageLocalization", + "appStoreVersionExperimentTreatmentLocalization", + "appScreenshots" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "comma-separated list of relationships to include", + "explode": false, + "in": "query", + "name": "include", + "required": false, + "schema": { + "items": { + "enum": [ + "appScreenshotSet" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + } + ] + }, + "patch": { + "operationId": "appScreenshots_updateInstance", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppScreenshotUpdateRequest" + } + } + }, + "description": "AppScreenshot representation", + "required": true + } + } + }, + "/v1/appStoreVersionLocalizations/{id}": { + "patch": { + "operationId": "appStoreVersionLocalizations_updateInstance", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppStoreVersionLocalizationUpdateRequest" + } + } + }, + "description": "AppStoreVersionLocalization representation", + "required": true + } + } + }, + "/v1/appStoreVersionLocalizations/{id}/appScreenshotSets": { + "get": { + "operationId": "appStoreVersionLocalizations_appScreenshotSets_getToManyRelated", + "parameters": [ + { + "description": "filter by attribute 'screenshotDisplayType'", + "explode": false, + "in": "query", + "name": "filter[screenshotDisplayType]", + "schema": { + "items": { + "enum": [ + "APP_IPHONE_67", + "APP_IPHONE_61", + "APP_IPHONE_65", + "APP_IPHONE_58", + "APP_IPHONE_55", + "APP_IPHONE_47", + "APP_IPHONE_40", + "APP_IPHONE_35", + "APP_IPAD_PRO_3GEN_129", + "APP_IPAD_PRO_3GEN_11", + "APP_IPAD_PRO_129", + "APP_IPAD_105", + "APP_IPAD_97", + "APP_DESKTOP", + "APP_WATCH_ULTRA", + "APP_WATCH_SERIES_10", + "APP_WATCH_SERIES_7", + "APP_WATCH_SERIES_4", + "APP_WATCH_SERIES_3", + "APP_APPLE_TV", + "APP_APPLE_VISION_PRO", + "IMESSAGE_APP_IPHONE_67", + "IMESSAGE_APP_IPHONE_61", + "IMESSAGE_APP_IPHONE_65", + "IMESSAGE_APP_IPHONE_58", + "IMESSAGE_APP_IPHONE_55", + "IMESSAGE_APP_IPHONE_47", + "IMESSAGE_APP_IPHONE_40", + "IMESSAGE_APP_IPAD_PRO_3GEN_129", + "IMESSAGE_APP_IPAD_PRO_3GEN_11", + "IMESSAGE_APP_IPAD_PRO_129", + "IMESSAGE_APP_IPAD_105", + "IMESSAGE_APP_IPAD_97" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "filter by id(s) of related 'appCustomProductPageLocalization'", + "explode": false, + "in": "query", + "name": "filter[appCustomProductPageLocalization]", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "filter by id(s) of related 'appStoreVersionExperimentTreatmentLocalization'", + "explode": false, + "in": "query", + "name": "filter[appStoreVersionExperimentTreatmentLocalization]", + "schema": { + "items": { + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "the fields to include for returned resources of type appScreenshotSets", + "explode": false, + "in": "query", + "name": "fields[appScreenshotSets]", + "schema": { + "items": { + "enum": [ + "screenshotDisplayType", + "appStoreVersionLocalization", + "appCustomProductPageLocalization", + "appStoreVersionExperimentTreatmentLocalization", + "appScreenshots" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "the fields to include for returned resources of type appStoreVersionLocalizations", + "explode": false, + "in": "query", + "name": "fields[appStoreVersionLocalizations]", + "schema": { + "items": { + "enum": [ + "description", + "locale", + "keywords", + "marketingUrl", + "promotionalText", + "supportUrl", + "whatsNew", + "appStoreVersion", + "appScreenshotSets", + "appPreviewSets", + "searchKeywords" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "the fields to include for returned resources of type appCustomProductPageLocalizations", + "explode": false, + "in": "query", + "name": "fields[appCustomProductPageLocalizations]", + "schema": { + "items": { + "enum": [ + "locale", + "promotionalText", + "appCustomProductPageVersion", + "appScreenshotSets", + "appPreviewSets", + "searchKeywords" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "the fields to include for returned resources of type appStoreVersionExperimentTreatmentLocalizations", + "explode": false, + "in": "query", + "name": "fields[appStoreVersionExperimentTreatmentLocalizations]", + "schema": { + "items": { + "enum": [ + "locale", + "appStoreVersionExperimentTreatment", + "appScreenshotSets", + "appPreviewSets" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "the fields to include for returned resources of type appScreenshots", + "explode": false, + "in": "query", + "name": "fields[appScreenshots]", + "schema": { + "items": { + "enum": [ + "fileSize", + "fileName", + "sourceFileChecksum", + "imageAsset", + "assetToken", + "assetType", + "uploadOperations", + "assetDeliveryState", + "appScreenshotSet" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "maximum resources per page", + "in": "query", + "name": "limit", + "schema": { + "maximum": 200, + "type": "integer" + }, + "style": "form" + }, + { + "description": "comma-separated list of relationships to include", + "explode": false, + "in": "query", + "name": "include", + "schema": { + "items": { + "enum": [ + "appStoreVersionLocalization", + "appCustomProductPageLocalization", + "appStoreVersionExperimentTreatmentLocalization", + "appScreenshots" + ], + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "maximum number of related appScreenshots returned (when they are included)", + "in": "query", + "name": "limit[appScreenshots]", + "schema": { + "maximum": 50, + "type": "integer" + }, + "style": "form" + } + ] + } + }, "/v1/appStoreVersions": { "post": { "operationId": "appStoreVersions_createInstance", diff --git a/tests/support.py b/tests/support.py index 5eebc4e..7525c37 100644 --- a/tests/support.py +++ b/tests/support.py @@ -50,7 +50,7 @@ def handle_request(self): if body is not None: self.wfile.write(json.dumps(body).encode()) - do_GET = do_POST = do_PUT = do_PATCH = handle_request + do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = handle_request self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) self.url = f"http://127.0.0.1:{self.server.server_port}" diff --git a/tests/test_app_store_submit.py b/tests/test_app_store_submit.py index 719eae1..69de88f 100644 --- a/tests/test_app_store_submit.py +++ b/tests/test_app_store_submit.py @@ -2,6 +2,7 @@ import base64 import copy +import hashlib import json import os import re @@ -68,6 +69,9 @@ def __init__(self, build=True, encryption=None, versions=None, submissions=None, self.group_builds = {} self.auto_notify = False self.beta_submissions = [] + # Screenshot sets: display type -> [(id, contents, checksum)]. + self.screenshot_sets = {} + self.uploads = {} self.spec = AppStoreSpec() def version(self, version_id): @@ -181,7 +185,68 @@ def testflight(self, method, path, query, body): {"betaReviewState": "WAITING_FOR_REVIEW"})} return None + def screenshots(self, method, path, body, request): + """Answers the screenshot requests, or returns None for the others.""" + if path == "/v1/appStoreVersionLocalizations/l1/appScreenshotSets": + return 200, {"data": [ + resource("appScreenshotSets", kind, {"screenshotDisplayType": kind}) + for kind in self.screenshot_sets + ]} + if path == "/v1/appScreenshotSets": + kind = body["data"]["attributes"]["screenshotDisplayType"] + assert body["data"]["relationships"]["appStoreVersionLocalization"][ + "data"]["id"] == "l1" + self.screenshot_sets[kind] = [] + return 201, {"data": resource("appScreenshotSets", kind, {})} + match = re.match(r"^/v1/appScreenshotSets/([^/]+)/appScreenshots$", path) + if match: + return 200, {"data": [ + resource("appScreenshots", shot_id, {"sourceFileChecksum": checksum}) + for shot_id, _, checksum in self.screenshot_sets[match.group(1)] + ]} + if path == "/v1/appScreenshots": + attributes = body["data"]["attributes"] + kind = body["data"]["relationships"]["appScreenshotSet"]["data"]["id"] + shot_id = f"s{len(self.uploads)}" + self.uploads[shot_id] = {"kind": kind, "name": attributes["fileName"], + "size": attributes["fileSize"], "data": b""} + host = request.headers["Host"] + return 201, {"data": resource("appScreenshots", shot_id, { + "uploadOperations": [{ + "method": "PUT", "url": f"http://{host}/upload/{shot_id}", + "offset": 0, "length": attributes["fileSize"], + "requestHeaders": [{"name": "Content-Type", "value": "image/png"}], + }], + })} + match = re.match(r"^/v1/appScreenshots/([^/]+)$", path) + if match: + shot_id = match.group(1) + if method == "DELETE": + for shots in self.screenshot_sets.values(): + shots[:] = [shot for shot in shots if shot[0] != shot_id] + return 204, None + upload = self.uploads[shot_id] + if method == "PATCH": + attributes = body["data"]["attributes"] + assert attributes["uploaded"] is True + assert len(upload["data"]) == upload["size"] + self.screenshot_sets[upload["kind"]].append( + (shot_id, upload["data"], attributes["sourceFileChecksum"])) + return 200, {"data": resource("appScreenshots", shot_id, {})} + return 200, {"data": resource("appScreenshots", shot_id, { + "fileName": upload["name"], + "assetDeliveryState": {"state": "COMPLETE", "errors": []}, + })} + return None + def __call__(self, request): + upload = re.match(r"^/upload/([^/]+)$", request.path) + if upload: + # Apple's upload hosts take the parts without the API token. + assert "Authorization" not in request.headers + assert request.method == "PUT" + self.uploads[upload.group(1)]["data"] += request.raw + return 200, None header, _, _ = request.headers["Authorization"].removeprefix("Bearer ").split(".") header = json.loads(base64.urlsafe_b64decode(header + "==")) assert header == {"alg": "ES256", "kid": "KEY123", "typ": "JWT"}, header @@ -286,7 +351,9 @@ def __call__(self, request): self.versions[version_id]["state"] = "WAITING_FOR_REVIEW" return 200, {"data": {"type": "reviewSubmissions", "id": match.group(1), "attributes": {"state": "WAITING_FOR_REVIEW"}}} - answer = self.testflight(method, path, request.query, body) + answer = self.screenshots(method, path, body, request) + if answer is None: + answer = self.testflight(method, path, request.query, body) if answer is None: raise AssertionError(f"unexpected request {request}") return answer @@ -645,6 +712,49 @@ def test_unreadable_key_fails_with_the_openssl_error(self): self.assertIn("openssl dgst", result.stderr) self.assertEqual(api.requests, []) + def screenshot_folder(self, files): + """A screenshots folder with {"//": contents}.""" + root = self.dir / "screenshots" + for name, contents in files.items(): + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_bytes(contents) + return str(root) + + def test_release_replaces_the_screenshots_that_differ(self): + root = self.screenshot_folder({ + "iphone-6.9/pl/01-schedule.png": b"iphone 1", + "iphone-6.9/pl/02-map.png": b"iphone 2", + "ipad-13/pl/01-schedule.png": b"ipad 1", + "iphone-6.9/en/01-schedule.png": b"english", + }) + mock = AppStoreMock() + old = hashlib.md5(b"old").hexdigest() + mock.screenshot_sets["APP_IPHONE_67"] = [("s-old", b"old", old)] + result, api = self.submit(mock, "build", "prod", notes="Poprawki", + SCREENSHOTS_DIR=root) + self.assertEqual(result.returncode, 0, result.stderr) + sets = {kind: [shot[1] for shot in shots] + for kind, shots in mock.screenshot_sets.items()} + # The version's only language is pl, so the English ones stay out. + self.assertEqual(sets, {"APP_IPHONE_67": [b"iphone 1", b"iphone 2"], + "APP_IPAD_PRO_3GEN_129": [b"ipad 1"]}) + self.assertIn("DELETE /v1/appScreenshots/s-old", api.calls()) + # Before the version goes to App Review. + calls = api.calls() + self.assertLess(calls.index("POST /v1/appScreenshots"), + calls.index("POST /v1/reviewSubmissionItems")) + + def test_release_keeps_screenshots_that_match(self): + root = self.screenshot_folder({"iphone-6.9/pl/01-schedule.png": b"same"}) + mock = AppStoreMock() + same = hashlib.md5(b"same").hexdigest() + mock.screenshot_sets["APP_IPHONE_67"] = [("s-same", b"same", same)] + result, api = self.submit(mock, "build", "prod", notes="Poprawki", + SCREENSHOTS_DIR=root) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("POST /v1/appScreenshots", api.calls()) + self.assertNotIn("DELETE /v1/appScreenshots/s-same", api.calls()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_google_play.py b/tests/test_google_play.py index 0f9349e..4e13e64 100644 --- a/tests/test_google_play.py +++ b/tests/test_google_play.py @@ -2,6 +2,7 @@ import hashlib import os +import re import tempfile import unittest from pathlib import Path @@ -12,6 +13,7 @@ EDITS = f"/androidpublisher/v3/applications/{PACKAGE}/edits" UPLOADS = f"/upload/androidpublisher/v3/applications/{PACKAGE}/edits" BUNDLE = b"PK\x03\x04 an App Bundle" +IMAGE_TYPES = ("phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots") BETA_TRACK = { "track": "beta", "releases": [{ @@ -26,11 +28,14 @@ class PlayMock: """Answers like the Play Developer API for a given state of the app.""" - def __init__(self, bundles=None, tracks=None, used=False, manual_review=False): + def __init__(self, bundles=None, tracks=None, used=False, manual_review=False, + images=None): self.bundles = bundles or {} # version code -> sha256 self.tracks = tracks or {} self.used = used self.manual_review = manual_review + # Store listing images: language -> image type -> contents. + self.images = images or {} self.spec = PlaySpec() self.puts = [] @@ -63,6 +68,28 @@ def __call__(self, request): assert body["track"] == track self.puts.append(body) return 200, body + if request.method == "GET" and path == f"{EDITS}/e1/listings": + return 200, {"listings": [ + {"language": language, "title": "App"} for language in self.images + ]} + listing = re.match(r"^(.*)/e1/listings/([^/]+)/([^/]+)$", path) + if listing: + root, language, image_type = listing.groups() + assert image_type in IMAGE_TYPES, image_type + images = self.images[language].setdefault(image_type, []) + if request.method == "GET" and root == EDITS: + return 200, {"images": [ + {"id": str(index), "sha256": hashlib.sha256(image).hexdigest()} + for index, image in enumerate(images) + ]} if images else {} + if request.method == "DELETE" and root == EDITS: + images.clear() + return 200, {} + if request.method == "POST" and root == UPLOADS: + assert query == {"uploadType": "media"} + assert request.headers["Content-Type"] == "image/png" + images.append(request.raw) + return 200, {"image": {"id": str(len(images))}} if request.method == "POST" and path == f"{EDITS}/e1:commit": assert "changesInReviewBehavior" not in query assert request.raw == b"" @@ -275,3 +302,47 @@ def test_promote_names_the_release_after_the_version(self): if __name__ == "__main__": unittest.main() + + def screenshots(self, files): + """A screenshots folder with {"//": contents}.""" + root = self.dir / "screenshots" + for name, contents in files.items(): + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_bytes(contents) + return str(root) + + def test_production_replaces_the_screenshots_that_differ(self): + root = self.screenshots({ + "phone/en/01-schedule.png": b"en 1", + "phone/en/02-map.png": b"en 2", + "phone/pl/01-schedule.png": b"pl 1", + "tablet-10/pl/01-schedule.png": b"pl tablet", + }) + mock = PlayMock(tracks={"beta": BETA_TRACK}, images={ + "en-US": {"phoneScreenshots": [b"old"]}, + "pl-PL": {"phoneScreenshots": [b"pl 1"]}, + "de-DE": {"phoneScreenshots": [b"alt"]}, + }) + result, api = self.run_play( + mock, MODE="promote", TRACK="production", TAG="v4.3.0-beta.1", + SCREENSHOTS_DIR=root, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(mock.images["en-US"]["phoneScreenshots"], [b"en 1", b"en 2"]) + self.assertEqual(mock.images["pl-PL"]["tenInchScreenshots"], [b"pl tablet"]) + # Unchanged, or without screenshots of their language: left alone. + self.assertEqual(mock.images["pl-PL"]["phoneScreenshots"], [b"pl 1"]) + self.assertEqual(mock.images["de-DE"]["phoneScreenshots"], [b"alt"]) + self.assertNotIn(f"DELETE {EDITS}/e1/listings/pl-PL/phoneScreenshots", api.calls()) + # In the edit that releases the version. + self.assertEqual(api.calls()[-1], f"POST {EDITS}/e1:commit") + + def test_testing_tracks_keep_the_listing(self): + root = self.screenshots({"phone/en/01-schedule.png": b"en 1"}) + mock = PlayMock(images={"en-US": {"phoneScreenshots": [b"old"]}}) + result, api = self.run_play( + mock, MODE="build", TRACK="beta", TAG="v4.3.0-beta.1", SCREENSHOTS_DIR=root + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn(f"GET {EDITS}/e1/listings", api.calls()) + self.assertEqual(mock.images["en-US"]["phoneScreenshots"], [b"old"])