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
99 changes: 99 additions & 0 deletions .github/workflows/app-store-metadata.yml
Original file line number Diff line number Diff line change
@@ -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
215 changes: 215 additions & 0 deletions .github/workflows/app-store-screenshots.yml
Original file line number Diff line number Diff line change
@@ -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 = ('<h2>App Store screenshots</h2><ul>'
'<li><a href="final/en-US/">English composed screenshots</a></li>'
'<li><a href="final/ru/">Russian composed screenshots</a></li>'
'<li><a href="en/index.html">English screenshots and recording</a></li>'
'<li><a href="ru/index.html">Russian screenshots and recording</a></li></ul>')
report.write_text(report.read_text().replace('</html>', links + '</html>'))
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"
4 changes: 4 additions & 0 deletions GrowingUp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -225,6 +226,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
C51000000000000000000002 /* AppStoreScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreScreenshotTests.swift; sourceTree = "<group>"; };
CA9319E726711F9C5D5FB002 /* ImagesCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesCacheTests.swift; sourceTree = "<group>"; };
A5000000000000000000000E /* AgeWidgetContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgeWidgetContentView.swift; sourceTree = "<group>"; };
A50000000000000000000008 /* UITestWidgetPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestWidgetPreview.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -907,6 +909,7 @@
A4461E02291EB42F4B6BD048 /* GrowingUpUITests */ = {
isa = PBXGroup;
children = (
C51000000000000000000002 /* AppStoreScreenshotTests.swift */,
97ABEB737086B8C5A490A11D /* JourneyTests.swift */,
3564E9887F474D5D96B7CDEA /* BirthdayAndEditingTests.swift */,
A40000000000000000000002 /* RemovalConfirmationTests.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
15 changes: 15 additions & 0 deletions GrowingUp.xcodeproj/xcshareddata/xcschemes/GrowingUpUI.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@
value = "$(GROWINGUP_RECORD_SNAPSHOTS)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "GROWINGUP_APP_STORE_CAPTURE"
value = "$(GROWINGUP_APP_STORE_CAPTURE)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "GROWINGUP_APP_STORE_PHOTOS"
value = "$(GROWINGUP_APP_STORE_PHOTOS)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "GROWINGUP_APP_STORE_LOCALE"
value = "$(GROWINGUP_APP_STORE_LOCALE)"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</TestAction>
<LaunchAction
Expand Down
3 changes: 3 additions & 0 deletions GrowingUp/Presentation/PersonOverviewScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ struct PersonOverviewScene: View {
burstStarted = nil
}
.task(id: person.appPicId) { await presenter.load(person: person) }
.onDisappear {
presenter.tick(person: person, now: now(), isSelected: false)
}
.task(id: isSelected ? person.birthday : nil) {
while !Task.isCancelled {
presenter.tick(person: person, now: now(), isSelected: isSelected)
Expand Down
13 changes: 12 additions & 1 deletion GrowingUp/Testing/AppComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,18 @@ enum AppComposition {
return SceneConfigurator(
gateway: gateway, loadImage: images.load,
now: { clockDate.addingTimeInterval(clockRuns ? Date().timeIntervalSince(clockStarted) : 0) },
photoFixtures: (0..<18).map(fixture))
photoFixtures: try photoFixtures(environment: environment))
}

private static func photoFixtures(environment: [String: String]) throws -> [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 {
Expand Down
Loading
Loading