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:
{
"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:
demotake to-spec take_01j… -o demotake.jsonDemoTake 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:
demotake test demotake.json --junit reports/demotake.xmlThat 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:
demotake run demotake.json --only-changed --outputs 16:9,9:16,guideDemoTake 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.
- 1Sign inKept
- 2Open cartKept
- 3Apply couponUI changed
- 4Shipping addressKept
- 5Payment methodUI changed
- 6Review orderUI changed
- 7ConfirmationKept
- 8Receipt emailKept
$ 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:
{ "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 statusshows what's missing. - The CLI on the
PATH. It ships inside the app:
mkdir -p ~/.local/bin
ln -sf /Applications/DemoTake.app/Contents/MacOS/demotake-cli ~/.local/bin/demotake
demotake statusThis 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.
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.