Skip to content
Launch offer: Mac Manager £9.99 first year

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. Run shotlist doctor as the first step: it exits 1 if anything’s missing.
  • A shared scheme. xcodebuild on a fresh checkout only sees schemes marked Shared in Xcode (stored in xcshareddata).
  • Disk: about 15 GB for the simulators, plus about 1 GB per project for the build.
  • The shotlist CLI on the PATH. See Installing Shotlist.
  • A licence key in SHOTLIST_LICENSE_KEY, for capture, compose and publish --apply (below).
  • Package macros allowed, if your app uses @Shot or 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-inner out with --slots and 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) and detail;
  • creative, one per locale and kind: locale, ascLocale, kind (header or search), action, file and detail.

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 077 makes the key file readable by its owner only. Shotlist refuses a key file other users can read.
  • Pass the same --slots to compose, verify and publish as to capture. Otherwise verify reports the slots you left out as not captured.
  • To upload too, add --apply to the publish step, then commit Shotlist/published.json so the next run knows which screenshots are Shotlist’s. Many teams keep the upload a manual step: review the artefact, then run shotlist publish --apply at a desk.
  • --skip-build saves 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-warm on 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 --help and shotlist help <command> describe every command and flag.
  • --json on 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.
  • publish without --apply is always safe to run. Keep --apply and --prune for 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.