# Use in CI

The same world on every pull request.

This page is executed as written by `packages/cli/src/journeys/tutorials.test.ts`; the recording
is made from the same run.

![Use in CI, recorded](../media/use-in-ci/use-in-ci.gif)

A world in CI is the world in your repo: `.volter/world.json` is committed, the twins are in your
lockfile, and the job runs the same commands you run locally.

## The app

An app that talks to Stripe, with a test suite on Node's own runner. The second test is the one
CI exists for: it proves that a call which would have reached a real vendor cannot.

```json file=package.json
{ "name": "acme-web", "private": true, "dependencies": { "stripe": "^17" } }
```

```text file=.env.example
STRIPE_SECRET_KEY=
```

```js file=ci.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import Stripe from 'stripe';

test('the twin answers', async () => {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  const customers = await stripe.customers.list({ limit: 1 });
  assert.equal(customers.object, 'list');
});

test('a real vendor host is refused in the sandbox', async () => {
  await assert.rejects(fetch('https://api.example.com/v1/anything'));
});
```

```bash
npm install
npm install -g @volter/world
npm install -D @volter/twin-stripe
volter world init
```

## The job

On GitHub, the [Volter World action](../../actions/setup-world/README.md) brings the World up, runs
the job's command inside it and takes it down:

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm install
      - uses: volter-ai/twin/actions/setup-world@main
        with:
          command: node --test --test-reporter=tap ci.test.mjs
```

The same job, spelled out as the three commands the action runs:

```yaml
      - run: npx volter world up --sandbox
      - run: npx volter world run -- node --test --test-reporter=tap ci.test.mjs
      - run: npx volter world down
        if: always()
```

The three commands, as the job runs them:

```bash
volter world up --sandbox
```

```text
acme-web  1 twin up, story loaded
```

`up` loads the default data, so every run starts from the same state. `--sandbox` makes the
redirected clients refuse any destination that is not a twin, so a test that would have reached
a real vendor fails in CI instead of succeeding by accident.

```bash
volter world run -- node --test --test-reporter=tap ci.test.mjs
```

```text
# pass 2
# fail 0
```

```bash
volter world down
```

```text
Stopped acme-web
```

`down` in an `if: always()` step keeps a failed run from leaving twins behind on a self-hosted
runner.

A Node runner (`node --test`, `vitest`, `jest`, `playwright test`) is governed through
`NODE_OPTIONS`. Bun ignores `NODE_OPTIONS`, so a `bun test` suite adds one dormant line at the top
of its setup file, `import '@volter/world-core/attach'`, which does nothing outside a world.

## Sharding

Each shard checks out its own copy of the repo, so each shard has its own world root and its
own branch. Nothing is shared and nothing needs a name.

## Against real data

A job that should run against the account as it is now clones the remote with a token from the
job's secrets:

```yaml
      - run: bunx volter world clone https://twins.example.com/acme/web --token "$VOLTER_TOKEN"
        env:
          VOLTER_TOKEN: ${{ secrets.VOLTER_READ_TOKEN }}
```

Use the read token: it opens the remote's history and nothing that writes. A job that pushes
needs a token that can, and the remote's deploy policy decides whether an unreviewed changeset is
deployed at all.

## Reading a failure

The job's `volter world log` is the same log you read locally. Print it in a failing step:

```yaml
      - run: bunx volter world log
        if: failure()
```

## A preview World for each pull request

A team that shares a World on a platform can give each pull request its own copy: a branch of the
shared World, made when the pull request opens, kept on each push, and removed when it closes.
Everyone in the org opens it from the link the action comments on the pull request, signed in as
themselves; an app deployed for the pull request points its SDKs at the preview's address.

Make an org token that may write Worlds (the org's Settings, Org tokens, Full access) and keep it
as the repository secret `VOLTER_TOKEN`. Then:

```yaml
name: preview
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
permissions:
  pull-requests: write
jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: volter-ai/twin/actions/setup-world@main
        id: world
        with:
          mode: ${{ github.event.action == 'closed' && 'remove-preview' || 'preview' }}
          platform: https://app.volter.ai
          token: ${{ secrets.VOLTER_TOKEN }}
          world: acme/web
      - run: echo "the app for this pull request talks to ${{ steps.world.outputs.preview-base }}"
        if: github.event.action != 'closed'
```

The preview is named `pr-<number>`. Asked again on the next push, the action keeps it (`replace:
true` makes a fresh one from the shared World as it is now). It ends on its own after `ttl-days`
(7 by default, a week at most), so a pull request closed while the job could not run leaves nothing
for long. The preview is made with a key for the org token that asked: revoking that token ends it,
and the `preview-token` output is a key to the preview, never its own token. The
comment is found by a marker and edited in place, so a pull request has one.

Underneath, the action calls the platform's preview endpoints, which any CI can call with the same
token:

```yaml
      - run: |
          curl -sf -X PUT "$PLATFORM/-/worlds/acme/web/previews/pr-$PR" \
            -H "authorization: Bearer $VOLTER_TOKEN" -H 'content-type: application/json' -d '{"ttlDays":7}'
      # and when it closes
      - run: curl -sf -X DELETE "$PLATFORM/-/worlds/acme/web/previews/pr-$PR" -H "authorization: Bearer $VOLTER_TOKEN"
```

The answer carries the preview's `open` link, its `base` address and its `token`.

## GitLab CI

```yaml
test:
  image: node:22
  script:
    - npm install
    - npx volter world up --sandbox
    - npx volter world run -- node --test ci.test.mjs
  after_script:
    - npx volter world down

preview:
  image: node:22
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - >
      curl -sf -X PUT "$PLATFORM/-/worlds/acme/web/previews/mr-$CI_MERGE_REQUEST_IID"
      -H "authorization: Bearer $VOLTER_TOKEN" -H 'content-type: application/json' -d '{"ttlDays":7}'
```

## Any other CI

Three commands in the app's folder, the last one whatever happened:

```yaml
- npx volter world up --sandbox
- npx volter world run -- <your test command>
- npx volter world down   # always
```

<!-- playback:BEGIN — GENERATED by `bun scripts/docs-media.ts`; do not edit between markers -->

## Playback

Each command above, as the recording shows it.

<details><summary><code>npm install</code></summary>

![step 1](../media/use-in-ci/step-01.png)

</details>
<details><summary><code>npm install -g @volter/world</code></summary>

![step 2](../media/use-in-ci/step-02.png)

</details>
<details><summary><code>npm install -D @volter/twin-stripe</code></summary>

![step 3](../media/use-in-ci/step-03.png)

</details>
<details><summary><code>volter world init</code></summary>

![step 4](../media/use-in-ci/step-04.png)

</details>
<details><summary><code>volter world up --sandbox</code></summary>

![step 5](../media/use-in-ci/step-05.png)

</details>
<details><summary><code>volter world run -- node --test --test-reporter=tap ci.test.mjs</code></summary>

![step 6](../media/use-in-ci/step-06.png)

</details>
<details><summary><code>volter world down</code></summary>

![step 7](../media/use-in-ci/step-07.png)

</details>

<!-- playback:END -->
