Skip to content

WonderTwin in GitHub Actions

Run a twin against your test suite in a GitHub Actions workflow. Capture replay artifacts on every run, including failures.

Drop this into .github/workflows/test.yml (or merge into your existing workflow):

name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 1. Install wt CLI
- name: Install wt
run: |
curl -sSL "https://github.com/wondertwin-ai/wondertwin/releases/latest/download/wt-linux-amd64" -o /usr/local/bin/wt
chmod +x /usr/local/bin/wt
# 2. Install license (soft-gate: optional but recommended)
- name: Install WonderTwin license
if: env.WONDERTWIN_LICENSE != ''
env:
WONDERTWIN_LICENSE: ${{ secrets.WONDERTWIN_LICENSE }}
run: |
printf '%s' "$WONDERTWIN_LICENSE" > "$RUNNER_TEMP/license.json"
wt license install "$RUNNER_TEMP/license.json"
rm "$RUNNER_TEMP/license.json"
# 3. Install + start the twin
- name: Start twin-<TWIN>
run: |
wt install <TWIN>@<TWIN_VERSION>
wt up > "$RUNNER_TEMP/twin.log" 2>&1 &
echo "TWIN_PID=$!" >> "$GITHUB_ENV"
# health check
for i in {1..30}; do
curl -sf http://127.0.0.1:<TWIN_PORT>/admin/state > /dev/null && break
sleep 0.2
done
# 4. Start a run
- name: Start run
env:
CI: "true"
run: |
wt runs start \
--twin http://127.0.0.1:<TWIN_PORT> \
--seed 42 \
--run-id "${{ github.run_id }}-${{ github.run_attempt }}"
# 5. Run your tests — they hit $WT_TWIN_URL
- name: Test
run: <TEST_COMMAND>
# 6. Finish the run + export — runs on success AND failure
- name: Finish run + export replay
if: always()
run: |
wt runs finish \
--twin http://127.0.0.1:<TWIN_PORT> \
--run-id "${{ github.run_id }}-${{ github.run_attempt }}" \
--export "$RUNNER_TEMP/wondertwin-replay.jsonl.gz"
# 7. Upload the replay as a build artifact — runs on success AND failure
- name: Upload replay
if: always()
uses: actions/upload-artifact@v4
with:
name: wondertwin-replay
path: ${{ runner.temp }}/wondertwin-replay.jsonl.gz
retention-days: 14
# 8. Cleanup
- name: Stop twin
if: always()
run: |
if [ -n "$TWIN_PID" ]; then
kill "$TWIN_PID" 2>/dev/null || true
sleep 2
kill -9 "$TWIN_PID" 2>/dev/null || true
fi
  1. Pick your twin and version. Replace <TWIN> (e.g. stripe, twilio, posthog) and <TWIN_VERSION> (e.g. 0.1.0 or latest). Replace <TWIN_PORT> with the twin’s default port (twin-stripe: 4111; check the twin’s README for others).
  2. Replace <TEST_COMMAND> with your actual test invocation (npm test, go test ./..., pytest, etc.).
  3. Set the license secret. In your GitHub repo: Settings → Secrets and variables → Actions → New repository secret. Name: WONDERTWIN_LICENSE. Value: the full contents of the license JSON file (paste it as-is; do not base64-encode).
    • If you don’t have a license yet, the workflow runs anyway with a soft-gate warning in the logs. Replay artifacts still produced.
    • To get a license, contact sales (link forthcoming).
  4. Tell your test code where the twin is. Most SDKs accept a base URL override:
    • Stripe: Stripe.api_base = ENV['WT_TWIN_URL'] || ENV['TWIN_URL']
    • Or set the SDK’s environment variable that controls the base URL
    • The action exports both WT_TWIN_URL and TWIN_URL to subsequent steps.
  5. Adjust retention-days to match your team’s needs. 14 is a safe default for debugging; longer retention costs more GitHub Actions storage.

Pass a --fixtures file to seed the twin with realistic data:

- name: Start run with fixtures
run: |
wt runs start \
--twin http://127.0.0.1:4111 \
--seed 42 \
--fixtures fixtures/stripe-snapshot.json \
--run-id "${{ github.run_id }}-${{ github.run_attempt }}"

The fixtures file must conform to the twin’s snapshot schema. See concepts.md on BYO synthetic data.

Each twin needs its own port. Run two wt up invocations against separate manifest files, or split twins across jobs.

- name: Start twin-stripe
run: |
wt install stripe@0.1.0
PORT=4111 wt up > $RUNNER_TEMP/stripe.log 2>&1 &
echo "STRIPE_PID=$!" >> $GITHUB_ENV
- name: Start twin-twilio
run: |
wt install twilio@0.1.0
PORT=4112 wt up > $RUNNER_TEMP/twilio.log 2>&1 &
echo "TWILIO_PID=$!" >> $GITHUB_ENV

The simpler pattern is one twin per job. Multi-twin in one job is supported but you own the port management.

Without an explicit run lifecycle (using wt runs wrap)

Section titled “Without an explicit run lifecycle (using wt runs wrap)”

If your test command is a single shell invocation, you can collapse start + finish into one step:

- name: Test with wrap
run: |
wt runs wrap \
--twin http://127.0.0.1:4111 \
--seed 42 \
--run-id "${{ github.run_id }}-${{ github.run_attempt }}" \
--export "$RUNNER_TEMP/wondertwin-replay.jsonl.gz" \
-- <TEST_COMMAND>

wrap always finishes the run on exit (success, failure, or signal). Less customizable than the explicit lifecycle; cleaner for simple test commands.

Pin to specific versions to make CI runs reproducible across time:

- run: |
curl -sSL "https://github.com/wondertwin-ai/wondertwin/releases/download/v0.4.2/wt-linux-amd64" -o /usr/local/bin/wt
chmod +x /usr/local/bin/wt
- run: wt install stripe@0.1.0

My replay artifact is empty / truncated when the test step fails

Section titled “My replay artifact is empty / truncated when the test step fails”

The shutdown path needs grace. If your test step is killed by a workflow timeout (rather than exiting normally), the wt runs finish step may not have time to drain the telemetry/replay buffer.

The if: always() guards on the finish + upload steps cover most cases. If you’re hitting hard timeouts, increase the workflow’s timeout-minutes to give the cleanup steps room.

See concepts.md on shutdown grace.

The health check loop is 30 polls × 200ms = 6 seconds. For complex twins or slow CI runners, increase the loop count or the sleep interval. If the twin reliably takes longer than that to start, file an issue — that’s a regression we want to know about.

Confirm WT_TWIN_URL and TWIN_URL are visible to your test code. Some test runners scrub env vars; if so, pass the URL explicitly:

- name: Test
env:
STRIPE_API_BASE: http://127.0.0.1:4111
run: npm test

Three common causes:

  1. The secret was base64-encoded when pasted — paste the JSON as-is.
  2. The license expired — check wt license status.
  3. The license twin_scope doesn’t include the twin you’re running — check the JSON, scope should be ["*"] or include your twin name.

I’m running self-hosted GitHub Actions runners

Section titled “I’m running self-hosted GitHub Actions runners”

The snippet works as-is. If your runners don’t have curl installed, replace the wt install step with whatever your image provides (wget, pre-baking the binary, etc.).