Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions .github/workflows/flutter-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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]
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/flutter-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Flutter store screenshots

# Reusable: runs the app's mise tasks <task>:android (on Linux, with Noto Color
# Emoji) and <task>:ios (on macOS, with the system fonts), which render
# <app-dir>/build/screenshots/<platform>/<device>/<language>/*.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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 | |
Expand Down Expand Up @@ -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 `<task>:android` (Linux, with Noto Color Emoji)
and `<task>:ios` (macOS, with the San Francisco font) mise tasks, which
write `<app-dir>/build/screenshots/<platform>/<device>/<language>/*.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).
Expand Down
118 changes: 118 additions & 0 deletions scripts/app_store_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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/<device>/<language>/*.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):
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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",
Expand Down
Loading