> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mobileboost.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Local testing in CI

> Start and stop a tunnel around a test run in your CI pipeline

A CI job can open a tunnel, run tests against it, and close it again. The one
thing to get right first is **where** the tunnel runs.

## Pick the right runner

The tunnel has to run somewhere that can already reach your internal services.

| Your setup                                        | What to do                                                                                                                                                                    |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Self-hosted runners inside your network           | Run `mb-local` in the job. This is the common case, and the snippets below cover it                                                                                           |
| Cloud-hosted runners (GitHub-hosted, GitLab SaaS) | These sit outside your network and cannot reach your staging environment, so a tunnel started there has nothing to offer. Use a [shared tunnel](#run-a-shared-tunnel) instead |
| A mix of both                                     | Use a shared tunnel. Jobs reference it by name wherever they run                                                                                                              |

<Warning>
  Starting `mb-local` on a cloud-hosted runner does not fail loudly. The tunnel
  connects, and then every request through it fails because the runner cannot
  reach your services either. If your runners are not inside your network, use a
  shared tunnel.
</Warning>

## GitHub Actions

```yaml theme={null}
name: Tests against staging

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: [self-hosted, internal-network]
    steps:
      - uses: actions/checkout@v4

      - name: Start MobileBoost tunnel
        uses: MobileBoostHQ/setup-local-tunnel@v1
        with:
          api-key: ${{ secrets.MOBILEBOOST_API_KEY }}
          only-hosts: "*.acme.internal,10.0.0.0/8"

      - name: Run tests
        run: |
          curl -X POST https://api.mobileboost.io/tests/execute \
            -H "Content-Type: application/json" \
            -d '{
              "organisationId": "'"${{ secrets.MOBILEBOOST_ORG_ID }}"'",
              "tags": ["critical"],
              "tunnelName": "'"$MOBILEBOOST_TUNNEL_NAME"'"
            }'
```

The action handles three things that are easy to get wrong by hand:

* It picks a **unique tunnel name per job**, derived from the run ID, the attempt
  number, and the runner. Two jobs never collide.
* It **waits until the tunnel is usable** before the step finishes, so your tests
  never start against a tunnel that is not up yet.
* It **stops the tunnel in a post step**, which runs even when the job fails or is
  cancelled.

It exports the name it chose as `MOBILEBOOST_TUNNEL_NAME` for later steps.

### Inputs

| Input           | Required | Description                                                   |
| --------------- | -------- | ------------------------------------------------------------- |
| `api-key`       | Yes      | Your MobileBoost API key. Store it as a repository secret     |
| `only-hosts`    | No       | Hosts this tunnel may reach. Strongly recommended             |
| `exclude-hosts` | No       | Hosts it may never reach. Wins over `only-hosts`              |
| `tunnel-name`   | No       | Override the generated name. Only useful with a shared tunnel |
| `version`       | No       | Pin a binary version. Defaults to the latest release          |

## Other CI providers

Without a ready-made action, do the same three things by hand: unique name, wait
for ready, and stop it in a step that always runs.

<Tabs>
  <Tab title="GitLab CI">
    ```yaml theme={null}
    test:
      stage: test
      tags: [internal-network]
      before_script:
        - curl -fsSL https://get.mobileboost.io/mb-local/latest/linux-amd64 -o mb-local
        - chmod +x mb-local
        - ./mb-local --daemon start
            --tunnel-name "gl-$CI_JOB_ID"
            --only-hosts "*.acme.internal"
            --wait-for-ready 60s
      script:
        - ./run-mobileboost-tests.sh "gl-$CI_JOB_ID"
      after_script:
        - ./mb-local --daemon stop --tunnel-name "gl-$CI_JOB_ID"
    ```

    Add `MOBILEBOOST_API_KEY` as a masked project variable under
    **Settings > CI/CD > Variables**. It is then already in the environment, so
    nothing needs to pass it explicitly.

    `after_script` runs even when `script` fails, which is what makes the cleanup
    reliable. `$CI_JOB_ID` is unique per job, so parallel jobs never collide.
  </Tab>

  <Tab title="Bitrise">
    Store your key as a Bitrise **Secret** named `MOBILEBOOST_API_KEY`, then add
    a **Script** step before your test step:

    ```bash theme={null}
    curl -fsSL https://get.mobileboost.io/mb-local/latest/darwin-arm64 -o mb-local
    chmod +x mb-local

    ./mb-local --daemon start \
               --tunnel-name "bitrise-$BITRISE_BUILD_NUMBER" \
               --only-hosts "*.acme.internal" \
               --wait-for-ready 60s

    envman add --key MOBILEBOOST_TUNNEL_NAME --value "bitrise-$BITRISE_BUILD_NUMBER"
    ```

    Then stop it in a step with **Run if previous Step failed** enabled, so it
    also runs on failure:

    ```bash theme={null}
    ./mb-local --daemon stop --tunnel-name "$MOBILEBOOST_TUNNEL_NAME"
    ```
  </Tab>

  <Tab title="Jenkins">
    ```groovy theme={null}
    pipeline {
        agent { label 'internal-network' }

        environment {
            MOBILEBOOST_API_KEY = credentials('mobileboost-api-key')
            TUNNEL_NAME   = "jenkins-${env.BUILD_TAG}"
        }

        stages {
            stage('Start tunnel') {
                steps {
                    sh '''
                        curl -fsSL https://get.mobileboost.io/mb-local/latest/linux-amd64 -o mb-local
                        chmod +x mb-local
                        ./mb-local --daemon start \
                                   --tunnel-name "$TUNNEL_NAME" \
                                   --only-hosts "*.acme.internal" \
                                   --wait-for-ready 60s
                    '''
                }
            }
            stage('Run tests') {
                steps {
                    sh './run-mobileboost-tests.sh "$TUNNEL_NAME"'
                }
            }
        }

        post {
            always {
                sh './mb-local --daemon stop --tunnel-name "$TUNNEL_NAME" || true'
            }
        }
    }
    ```

    The `post { always { ... } }` block is what guarantees cleanup on failure and
    on abort.
  </Tab>

  <Tab title="CircleCI">
    ```yaml theme={null}
    version: 2.1

    jobs:
      test:
        machine: true
        steps:
          - checkout
          - run:
              name: Start MobileBoost tunnel
              command: |
                curl -fsSL https://get.mobileboost.io/mb-local/latest/linux-amd64 -o mb-local
                chmod +x mb-local
                ./mb-local --daemon start \
                           --tunnel-name "circle-$CIRCLE_WORKFLOW_JOB_ID" \
                           --only-hosts "*.acme.internal" \
                           --wait-for-ready 60s
          - run:
              name: Run tests
              command: ./run-mobileboost-tests.sh "circle-$CIRCLE_WORKFLOW_JOB_ID"
          - run:
              name: Stop MobileBoost tunnel
              when: always
              command: ./mb-local --daemon stop --tunnel-name "circle-$CIRCLE_WORKFLOW_JOB_ID"
    ```

    `when: always` makes the cleanup step run even after a failure.
  </Tab>
</Tabs>

## Give every job a unique tunnel name

A tunnel name identifies one connection. If two jobs use the same name at the
same time, the second one is refused rather than silently taking over the first.

Build the name from something your CI guarantees is unique:

| CI             | Use                                                                        |
| -------------- | -------------------------------------------------------------------------- |
| GitHub Actions | `${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }}` |
| GitLab CI      | `$CI_JOB_ID`                                                               |
| Bitrise        | `$BITRISE_BUILD_NUMBER`                                                    |
| Jenkins        | `$BUILD_TAG`                                                               |
| CircleCI       | `$CIRCLE_WORKFLOW_JOB_ID`                                                  |

If you omit `--tunnel-name`, `mb-local` derives one from these variables itself.

<Note>
  A matrix build needs the matrix index too. The run ID alone is shared by every
  job in the matrix, so five parallel jobs would fight over one name and four of
  them would be refused.
</Note>

## Wait for the tunnel, do not sleep

`--wait-for-ready` blocks until the tunnel can actually carry traffic, then keeps
running. Use it instead of `sleep`:

```bash theme={null}
./mb-local --daemon start --tunnel-name "$NAME" --wait-for-ready 60s
```

If the tunnel is not usable within the timeout, the command exits non-zero and
your job fails with a clear reason. That is much better than a run where every
test times out.

## Run a shared tunnel

For teams running many jobs, one long-lived tunnel beats one tunnel per job. It
removes tunnel setup from every pipeline, works for cloud-hosted runners, and
removes name collisions entirely.

Run it on any host inside your network: a small VM, a container in the cluster
that already hosts staging, or an existing build server.

<CodeGroup>
  ```ini systemd theme={null}
  [Unit]
  Description=MobileBoost Local tunnel
  After=network-online.target

  [Service]
  Environment=MOBILEBOOST_API_KEY=your-api-key
  ExecStart=/usr/local/bin/mb-local \
    --tunnel-name acme-shared \
    --only-hosts '*.acme.internal,10.0.0.0/8' \
    --links 8
  Restart=always
  RestartSec=5

  [Install]
  WantedBy=multi-user.target
  ```

  ```yaml kubernetes theme={null}
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: mobileboost-tunnel
  spec:
    replicas: 1
    selector:
      matchLabels: {app: mobileboost-tunnel}
    template:
      metadata:
        labels: {app: mobileboost-tunnel}
      spec:
        containers:
          - name: tunnel
            image: mobileboost/local-tunnel:latest
            args:
              - --tunnel-name=acme-shared
              - --only-hosts=*.acme.internal,10.0.0.0/8
              - --links=8
            env:
              - name: MOBILEBOOST_API_KEY
                valueFrom:
                  secretKeyRef: {name: mobileboost, key: api-key}
  ```
</CodeGroup>

Jobs then reference it by name and start nothing themselves:

```json theme={null}
{
  "organisationId": "org123",
  "tags": ["critical"],
  "tunnelName": "acme-shared"
}
```

<Tip>
  Raise `--links` on a shared tunnel. It sets how many parallel connections carry
  traffic, so a large test run does not queue behind itself. Eight is a good
  starting point for a tunnel serving a whole team.
</Tip>

Keep exactly one process per shared tunnel name. Two replicas would both claim
the name, and the second is refused.

## Keep the access key out of your logs

The access key is a credential. Store it in your CI secret store and pass it
through the environment rather than `--key`, so it never appears in a command
line that gets logged.

```bash theme={null}
export MOBILEBOOST_API_KEY="$MOBILEBOOST_API_KEY"
./mb-local --tunnel-name "$NAME"
```

`mb-local` never writes the key to its own logs at any verbosity.

## Check the exit code

CI should tell a wrong key apart from a network blip. `mb-local` uses distinct
exit codes so you can:

| Code | Meaning                                               | Retry?                    |
| ---- | ----------------------------------------------------- | ------------------------- |
| `0`  | Clean exit                                            | -                         |
| `10` | API key rejected                                      | No                        |
| `11` | Cannot reach MobileBoost                              | Maybe, check egress first |
| `12` | Tunnel could not be established                       | Maybe                     |
| `13` | A tunnel with this name is already connected          | No, fix the name          |
| `15` | The key is valid but lacks the `tunnel:connect` scope | No, ask an administrator  |
| `20` | Tunnel limit for your organisation reached            | No                        |
| `22` | Binary too old                                        | No, upgrade               |

Full list in the [CLI reference](/test-agent/local-testing-reference#exit-codes).
