Skip to content
All Posts
BlogMarch 25, 20269 min read
SL

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:

  1. 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.
  2. 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.
  3. 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.
  4. Post-processing — The pipeline extracts the ZIP and organizes files into the directory structure expected by your upload tool.
  5. 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_screenshots

The 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
end

The 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 1

You 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:

  1. A designer creates or updates a template in the Screenshots.live visual editor.
  2. A developer updates .screenshots/config.yml with new text or locales and pushes to main.
  3. GitHub Actions detects the change and triggers the screenshot workflow.
  4. The rendering script reads the config, calls the API, and downloads rendered screenshots.
  5. Fastlane uploads the screenshots to App Store Connect and Google Play Console.
  6. 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.

Related Posts