# Record your app in CI

`sleeperhit capture record` drives Google Chrome through a **tour** of your app (a JSON script of steps) and records one **take**:

- `capture.mp4`: H.264, exactly 1920×1080, constant 30 fps, bt709;
- `events.json`: clicks with their targets and results, typing, scrolls, network waits, marks and cuts;
- `checkpoints/`: one PNG per step;
- `tour.json`: the tour exactly as it ran.

With `--upload --project <projectId>`, the take goes to your Sleeper Hit Studio project, ready for a trailer or a deck.

Recording in your own CI has three advantages:
- it records the build you just tested;
- it reaches an app that only runs inside the job, on `localhost`;
- your demo login stays in your CI secrets and is never stored with us.

## What you need

- **A tour** in your repository, for example `tours/onboarding.json` (see [Writing a tour](#writing-a-tour)).
- **A demo account** on a demo or test tenant. Never use a real account or production data: everything on screen ends up in the video.
- **An API key** with only the `source:read` and `source:write` scopes, minted at https://sleeperhit.studio/dashboard/developers. Store it as the CI secret `SLEEPERHIT_API_KEY`.
- **Your project's id**, from `sleeperhit projects list` or the project page. Store it as the CI variable `SLEEPERHIT_PROJECT_ID`.
- **A Linux x86-64 runner with Docker**, such as GitHub's `ubuntu-24.04`. The image is amd64 only, because Google Chrome for Linux is. Apple silicon can run it under emulation: it is slow, but the frames are still exact.

## Two ways to install

A machine without the recorder says so: `capture record` stops with `capture record needs the recorder` and names the
two routes below, in the same words this page does (`packages/cli/src/capture-record.ts`). `npm install
@sleeperhit/capture` is still a 404 — neither package is on npm yet — so nothing else is offered anywhere.

### The runner image (recommended)

`ghcr.io/sleeperhitstudio/capture-runner` holds the CLI, the recorder, Google Chrome, ffmpeg and Node, all pinned:

| Tag | What it is |
|---|---|
| `0.1.0` | The newest build of CLI version 0.1.0 |
| `sha-<12 hex>` | One commit's build. It never moves. |
| `main` | The newest build |

Pin a `sha-` tag or a digest when the take must be reproducible.

**Preview: the image is private for now.** Ask us for pull access, then log in to `ghcr.io` before the first `docker run`. Until you have access, use the tarballs below.

### The tarballs, without Docker (until the packages are on npm)

`@sleeperhit/cli` and `@sleeperhit/capture` are not on npm yet. Each one ships as one tarball:
- `sleeperhit-cli-0.1.0.tgz`;
- `sleeperhit-capture-0.1.0.tgz`.

Ask us for them, or take them out of the image:

```sh
docker run --rm --entrypoint cat ghcr.io/sleeperhitstudio/capture-runner:0.1.0 /opt/pkgs/sleeperhit-cli-0.1.0.tgz > sleeperhit-cli-0.1.0.tgz
docker run --rm --entrypoint cat ghcr.io/sleeperhitstudio/capture-runner:0.1.0 /opt/pkgs/sleeperhit-capture-0.1.0.tgz > sleeperhit-capture-0.1.0.tgz
```

Install both, side by side. The CLI loads the recorder by name, only for `capture record`:

```sh
npm install -g ./sleeperhit-cli-0.1.0.tgz ./sleeperhit-capture-0.1.0.tgz
npx -y playwright@1.58.2 install --with-deps chrome   # the recorder's Playwright version
sudo apt-get install -y --no-install-recommends ffmpeg
```

This path records in whatever Chrome is stable on the day you install it. The image pins Chrome.

Once the packages are published, the first line becomes `npm install -g @sleeperhit/cli @sleeperhit/capture`.

## GitHub Actions

```yaml
jobs:
  capture:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Start the app on localhost:3000
        run: |
          docker compose up -d
          npx -y wait-on@8 http://localhost:3000

      - name: Log in to GHCR (only while the image is private)
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.CAPTURE_RUNNER_PULL_TOKEN }}

      - name: Record the onboarding tour
        env:
          SLEEPERHIT_API_KEY: ${{ secrets.SLEEPERHIT_API_KEY }}
          APP_DEMO_PASSWORD: ${{ secrets.APP_DEMO_PASSWORD }}
        run: |
          mkdir -p "$RUNNER_TEMP/takes"
          docker run --rm --network host \
            --user "$(id -u):$(id -g)" \
            -e SLEEPERHIT_API_KEY -e APP_DEMO_PASSWORD \
            -v "$GITHUB_WORKSPACE/tours:/work/tours:ro" \
            -v "$RUNNER_TEMP/takes:/work/out" \
            ghcr.io/sleeperhitstudio/capture-runner:0.1.0 \
            capture record tours/onboarding.json --out out/onboarding \
              --secret-env password=APP_DEMO_PASSWORD \
              --app-version "$GITHUB_SHA" \
              --upload --project "${{ vars.SLEEPERHIT_PROJECT_ID }}"

      - name: Keep the take
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: onboarding-take
          path: ${{ runner.temp }}/takes
          if-no-files-found: ignore
```

What each part of the `docker run` does:

| Part | Why |
|---|---|
| `--network host` | Chrome in the container reaches the app the job started on `localhost`. Only the finished take leaves the runner. |
| `--user "$(id -u):$(id -g)"` | The take is written as the runner's own user, so later steps can read and delete it. Without it, the image records as uid 10001, which needs a take directory that uid can write. |
| `-e SLEEPERHIT_API_KEY -e APP_DEMO_PASSWORD` | The container receives each value from the step's environment, by name. A secret value never appears on a command line, where `ps` and logs would keep it. |
| `-v …/tours:/work/tours:ro` | The tours, read-only. The container's working directory is `/work`. |
| `-v …/takes:/work/out` | Where the take is written. |

The recorder checks the project with `--upload` before it records, so a wrong project id or an expired key costs no take. The command prints the take and the uploaded capture as JSON. It exits non-zero when the recording or the upload fails.

### Any other CI

Run the same `docker run`. The runner needs to:
- reach `ghcr.io` (or have the image already);
- have Docker with host networking. Docker Desktop's host networking is opt-in: otherwise, start the app on the host and use `http://host.docker.internal:<port>` as the tour's `baseUrl`.

## Flags

Run `sleeperhit --help` for every flag. These are the ones a CI run uses:

| Flag | What it does |
|---|---|
| `--out <dir>` | Where the take goes. It must be a new or empty directory. |
| `--secret-env <secretRef>=<VAR>` | The environment variable that holds each secret the tour types by `secretRef`. A secret is typed key by key, and only into a field on the app's origins that has the keyboard focus. It is never written to `events.json`, `tour.json` or a log. |
| `--storage-state-env <VAR>` | Start signed in. The variable holds a Playwright storage state (JSON), which goes from memory into the browser and never touches disk. Prefer this to logging in during the tour. |
| `--storage-state <file>` | The same, from a file. The file must be outside any git repository and have mode 600. In CI, use `--storage-state-env`. |
| `--origin-header-env <Header-Name>=<VAR>` | A header for a protected preview, such as `CF-Access-Client-Id` and `CF-Access-Client-Secret` for Cloudflare Access, or `x-vercel-protection-bypass` for Vercel. It is sent only to the app's origins, never to another site, a third-party frame or a redirect away from the app. |
| `--extra-origin <origin>` | Another origin that belongs to the app, such as its API at `https://api.preview.example.com`. It receives the headers above, and secrets may be typed into its fields. Repeat it for each origin. |
| `--app-version <tag>` | The version recorded in `events.json` and on the capture. In CI, use the commit (`$GITHUB_SHA`) or the image tag. Without it, the recorder reads a response header such as `x-app-version`, or a meta tag such as `app-version`. |
| `--sound app` | Record the app's own sound: a table read, a player, a sound effect. The same as `"sound": "app"` in the tour. Each take gets its own PulseAudio sink, so it hears only its own browser. The sound is put on the picture's clock and cut with the picture, so a cut never slides the sound that follows it. It is levelled once, toward -20 LUFS, and never above -1.5 dBTP. Linux only: use the runner image. A Mac refuses the tour before the browser opens. |
| `--audio-from <url\|file>` `[--offset <seconds>]` | Put a sound under the take, lined up on the tour's `audioSyncMark`. It plays on the take's own clock, so the tour's cuts remove it with the picture. Not with `--sound app`. |
| `--browser chrome\|chromium` | `chrome` is the default. Playwright's Chromium cannot play H.264 or AAC media, so an app with video or sound needs Chrome. |
| `--sandbox` | Turn on Chrome's own sandbox. See the next section. |
| `--egress-proxy <url>` | Only with `--sandbox`. Every request goes through this proxy, loopback included, and WebRTC sends no UDP around it. |
| `--upload --project <projectId>` `[--label <text>]` | Upload the take to the project when it is done. |

A preview behind Cloudflare Access:

```sh
docker run --rm --user "$(id -u):$(id -g)" \
  -e CF_ACCESS_CLIENT_ID -e CF_ACCESS_CLIENT_SECRET -e APP_DEMO_PASSWORD \
  -v "$PWD/tours:/work/tours:ro" -v "$PWD/takes:/work/out" \
  ghcr.io/sleeperhitstudio/capture-runner:0.1.0 \
  capture record tours/onboarding.json --out out/onboarding \
    --origin-header-env CF-Access-Client-Id=CF_ACCESS_CLIENT_ID \
    --origin-header-env CF-Access-Client-Secret=CF_ACCESS_CLIENT_SECRET \
    --extra-origin https://api.preview.example.com \
    --secret-env password=APP_DEMO_PASSWORD
```

## Chrome's sandbox in a container

**By default, the recorder runs Chrome without Chrome's own sandbox, as Playwright does.** The container is then the boundary: an unprivileged user, Docker's default seccomp profile and no added capabilities. That is enough when the tour records your own app.

**Turn on `--sandbox` when the tour can reach pages you do not control**, such as third-party embeds or content your users wrote. A compromised page must then also escape Chrome's sandbox.

Chrome's sandbox needs unprivileged user namespaces, and Docker's default seccomp profile refuses them. The image carries a profile that allows them. It is Docker's default profile with one rule added, which lets `clone` and `unshare` make a user namespace without `CAP_SYS_ADMIN`. Take it out of the image and pass it to `docker run`:

```sh
docker run --rm --entrypoint cat ghcr.io/sleeperhitstudio/capture-runner:0.1.0 \
  /etc/capture-runner/seccomp-chrome.json > seccomp-chrome.json

docker run --rm --security-opt seccomp=seccomp-chrome.json --user "$(id -u):$(id -g)" ... \
  ghcr.io/sleeperhitstudio/capture-runner:0.1.0 capture record tours/onboarding.json --out out/onboarding --sandbox
```

Ubuntu 23.10 and later can also restrict user namespaces through AppArmor (`kernel.apparmor_restrict_unprivileged_userns = 1`). The profile alone was enough on an Ubuntu 24.04 CI runner with that restriction on (measured 2026-10-12): Docker's own AppArmor profile confines the container, and the host's restriction did not block it. If `--sandbox` still fails under the profile on some host, allow user namespaces there with `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`.

Never use `--privileged` or `--cap-add SYS_ADMIN` to get the sandbox: both give the container far more than the sandbox needs.

The image has no setuid sandbox helper, so `--sandbox` either runs Chrome in its user-namespace sandbox or stops with this message:

> Chrome's sandbox could not start here. On Linux it needs unprivileged user namespaces …

It never quietly records without the sandbox.

## Writing a tour

```json
{
  "version": 1,
  "name": "onboarding",
  "baseUrl": "http://localhost:3000/",
  "viewport": { "width": 1280, "height": 720, "deviceScaleFactor": 1.5 },
  "allow": [],
  "reset": { "note": "The demo tenant is reseeded by the job before this step." },
  "preroll": [
    { "goto": "/login" },
    { "type": { "into": { "label": "Email" }, "text": "demo@example.com" } },
    { "type": { "into": { "label": "Password" }, "secretRef": "password" } },
    { "click": { "role": "button", "name": "Sign in" } },
    { "waitFor": { "role": "heading", "name": "Dashboard" } }
  ],
  "steps": [
    { "mark": { "id": "start", "emphasis": "establish" } },
    { "click": { "role": "button", "name": "New project" } },
    { "waitFor": { "role": "dialog", "name": "New project" } },
    { "type": { "into": { "label": "Name" }, "text": "Dead Air" } },
    { "click": { "role": "button", "name": "Create" } },
    { "cut": { "until": { "role": "heading", "name": "Dead Air" } } },
    { "hold": 1.5 }
  ]
}
```

- **`preroll`** runs before the recording starts. Log in there, or skip it with `--storage-state-env`. Nothing is
  sent there: a `type` with `submit` is refused (its answer would be on screen at the first frame, and `allow` cannot
  clear it), so a send belongs in `steps`, where its reply is what the take is about. A `secretRef` or `textFromEnv`
  entry may still submit: that is a sign-in form, not a message.
- **Steps** find controls the way a person names them: by `role` and `name`, `label`, `placeholder`, `text` or `testId`. The step kinds are:
  - `goto`, `click`, `hover`, `type`, `select`, `upload` and `scroll`;
  - `waitFor` and `hold`;
  - `mark`, `cut`, `audioSyncMark` and `dismiss`.
- **A `cut`** drops the wait it covers from the recording, as a hard cut.
- **`"sound": "app"`** records what the app plays (a table read, a player). Without it a take is silent. Turn it on for tours that play media, and run them in the runner image.
- **Controls that delete, spend, bill, invite or send are refused unless the tour's `allow` names their category.** The categories are `delete`, `confirm-spend`, `billing`, `invite` and `send`, and an entry can name one control (`{ "category": "send", "name": "Send test email", "why": "…" }`). The recorder stops before the click and writes nothing.
- A secret never sits in the tour: type it with `secretRef` and pass `--secret-env`.

## Troubleshooting

| Symptom | Fix |
|---|---|
| `EACCES` or `permission denied` writing the take | Add `--user "$(id -u):$(id -g)"`, or make the take directory writable by uid 10001. |
| `Chrome's sandbox could not start here` | Pass the image's seccomp profile. On Ubuntu 23.10+, see the AppArmor note above. Or record without `--sandbox`. |
| The tour cannot reach `localhost` | Add `--network host` (Linux). On Docker Desktop, use `host.docker.internal`. |
| `--storage-state must live outside the repository` | Use `--storage-state-env`. |
| `The tour types the secret "password" (secretRef)` | Add `--secret-env password=<VAR>`, and pass that variable into the container with `-e <VAR>`. |
| `exec format error`, or a very slow take, on a Mac | The image is amd64. Docker Desktop emulates it, so run it on an x86-64 runner. |

---

## For maintainers: how the image is built

- **The image:** `docker/capture-runner/Dockerfile`, with `seccomp-chrome.json` beside it.
- **The workflow:** `.github/workflows/capture-runner.yml` ("Capture runner image"). It builds the CLI and the recorder from the commit and packs each into its tarball. It builds the image from those two tarballs (the named build context `pkgs`, never npm), smokes it, and pushes it to GHCR.
- **When it runs:**
  - pull requests that touch the image, the recorder or the smoke: build and smoke, never push;
  - pushes to `main`: build, smoke and push, for any CLI, shared or recorder change;
  - by hand: build and smoke, and push only when asked.
- **The smoke:** `scripts/smoke-capture-runner-image.cjs <image> [--pkgs <dir>]`.
  1. It takes the tarballs back out of the image. With `--pkgs`, they must match the job's byte for byte. Then `scripts/smoke-npm-package.cjs` checks their files and manifests and installs them outside the image.
  2. It checks the image: uid 10001, and the Chrome and Playwright it pins.
  3. It records the recorder's e2e fixture page inside the image, through the entrypoint, with `--sandbox` under the seccomp profile and as the caller's uid.
  4. The image's ffprobe checks the MP4: H.264, 1920×1080, yuv420p, bt709 and constant 30 fps.
- **Visibility:** the GHCR package is **private**. The workflow never changes that. Making it public is the owner's decision.

**Build and smoke it locally** (from the repository root, after `pnpm bootstrap`):

```sh
pnpm --filter @sleeperhit/cli build && pnpm --filter @sleeperhit/capture build
mkdir -p .local/capture-runner-pkgs
(cd packages/cli && npm pack --ignore-scripts --pack-destination ../../.local/capture-runner-pkgs)
(cd packages/capture && npm pack --ignore-scripts --pack-destination ../../.local/capture-runner-pkgs)
docker buildx build --platform linux/amd64 --build-context pkgs=.local/capture-runner-pkgs \
  -t capture-runner:dev --load docker/capture-runner
node scripts/smoke-capture-runner-image.cjs capture-runner:dev --pkgs .local/capture-runner-pkgs
docker image rm capture-runner:dev   # about 1.3 GB unpacked
```

**Moving a pin:**
- **Chrome:** `CHROME_VERSION` and `CHROME_SHA256` come from Google's index (https://dl.google.com/linux/chrome/deb/dists/stable/main/binary-amd64/Packages). Google keeps about ten months of stable `.deb` files, so a pin older than that stops building.
- **Playwright:** `PLAYWRIGHT_VERSION` must equal `packages/capture/package.json`'s `playwright`. The build and `tests/unit/the-capture-runner-image-is-pinned.test.ts` both check it.
- **The base images:** move the digests together with their tags.
