Skip to content
← All posts
Automation · 5 min read ·

Automate demo videos in CI so they never go stale

Treat the demo as code. Test the flow on every pull request, and on each release re-record only the scenes whose screens actually changed.

Every product team has this video. It was recorded for a launch, it's embedded on the pricing page and in the onboarding email, and it shows a sidebar that was redesigned two releases ago. Nobody updates it because updating means re-recording the whole thing, and nobody notices it's wrong until a customer asks why their screen looks different.

Demo videos go stale for the same reason documentation does: they live outside the code that changes them. The fix is also the same. Put the demo next to the code, check it on every change, and regenerate it when a release changes what it shows.

Why demo videos go stale

  • The UI changes weekly; the video changes yearly. A renamed button or a new onboarding step makes the whole video look old.
  • Re-recording is all or nothing. One changed screen means redoing every screen, plus every edit you made on top.
  • Nobody owns it. The person who recorded it has moved on, and the steps live in their head.
  • Nothing fails when it's wrong. A broken test blocks a merge. An outdated video just sits there.

A useful setup answers each point: the steps are written down in the repo, a check fails when the flow breaks, and a release re-records only what changed.

Treat the demo as code: demotake.json

A demotake.json file describes a demo as takes, scenes and steps. Each step is an action (click, type, key, wait) or an assertion (assert_text, assert_element, assert_state), and each scene ends at a checkpoint: a stable state described in words. Here is a trimmed version of the example that ships with DemoTake:

demotake.json
{
  "version": 1,
  "target": { "bundle_id": "com.apple.TextEdit", "app_name": "TextEdit" },
  "outputs": ["16:9", "9:16", "guide"],
  "defaults": { "timeout": 10, "attempts": 2 },
  "edit": { "target_duration": 30, "compress_waits": { "threshold": 1, "to": 0.4 } },
  "takes": [{
    "name": "Write and save a note",
    "scenes": [
      {
        "title": "New document",
        "checkpoint": "An empty Untitled document is open",
        "steps": [
          { "action": "key", "keys": "cmd+n", "label": "Create a new document" },
          { "action": "wait", "element": { "role": "textarea" }, "timeout": 5 },
          { "action": "assert_state", "title": "Blank document open", "window_title": "Untitled" }
        ]
      },
      {
        "title": "Write the note",
        "checkpoint": "The note text is in the document",
        "steps": [
          { "action": "type", "text": "Release notes: DemoTake now runs demos as code.", "element": { "role": "textarea" } },
          { "action": "assert_text", "text": "runs demos as code", "title": "Note text is visible" }
        ]
      }
    ]
  }]
}

Steps find elements by role, title, text or accessibility identifier rather than by pixel position, so moving a button around doesn't break the spec. demotake schema prints the JSON Schema for editor completion, and demotake validate demotake.json checks the file without the app.

Get a spec without writing it by hand

You rarely start from an empty file. Record the flow once, then save it as code. Your coding agent can call take_to_spec, or you can use the CLI:

Terminal
demotake to-spec take_01j… -o demotake.json

DemoTake turns the take's trace into steps with element queries, turns its assertions into assert_* steps, and keeps its style and edit settings. Anything secret you typed becomes a secret_env reference. Let your coding agent record the demo shows how to get that first take without clicking through it yourself.

Run a DemoTest on every pull request

demotake test runs the spec scene by scene without recording video. It fails the build when a step or an assertion fails, and it writes a JUnit report your CI already knows how to show:

Terminal
demotake test demotake.json --junit reports/demotake.xml

That makes every demo an end-to-end test. If a pull request renames the Save button, the demo's test fails on that pull request, not three weeks later in front of a customer. Failure evidence, a blurred snapshot and a UI excerpt, is in the JUnit report. Test runs reuse one "(DemoTest)" take per spec take, so they don't flood your library.

Regenerate on release, but only the scenes that changed

When you tag a release, run the spec for real with --only-changed:

Terminal
demotake run demotake.json --only-changed --outputs 16:9,9:16,guide

DemoTake compares each scene's checkpoint with the last recording of the same take and decides per scene:

  • The accessibility state is compared as text. Numbers are normalized first, so a clock or a counter doesn't count as a change.
  • The checkpoint snapshot is compared visually, as a perceptual diff of a small grayscale image. Redacted regions and secure fields are ignored.
  • New scenes, and scenes without enough evidence on one side, count as changed.

Changed scenes are re-recorded. Unchanged scenes keep their footage and their edits. If nothing changed, the existing exports stay current and no render runs.

Checkout8 scenes · 3 of 8 changed
  1. 1Sign inKept
  2. 2Open cartKept
  3. 3Apply couponUI changed
  4. 4Shipping addressKept
  5. 5Payment methodUI changed
  6. 6Review orderUI changed
  7. 7ConfirmationKept
  8. 8Receipt emailKept
A release changed three screens. Those scenes were re-recorded; the other five kept their footage and edits.
Terminal
$ demotake run demotake.json --only-changed --outputs 16:9,9:16,guide
Checkout Take: PASS · 8 scene(s) · 3 of 8 scene(s) changed · 3 regenerated, 5 kept

You can check the same thing by hand with demotake diff take_01j…, which lists the scenes whose UI changed against the previous run.

Keep secrets out of the spec

Specs never contain passwords. A type step names an environment variable instead:

demotake.json · step
{ "action": "type", "secret_env": "DEMO_PASSWORD", "element": { "role": "securetextfield" } }

The demotake process reads DEMO_PASSWORD from its environment and forwards it to DemoTake for that run only. It is never logged, never stored in the take and never shown in videos, and secure fields are masked. If you prefer, {"keychain": {"service": "com.example.demo", "account": "ci"}} reads a generic password from the runner's login Keychain.

What the CI runner needs

DemoTake records and operates real apps, so the runner is a real Mac with a real screen session:

  • A self-hosted macOS runner with a logged-in GUI session. Keep its display awake and unlocked.
  • DemoTake Agent installed and running. The Mac App Store edition, DemoTake Studio, can't operate other apps, so it can't run specs.
  • Screen Recording and Accessibility granted to DemoTake once, interactively, in System Settings › Privacy & Security. demotake status shows what's missing.
  • The CLI on the PATH. It ships inside the app:
Terminal
mkdir -p ~/.local/bin
ln -sf /Applications/DemoTake.app/Contents/MacOS/demotake-cli ~/.local/bin/demotake
demotake status

This adds a link in your home folder only; no administrator password needed. If demotake isn't found afterwards, add export PATH="$HOME/.local/bin:$PATH" to ~/.zshrc and open a new terminal window. You can also run the CLI by its full path, /Applications/DemoTake.app/Contents/MacOS/demotake-cli.

The CLI finds DemoTake's local endpoint and token by itself. Add setup steps to your takes, and to scenes that may be retried, that bring the app back to its starting state, so every attempt starts clean.

A GitHub Actions workflow for DemoTest and release regeneration

This is a trimmed version of the workflow in the Demo specs and CI docs. Pull requests get a DemoTest; version tags regenerate the demos and upload them as build artifacts.

.github/workflows/demotake.yml
name: DemoTake
on:
  pull_request:
  push:
    tags: ["v*"]

concurrency:
  group: demotake-${{ github.ref }}   # one GUI session, one run at a time
  cancel-in-progress: true

jobs:
  demotest:
    if: github.event_name == 'pull_request'
    runs-on: [self-hosted, macOS, demotake]
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build.sh && open -g build/MyApp.app
      - run: demotake validate demotake.json
      - name: DemoTest (no video)
        env:
          DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}
        run: demotake test demotake.json --junit reports/demotake.xml --commit "$GITHUB_SHA"

  regenerate-demos:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: [self-hosted, macOS, demotake]
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/build.sh --release && open -g build/MyApp.app
      - name: Re-record changed scenes and render
        env:
          DEMO_PASSWORD: ${{ secrets.DEMO_PASSWORD }}
        run: |
          demotake run demotake.json --only-changed \
            --outputs 16:9,9:16,guide --commit "$GITHUB_SHA" \
            --json > reports/demotake-run.json
      - uses: actions/upload-artifact@v4
        with:
          name: demos-${{ github.ref_name }}
          path: reports/

The full version also publishes the JUnit report and copies each finished export, whose paths are listed in the JSON result, into the uploaded artifact.

Exit codes

Code Meaning
0 Everything passed
1 A scene or assertion failed, or the spec is invalid
2 Usage error, such as an unknown flag
3 DemoTake isn't reachable
4 DemoTake reported an error

A 3 means the CLI couldn't reach the DemoTake app on the runner, so check the runner before you debug the demo.

Where this is available

The demotake CLI and the demotake.json format ship inside DemoTake Agent, and everything in this post runs on your own runner. Managed "CI regeneration on release" is listed with the Team plan on the pricing page, where it is marked as coming soon. The CLI reference lists every command and flag.

Download DemoTake Agent, save your most important demo as demotake.json, and let the next release keep it current.