Shotlist guide
CI and automation
Running Shotlist from scripts, CI and coding agents - JSON output, exit codes, the one-capture lock, licensing with SHOTLIST_LICENSE_KEY, App Store Connect secrets, and proving Release builds contain no ShotlistKit.
CI and automation
Every Shotlist command can run unattended, prints machine-readable results with --json, and exits non-zero on failure. This page covers what a CI runner needs, how to read results, and a working pipeline.
What the runner needs
- macOS 26 or later, Xcode 27 or later selected with
xcode-select, and the iOS 27 simulator runtimes. Runshotlist doctoras the first step: it exits 1 if anything’s missing. - A shared scheme.
xcodebuildon a fresh checkout only sees schemes marked Shared in Xcode (stored inxcshareddata). - Disk: about 15 GB for the simulators, plus about 1 GB per project for the build.
- The
shotlistCLI on thePATH. See Installing Shotlist. - A licence key in
SHOTLIST_LICENSE_KEY, for capture, compose andpublish --apply(below). - Package macros allowed, if your app uses
@Shotor another package’s macros, such as TCA’s. See Package macros. - No iPhone Duo inner display, unless someone is at the Mac. Unfolding needs an unlocked screen. Leave
iphone-duo-innerout with--slotsand capture it at a desk. See iPhone Duo.
Shotlist creates its own simulators on the runner the first time, which takes a few minutes. A persistent runner reuses them.
Package macros
Xcode won’t run a package’s macros until someone trusts them, and on your Mac it asks once, the first time you build. A CI runner has no one to ask, so xcodebuild, and so shotlist capture, fails with “Macro … must be enabled before it can be used”. This applies to @Shot from ShotlistMacros and to any other package macro, such as TCA’s.
On a runner you control, allow them before capturing:
defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES
Shotlist doesn’t skip the check itself: whether to run a package’s macros is your call. If you build the app with your own xcodebuild and pass it to shotlist capture --app, add -skipMacroValidation to that build instead.
Licensing in CI
Set your licence key in the environment instead of activating:
export SHOTLIST_LICENSE_KEY=<your key>
- Each paid command (capture, compose,
publish --apply) checks the key with Polar. Nothing is activated on the runner. - If Polar can’t be reached, the command carries on and says so on stderr: “! Couldn’t reach Polar to check SHOTLIST_LICENSE_KEY; carrying on.” An outage at Polar won’t break a build.
- If the subscription has ended, the command stops with exit code 1 and says how to renew.
Keep the key in your CI system’s secret store, not in the repo. See Subscription.
JSON output
Every command takes --json. The result goes to stdout as one JSON object (an array for captions status). Progress and log lines go to stderr, each as a JSON object:
{
"level" : "info",
"message" : "en-GB/iphone-6.3/01-table-light unchanged",
"time" : "2026-11-01T00:21:54Z"
}
level is info, warning, error or prompt (something a person must do, such as unfolding the iPhone Duo). Each event is one JSON object on its own line (JSON Lines), so jq and line-by-line readers both work.
Keys are sorted, dates are ISO 8601, and paths are absolute or relative to Shotlist/Output/ as noted. Redirect stdout to a file and parse that:
shotlist capture --json > capture.json
jq '.failed' capture.json
jq -r '.failures[] | "\(.cell): \(.reason)"' capture.json
What each command prints
capture
| Key | Meaning |
|---|---|
cells |
Cells in this run. |
captured, failed, notApplicable |
How many were captured, failed, or skipped for their posture. |
new, changed, unchanged |
Change review counts. |
newCells, changedCells |
Their keys, such as en-GB/iphone-6.3/01-shop-light. |
failures |
Each failure’s cell and reason. |
manifest, log |
Paths to the manifest and run log. |
seconds |
How long the run took. |
duoPosture |
automatic, guided or unavailable, when the iPhone Duo was captured. |
If the run can’t proceed at all (exit code 2), nothing is printed on stdout; the reason is the last error on stderr.
compose: composed, unchanged, creative (files written, relative to Output/), failures (each item and reason), lint (each locale, key and finding with rule and match), unreviewedLocales and warnings.
verify: checkedFiles and problems, each with message and usually file. No problems means it passed.
doctor: checks, each with name, status (ok, warning or fail) and detail.
captions status: an array with, per locale, locale, reviewed, needsReview and missing (the missing keys).
captions draft: drafted (count per locale), engines (the translator per locale) and skipped (reason per locale).
captions review: the number of captions marked reviewed, per locale.
publish: appID, version, versionID, versionState, applied, problems, and:
rows, one per locale and slot:locale,ascLocale,slot,displayType,action,files,live(screenshots live now),foreign(live ones Shotlist didn’t upload) anddetail;creative, one per locale and kind:locale,ascLocale,kind(headerorsearch),action,fileanddetail.
action is one of add, replace, reorder, unchanged, delete, export, skip or blocked.
# How many rows would change?
jq '[.rows[], .creative[] | select(.action != "unchanged" and .action != "skip" and .action != "export")] | length' plan.json
init: created and skipped, the files in each case.
A few messages are always plain text on stderr, even with --json: a subscription refusal, and publish --apply refusing because verify has problems.
Exit codes
| Command | 0 | 1 | Other |
|---|---|---|---|
doctor |
No check failed (warnings are fine) | A check failed | |
capture |
Every cell captured or skipped | A cell failed; shotlist.json invalid; no subscription |
2: the run couldn’t proceed (build failed, lock held, no matching cells, app not found) |
compose |
Everything composed | Anything failed; no manifest; no subscription | |
captions status |
Every locale ready | A locale has missing or unreviewed captions | |
captions draft |
Finished, even if some locales were skipped | No source captions, or a translation error | |
captions review |
Done | ||
verify |
No problems | Any problem | |
publish |
The plan or apply had no problems | Any problem: no editable version, a blocked or failed row, verify refusing --apply, credentials, no subscription |
|
licence status |
Active | Not subscribed, ended or needs a check | |
licence activate, licence deactivate |
Done | Polar refused or couldn’t be reached |
Every command exits 64 on a usage error, such as an unknown option.
captions draft succeeding doesn’t mean every locale was drafted. Check skipped in its JSON, or gate on captions status afterwards.
One capture at a time
Every project on a Mac shares Shotlist’s simulators, so one capture runs at a time. The lock lives in ~/Library/Caches/app.shotlist/capture.lock. A second capture stops at once, with exit code 2: “another shotlist capture is running on this Mac; captures share simulators, so wait for it to finish”.
On a shared runner, serialise screenshot jobs: a concurrency group in GitHub Actions, a lock or resource in other systems. The lock is released when the process ends, however it ends.
compose, verify, captions and publish don’t take the lock.
A pipeline
A GitHub Actions workflow on a self-hosted Mac runner. It captures, checks captions, composes and verifies, keeps the output as an artefact, and prints the App Store Connect plan without changing anything:
name: Screenshots
on:
workflow_dispatch:
concurrency:
group: shotlist-capture
jobs:
screenshots:
runs-on: [self-hosted, macOS]
env:
SHOTLIST_LICENSE_KEY: ${{ secrets.SHOTLIST_LICENSE_KEY }}
SLOTS: iphone-6.9,iphone-6.3,ipad-13
steps:
- uses: actions/checkout@v4
- name: Check the Mac
run: shotlist doctor
- name: Capture
run: shotlist capture --json --slots "$SLOTS" > capture.json
- name: Captions are reviewed
run: shotlist captions status
- name: Compose
run: shotlist compose --json --slots "$SLOTS" > compose.json
- name: Verify
run: shotlist verify --json --slots "$SLOTS" > verify.json
- name: Plan the upload
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
run: |
umask 077
export ASC_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
printf '%s\n' "$ASC_KEY_P8" > "$ASC_KEY_PATH"
shotlist publish --json --slots "$SLOTS" > plan.json
rm -f "$ASC_KEY_PATH"
- name: Keep the output
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: |
Shotlist/Output
*.json
Notes:
umask 077makes the key file readable by its owner only. Shotlist refuses a key file other users can read.- Pass the same
--slotstocompose,verifyandpublishas tocapture. Otherwiseverifyreports the slots you left out as not captured. - To upload too, add
--applyto the publish step, then commitShotlist/published.jsonso the next run knows which screenshots are Shotlist’s. Many teams keep the upload a manual step: review the artefact, then runshotlist publish --applyat a desk. --skip-buildsaves time when an earlier step already built the app for the simulator into.shotlist/DerivedData. With a build from elsewhere, pass--app <path to the .app>.- Leave out
--keep-warmon shared runners, so simulators don’t hold memory between jobs.
Prove Release builds contain no ShotlistKit
Everything in ShotlistKit, ShotlistDependencies and ShotlistTCA is inside #if DEBUG, and ShotlistMacros declares only the @Shot macro, so a Release build should contain none of it. check-release-symbols.sh proves it. The script is in the ShotlistKit package, at scripts/check-release-symbols.sh; copy it into your repo.
Build Release for the simulator, then check it:
xcodebuild build -project MyApp.xcodeproj -scheme MyApp -configuration Release \
-destination "generic/platform=iOS Simulator" -derivedDataPath build -quiet
scripts/check-release-symbols.sh build/Build/Products/Release-iphonesimulator/MyApp.app
build/Build/Products/Release-iphonesimulator/MyApp.app/MyApp: 0 Shotlist symbols, 0 Shotlist strings
OK: no ShotlistKit code
It looks for ShotlistKit’s module names in the binary’s symbols, and for its strings, including in the .debug.dylib Xcode puts beside Debug builds. It exits 0 when clean, 1 when it finds ShotlistKit code (printing a few of the symbols), and 2 when it can’t find the executable.
Pointed at a Debug build, it fails: “FAIL: ShotlistKit code is present in this build”. That’s a quick check that it can see ShotlistKit at all.
It also works on an archived device build: pass the .app from the archive’s Products/Applications/.
Agents
Coding agents can drive Shotlist the same way CI does:
shotlist --helpandshotlist help <command>describe every command and flag.--jsonon every command gives stable keys to act on.- Failure reasons name what to fix: the open hold, the URL with no fixture and the paths searched, the locale and scene that overflowed.
publishwithout--applyis always safe to run. Keep--applyand--prunefor a person to approve.
A typical loop for an agent fixing a scene: capture just that scene (--scenes, --slots, --locales, --skip-build when only fixtures changed), read failures from the JSON, fix, and repeat until failed is 0 and the cell is unchanged on a second run.