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
91 changes: 83 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,19 @@ jobs:
# Beside the build rather than behind it: it signs nothing and uploads nothing,
# it just drives simulators, and it takes about as long. On a dry run too -
# the artifact is the only way to look at the pictures before the store does.
# A runner per device rather than one doing both. Two simulators on the seven
# gigabytes a runner has spent their time swapping, and the runs took five
# times as long as they do alone; a device to a machine is that, structurally.
# It halves the wall clock as well, since the two halves photograph at once.
screenshots:
runs-on: macos-26
# long by nature, but the default is six hours for a wedged simulator to sit in
timeout-minutes: 180
strategy:
# one device failing should not throw away the other's hour of work
fail-fast: false
matrix:
device: [iphone, ipad]
steps:
- name: checkout
uses: actions/checkout@v7
Expand All @@ -205,16 +216,28 @@ jobs:
python-version: "3.13"
- run: python3 -m pip install --quiet Pillow

# the lane checks the set it produced, so a language that came out short
# fails here rather than half way up to App Store Connect
- name: photograph both devices in every locale
# The lane builds once into build/screenshots and photographs eighteen
# times without building again. Keeping it means a rerun starts at the
# first simulator instead of at the compiler. Keyed on everything the
# build reads, so a stale one is never used - only ever missed.
- name: reuse the last build
uses: actions/cache@v4
with:
path: build/screenshots
key: screenshots-${{ matrix.device }}-${{ runner.os }}-${{ env.xcode_version }}-${{ hashFiles('OpenDocumentReader.xcodeproj/project.pbxproj', 'OpenDocumentReader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', 'OpenDocumentReader/**', 'OpenDocumentReaderUITests/**', 'Ads/**', 'NoAds/**', 'configs/**', 'scripts/make-screenshot-documents.py') }}

# half a set, so the lane does not check it - the job below does, once
# both halves are in
- name: photograph the ${{ matrix.device }} in every locale
env:
ODR_SCREENSHOT_DEVICE: ${{ matrix.device }}
run: bundle exec fastlane ios screenshots

# what the store is given
- name: archive the framed screenshots
uses: actions/upload-artifact@v7
with:
name: framed
name: framed-${{ matrix.device }}
path: fastlane/framed
if-no-files-found: error
# png, so there is nothing left to squeeze out of them
Expand All @@ -223,20 +246,72 @@ jobs:
# and what they were framed from, which is where to look when a picture
# comes out wrong
- name: archive the raw captures
# also when the lane failed: a half finished set is what says which
# language it got to
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: screenshots
name: screenshots-${{ matrix.device }}
path: fastlane/screenshots
if-no-files-found: error
if-no-files-found: warn
compression-level: 0

- uses: actions/upload-artifact@v7
if: failure()
with:
name: screenshot-log
name: screenshot-log-${{ matrix.device }}
path: ~/Library/Logs/snapshot
if-no-files-found: warn

# The two halves put back together, and only now checked: a set is both
# devices in every locale, and neither runner above can see the other's.
# Republished under the names the release reads.
screenshot-set:
needs: screenshots
runs-on: ubuntu-24.04
steps:
- name: checkout
uses: actions/checkout@v7

- uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: fetch both halves
uses: actions/download-artifact@v8
with:
pattern: framed-*
merge-multiple: true
path: fastlane/framed

- name: fetch what they were framed from
uses: actions/download-artifact@v8
with:
pattern: screenshots-*
merge-multiple: true
path: fastlane/screenshots

# the check the lane cannot do on half a set: every locale, both devices,
# at a size App Store Connect takes
- name: check the set
run: scripts/store_screenshots.py --screenshots fastlane/framed

- name: archive the framed screenshots
uses: actions/upload-artifact@v7
with:
name: framed
path: fastlane/framed
if-no-files-found: error
compression-level: 0

- name: archive the raw captures
uses: actions/upload-artifact@v7
with:
name: screenshots
path: fastlane/screenshots
if-no-files-found: warn
compression-level: 0

# a job per app, fail-fast off, so "Re-run failed jobs" can retry one half
upload:
needs: build
Expand Down Expand Up @@ -275,7 +350,7 @@ jobs:
# while a build cannot be uploaded twice. No macOS runner: this touches the
# listing, not the app
listing:
needs: [upload, screenshots]
needs: [upload, screenshot-set]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-24.04
strategy:
Expand Down
9 changes: 8 additions & 1 deletion OpenDocumentReaderUITests/ScreenshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ final class ScreenshotTests: XCTestCase {

override func setUpWithError() throws {
continueAfterFailure = false

// A ceiling, not a target: six launches on a shared runner outrun
// XCTest's ten minute default and get killed mid-test.
executionTimeAllowance = 1800
}

@MainActor
Expand Down Expand Up @@ -125,7 +129,10 @@ final class ScreenshotTests: XCTestCase {
private func dismissTheKeyboardTutorial(in app: XCUIApplication) {
let continueButton = app.buttons["Continue"]

if continueButton.waitForExistence(timeout: 2) {
// Short, because this waits its whole timeout on every run that does not
// need it - which is all of them while the launch argument holds. The
// panel comes up with the keyboard, and the keyboard is already up here.
if continueButton.waitForExistence(timeout: 0.5) {
continueButton.tap()
}
}
Expand Down
70 changes: 59 additions & 11 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@ MAKE_DOCUMENTS = File.expand_path("../scripts/make-screenshot-documents.py").fre
SCREENSHOT_DIR = File.expand_path("screenshots").freeze
FRAMED_DIR = File.expand_path("framed").freeze

# Named, where snapshot would pick a fresh temp directory each run. The app is
# built once into it and then photographed eighteen times without building
# again, and CI keeps it between runs.
SCREENSHOT_BUILD_DIR = File.expand_path("../build/screenshots").freeze

# The two sizes App Store Connect asks an app that runs on both for, named by
# whatever the runner's Xcode calls them. Newest first, and the first one the
# runner actually has wins - a simulator's name changes with every Xcode, the
# number of pixels it has does not, and store_screenshots.py checks that.
SCREENSHOT_DEVICES = [
["iPhone 17 Pro Max", "iPhone 16 Pro Max", "iPhone 15 Pro Max"],
["iPad Pro 13-inch (M5)", "iPad Pro 13-inch (M4)", "iPad Pro (12.9-inch) (6th generation)"],
].freeze
SCREENSHOT_DEVICES = {
"iphone" => ["iPhone 17 Pro Max", "iPhone 16 Pro Max", "iPhone 15 Pro Max"],
"ipad" => ["iPad Pro 13-inch (M5)", "iPad Pro 13-inch (M4)", "iPad Pro (12.9-inch) (6th generation)"],
}.freeze

def dry_run?
ENV["ODR_DRY_RUN"].to_s.strip == "true"
Expand Down Expand Up @@ -94,7 +99,8 @@ def screenshot_devices(ios)

available = simulators_by_ios.fetch(ios, [])

SCREENSHOT_DEVICES.map do |candidates|
screenshot_kinds.map do |kind|
candidates = SCREENSHOT_DEVICES.fetch(kind)
candidates.find { |name| available.include?(name) } ||
UI.user_error!(
"none of #{candidates.join(', ')} runs iOS #{ios}. That runtime has: " \
Expand All @@ -104,6 +110,19 @@ def screenshot_devices(ios)
end
end

# Which of the two the run is for. Both unless one is named, so a run started by
# hand is the whole set - only the release splits them, a device to a runner.
def screenshot_kinds
given = ENV["ODR_SCREENSHOT_DEVICE"].to_s.strip.downcase
return SCREENSHOT_DEVICES.keys if given.empty?

unless SCREENSHOT_DEVICES.key?(given)
UI.user_error!("no such device: #{given}. One of #{SCREENSHOT_DEVICES.keys.join(', ')}.")
end

[given]
end

platform :ios do
desc "Build a signed .ipa of the paid app"
lane :buildPro do
Expand Down Expand Up @@ -149,6 +168,20 @@ platform :ios do
# it starts - a file written later lands in the next build, not this one.
sh(MAKE_DOCUMENTS)

# Built once, here. snapshot asks xcodebuild to build and then test on every
# run, and there are eighteen of them - two devices in nine languages. The
# build is the same every time, and checking it is still up to date costs
# about two minutes on a runner.
scan(
project: "OpenDocumentReader.xcodeproj",
scheme: "ODR Screenshots",
# not a device: this is the one build both simulators are photographed from
destination: "generic/platform=iOS Simulator",
derived_data_path: SCREENSHOT_BUILD_DIR,
build_for_testing: true,
skip_detect_devices: true
)

# The Pro scheme, so no ad sdk is linked and no consent form can come up in
# front of a picture. The two apps are the same app, and what differs - the
# banner Lite carries - is not in a screenshot either way, so one set of
Expand Down Expand Up @@ -178,7 +211,18 @@ platform :ios do
skip_helper_version_check: true,
# the runner has no browser to open the summary in, and no one watching it
skip_open_summary: true,
stop_after_first_error: true
stop_after_first_error: true,
# one device per xcodebuild rather than both at once: two simulators
# rendering on a three core runner took ten minutes over one, and ran the
# test out of the time XCTest allows it
concurrent_simulators: false,
# the build above, photographed as it stands
derived_data_path: SCREENSHOT_BUILD_DIR,
test_without_building: true,
# the language the app is given is not the language the status bar is
# drawn in, and an iPad's status bar carries the date. Without this it
# reads Mon Aug 17 over a Russian document.
localize_simulator: true
)

# A raw capture is not what the store shows. The framing is separate from the
Expand All @@ -189,12 +233,16 @@ platform :ios do

# what came out is what the store would be given, so it is checked here
# rather than at upload time on the other side of the run. A run narrowed to
# a few languages by hand has nothing to check against: half a set is what it
# was asked for.
if ENV["ODR_SCREENSHOT_LANGUAGES"].to_s.strip.empty?
sh(STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR)
# some of the languages or to one of the devices has nothing to check
# against: half a set is what it was asked for, and the release checks the
# two halves together once both runners have handed theirs in.
narrowed = %w[ODR_SCREENSHOT_LANGUAGES ODR_SCREENSHOT_DEVICE ODR_SCREENSHOT_DEVICES]
.any? { |name| !ENV[name].to_s.strip.empty? }

if narrowed
UI.important("#{devices.join(', ')} in #{languages.join(', ')} is not a full set, so it is not checked")
else
UI.important("only #{languages.join(', ')} were captured, so the set is not checked")
sh(STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR)
end
end

Expand Down