Screenshots.live
Team
Building a Screenshot Pipeline in Your CI/CD: A Step-by-Step Guide
Learn how to build an automated screenshot pipeline using Screenshots.live API, GitHub Actions, and Fastlane. Complete with YAML configs, rendering scripts, and upload automation for both iOS and Android.
Why Screenshots Belong in Your CI/CD Pipeline
App store screenshots are usually treated as a design task, something that happens once before launch and then gets painfully updated every few months. This approach breaks down the moment your team ships frequently. If you release every two weeks, your screenshots fall behind after the first sprint. Users see promotional images that no longer match the actual app experience.
The solution is to treat screenshots like any other build artifact. They should be generated automatically when your UI changes, versioned alongside your code, and deployed to the app stores as part of your release process. This is what a screenshot pipeline does.
The benefits are substantial. First, your screenshots always reflect the current state of your app. Second, localized screenshots update automatically when you add new languages or change copy. Third, your design team sets the template once and developers handle the rest through configuration. Fourth, you eliminate the manual export-and-upload cycle that eats hours every release.
Architecture Overview
A complete screenshot pipeline has five stages:
- Template Design — Your design team creates screenshot templates in the Screenshots.live visual editor. Templates define the layout: device frame position, text placement, background colors, and dynamic text fields that accept locale-specific content.
- Configuration — A YAML file in your repository defines which templates to render, which locales to generate, and what text variables to inject for each language.
- Rendering — Your CI/CD pipeline calls the Screenshots.live API with the configuration, which renders all screenshot variations and returns them as a downloadable ZIP archive.
- Post-processing — The pipeline extracts the ZIP and organizes files into the directory structure expected by your upload tool.
- Upload — Fastlane delivers the screenshots to App Store Connect and Google Play Console automatically.
Each stage is independent and debuggable. If rendering fails, you can re-run just that step. If upload fails, your rendered screenshots are cached and ready for retry.
Setting Up Your Configuration File
Start by creating a screenshot configuration file in your repository root. This file defines everything the pipeline needs to generate your screenshots.
# .screenshots/config.yml
api_key: ${SCREENSHOTS_API_KEY}
base_url: https://api.screenshots.live/v1
templates:
- id: tpl_hero_screen
name: "Hero Screenshot"
devices:
- iphone67
- ipad129
- android_phone
- android_tablet
- id: tpl_feature_list
name: "Feature List"
devices:
- iphone67
- ipad129
- android_phone
- android_tablet
- id: tpl_onboarding
name: "Onboarding Flow"
devices:
- iphone67
- ipad129
- android_phone
- android_tablet
locales:
- code: en
variables:
headline: "Track Your Progress"
subtitle: "All-in-one fitness companion"
cta: "Start Free Trial"
- code: de
variables:
headline: "Verfolge deinen Fortschritt"
subtitle: "Alles-in-einem Fitness-Begleiter"
cta: "Kostenlos testen"
- code: es
variables:
headline: "Sigue tu progreso"
subtitle: "Tu companero fitness todo-en-uno"
cta: "Prueba gratis"
- code: fr
variables:
headline: "Suivez vos progres"
subtitle: "Votre compagnon fitness tout-en-un"
cta: "Essai gratuit"
- code: ja
variables:
headline: "Progress wo Tsuiseki"
subtitle: "Ooru-in-wan Fitness Companion"
cta: "Muryou Taiken"Store your API key as a repository secret, never in the configuration file itself. The ${SCREENSHOTS_API_KEY} placeholder gets resolved at runtime by your CI environment.
GitHub Actions Workflow
Here is a complete GitHub Actions workflow that generates screenshots and uploads them to both app stores. Create this file at .github/workflows/screenshots.yml in your repository.
name: Generate App Store Screenshots
on:
push:
branches: [main]
paths:
- '.screenshots/**'
- 'fastlane/metadata/**'
workflow_dispatch:
inputs:
force_regenerate:
description: 'Force regenerate all screenshots'
required: false
default: 'false'
env:
SCREENSHOTS_API_KEY: ${{ secrets.SCREENSHOTS_API_KEY }}
SCREENSHOTS_OUTPUT_DIR: ./fastlane/screenshots
jobs:
generate-screenshots:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache rendered screenshots
uses: actions/cache@v4
with:
path: .screenshots/cache
key: screenshots-${{ hashFiles('.screenshots/config.yml') }}
restore-keys: |
screenshots-
- name: Install dependencies
run: npm install js-yaml node-fetch@2 adm-zip
- name: Generate screenshots
run: |
node .screenshots/generate.js
- name: Verify output
run: |
echo "Generated screenshots:"
find $SCREENSHOTS_OUTPUT_DIR -name "*.png" | head -20
TOTAL=$(find $SCREENSHOTS_OUTPUT_DIR -name "*.png" | wc -l)
echo "Total screenshots generated: $TOTAL"
if [ "$TOTAL" -lt 1 ]; then
echo "ERROR: No screenshots were generated"
exit 1
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: app-store-screenshots
path: ${{ env.SCREENSHOTS_OUTPUT_DIR }}
retention-days: 30
upload-ios:
needs: generate-screenshots
runs-on: macos-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download screenshots
uses: actions/download-artifact@v4
with:
name: app-store-screenshots
path: fastlane/screenshots
- name: Install Fastlane
run: gem install fastlane
- name: Upload to App Store Connect
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
run: fastlane ios upload_screenshots
upload-android:
needs: generate-screenshots
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download screenshots
uses: actions/download-artifact@v4
with:
name: app-store-screenshots
path: fastlane/screenshots
- name: Install Fastlane
run: gem install fastlane
- name: Upload to Google Play
env:
SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: fastlane android upload_screenshotsThe Rendering Script
The GitHub Actions workflow calls a Node.js script that reads the configuration, calls the Screenshots.live API, and organizes the output. Create this at .screenshots/generate.js.
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const fetch = require('node-fetch');
const AdmZip = require('adm-zip');
const CONFIG_PATH = path.join(__dirname, 'config.yml');
const OUTPUT_DIR = process.env.SCREENSHOTS_OUTPUT_DIR || './fastlane/screenshots';
const CACHE_DIR = path.join(__dirname, 'cache');
async function main() {
const config = yaml.load(fs.readFileSync(CONFIG_PATH, 'utf8'));
const apiKey = process.env.SCREENSHOTS_API_KEY;
if (!apiKey) {
throw new Error('SCREENSHOTS_API_KEY environment variable is required');
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(CACHE_DIR, { recursive: true });
for (const template of config.templates) {
console.log(`Rendering template: ${template.name}`);
const cacheKey = `${template.id}-${hashObject(config.locales)}`;
const cachePath = path.join(CACHE_DIR, `${cacheKey}.zip`);
let zipBuffer;
if (fs.existsSync(cachePath) && !process.env.FORCE_REGENERATE) {
console.log(` Using cached render for ${template.name}`);
zipBuffer = fs.readFileSync(cachePath);
} else {
const variables = {};
for (const locale of config.locales) {
variables[locale.code] = locale.variables;
}
const response = await fetch(`${config.base_url}/render`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
templateId: template.id,
locales: config.locales.map(l => l.code),
devices: template.devices,
variables: variables,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Render failed for ${template.name}: ${response.status} ${errorText}`
);
}
zipBuffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(cachePath, zipBuffer);
console.log(` Rendered and cached ${template.name}`);
}
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();
for (const entry of entries) {
if (entry.isDirectory) continue;
const outputPath = path.join(OUTPUT_DIR, entry.entryName);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, entry.getData());
}
console.log(` Extracted ${entries.length} files for ${template.name}`);
}
console.log('Screenshot generation complete.');
}
function hashObject(obj) {
const crypto = require('crypto');
return crypto.createHash('md5').update(JSON.stringify(obj)).digest('hex').slice(0, 8);
}
main().catch(err => {
console.error(err);
process.exit(1);
});Fastlane Integration
Fastlane handles the upload to both app stores. Here are the lane definitions you need in your Fastfile.
# fastlane/Fastfile
platform :ios do
desc "Upload screenshots to App Store Connect"
lane :upload_screenshots do
deliver(
skip_binary_upload: true,
skip_metadata: true,
skip_app_version_update: true,
screenshots_path: "./fastlane/screenshots/ios",
overwrite_screenshots: true,
precheck_include_in_app_purchases: false,
)
end
end
platform :android do
desc "Upload screenshots to Google Play"
lane :upload_screenshots do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
images_path: "./fastlane/screenshots/android",
)
end
endThe directory structure that Fastlane expects for iOS looks like this:
fastlane/screenshots/ios/
en-US/
iPhone 6.7" Display-1.png
iPhone 6.7" Display-2.png
iPad Pro 12.9" Display-1.png
de-DE/
iPhone 6.7" Display-1.png
...For Android, the structure is:
fastlane/screenshots/android/
en-US/
phoneScreenshots/
1.png
2.png
tenInchScreenshots/
1.png
de-DE/
phoneScreenshots/
1.png
...Handling Locales in the Pipeline
Locale management is one of the biggest advantages of an automated pipeline. Instead of manually tracking which screenshots have been translated, your configuration file becomes the single source of truth.
When you add a new language, you add one entry to config.yml:
- code: ko
variables:
headline: "Dangsinui Baljeonul Chujeokhasejo"
subtitle: "Ol-in-won Pitniseu Dongbanja"
cta: "Mullo Chehum"The next pipeline run generates screenshots for every template in the new language. No design work needed. No separate export. The new locale flows through the entire system automatically.
For teams using a translation management system like Lokalise, Phrase, or Crowdin, you can add a pre-render step to your pipeline that pulls the latest translations and writes them into the config file programmatically.
- name: Pull latest translations
run: |
node .screenshots/sync-translations.js
env:
LOKALISE_API_KEY: ${{ secrets.LOKALISE_API_KEY }}
LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }}Caching and Optimization Tips
Screenshot rendering is computationally intensive. Each render takes a few seconds, and when you multiply that by templates, devices, and locales, a full regeneration can take several minutes. Smart caching keeps your pipeline fast.
Content-based cache keys. The workflow above uses hashFiles('.screenshots/config.yml') as the cache key. If the config has not changed since the last run, cached screenshots are reused instantly. A full render only happens when you change text, add locales, or modify templates.
Selective rendering. If only one locale changed, consider splitting your renders by locale and caching each independently. This way, adding Korean does not force re-rendering English, German, and every other existing locale.
Parallel rendering. The Screenshots.live API can handle concurrent requests. Modify the generate script to render templates in parallel:
const renderPromises = config.templates.map(template =>
renderTemplate(template, config, apiKey)
);
await Promise.all(renderPromises);Artifact retention. Set a reasonable retention period for your screenshot artifacts. Thirty days is usually enough to debug any issues without consuming excessive storage.
Monitoring Renders with Webhooks
For teams that need visibility into the rendering process, Screenshots.live supports webhooks that notify your systems when renders complete or fail.
Configure a webhook endpoint in your Screenshots.live dashboard, then add a monitoring step to your pipeline:
- name: Wait for render completion
run: |
RENDER_ID=$(cat .screenshots/last-render-id.txt)
for i in $(seq 1 60); do
STATUS=$(curl -s -H "Authorization: Bearer $SCREENSHOTS_API_KEY" \
"https://api.screenshots.live/v1/render/$RENDER_ID/status" | \
jq -r '.status')
if [ "$STATUS" = "completed" ]; then
echo "Render completed successfully"
exit 0
elif [ "$STATUS" = "failed" ]; then
echo "Render failed"
exit 1
fi
echo "Status: $STATUS - waiting..."
sleep 10
done
echo "Render timed out"
exit 1You can also send render notifications to Slack, Microsoft Teams, or any webhook-compatible service. This gives your design team visibility without needing access to the CI system.
Putting It All Together
A complete screenshot pipeline transforms app store asset management from a manual, error-prone process into an automated, reliable system. Here is what your workflow looks like end to end:
- A designer creates or updates a template in the Screenshots.live visual editor.
- A developer updates
.screenshots/config.ymlwith new text or locales and pushes tomain. - GitHub Actions detects the change and triggers the screenshot workflow.
- The rendering script reads the config, calls the API, and downloads rendered screenshots.
- Fastlane uploads the screenshots to App Store Connect and Google Play Console.
- Both store listings are updated with fresh, accurate screenshots within minutes of the code push.
The entire process runs without manual intervention. Your screenshots are always current, always consistent across platforms, and always localized for every market you serve. That is the power of treating screenshots as code.