Cubicle

Run CI jobs in Boxes

Run each CI job or scripted task in a fresh Cubicle Box created with a service account API key, with a GitHub Actions workflow that runs tests in a new Box per run, cleanup of leftover Boxes, the cubicle CLI with CB_CLI_API_KEY in shell scripts, grants on existing Boxes and what a CI job cannot do in a Box.

Run each CI job in a Box that the job creates when it starts and deletes when it ends. A new Box's disk is a fresh copy of its base image, or of a template snapshot you choose, so nothing an earlier run installed or wrote is on it. The job's code runs in its own virtual machine with its own kernel, and Boxes cannot reach each other over the network. There is no runner to install, register or update in the Box: the job drives it through the API with a service account's API key. Deleting the Box removes its disk and snapshots and releases the CPU, memory and disk it reserved.

This page builds a GitHub Actions job that runs tests in a fresh Box, makes sure every Box it creates is deleted, then does the same from a shell script with the CLI and shows how to use Boxes that already exist. A service account is a non-interactive member of one organization, such as acme/ci, whose API keys let jobs create Boxes, run commands and delete them without a person signing in. Sign in and API keys covers keys and scopes, and Organizations and access covers roles and grants.

Before you begin

  • Create a service account ci in acme and a write key named github-actions, as in Create a service account and a key. This needs the owner or admin role and a person's login: API keys cannot manage service accounts, keys or grants.
  • The organization must allow Box creation. Otherwise creating a Box fails with 403 box_create_disabled.
  • Store the key as a secret in your CI system. The workflow below reads a GitHub Actions repository secret named CUBICLE_API_KEY.

The SDK reads CB_SDK_API_URL and CB_SDK_API_KEY, and the CLI reads CB_CLI_API_URL and CB_CLI_API_KEY. The API URL is https://api.cubicle.sh. To check a key, run CB_CLI_API_URL=https://api.cubicle.sh CB_CLI_API_KEY=cbk_… cubicle whoami, which prints acme/ci (acme:member).

Run tests in a Box from GitHub Actions

This job creates a Box for each workflow run, runs a test script in it, reports the script's exit code as the job's result and deletes the Box. The script travels in the exec request, which is limited to 64 KiB. Move files into and out of a Box covers larger inputs.

  1. Add the script to run in the Box:

    ci/test.sh
    set -eu
    uname -a
    python3 -c 'print("tests passed")'
  2. Add the driver. Node 24 runs this TypeScript file directly:

    ci/run-in-box.mts
    import { readFile } from "node:fs/promises"
    import { Cubicle, type Box } from "@cubicle/sdk"
    
    const cubicle = new Cubicle() // reads CB_SDK_API_URL and CB_SDK_API_KEY
    const name = process.env.BOX_NAME ?? "ci-local"
    const script = await readFile("ci/test.sh", "utf8")
    let box: Box | undefined
    
    try {
      // wait: false returns the handle at once, so cleanup has it even if the Box fails to start.
      box = await cubicle.boxes.create({ name }, { wait: false })
      await box.waitUntilReady()
      const result = await box.exec(["sh", "-c", script], { timeoutMs: 600_000 })
    
      process.stdout.write(result.stdout)
      process.stderr.write(result.stderr)
      if (result.truncated) console.error("output was truncated at 64 KiB")
      process.exitCode = result.timedOut ? 1 : result.exitCode
    } finally {
      await box?.delete() // waits until the Box is removed
    }
  3. Add the workflow:

    .github/workflows/box-tests.yml
    name: Box tests
    on: push
    
    jobs:
      test:
        runs-on: ubuntu-latest
        timeout-minutes: 30
        env:
          BOX_NAME: ci-${{ github.run_id }}-${{ github.run_attempt }}
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: 24
          - run: npm install @cubicle/sdk
          - name: Run tests in a Box
            run: node ci/run-in-box.mts
            env:
              CB_SDK_API_URL: https://api.cubicle.sh
              CB_SDK_API_KEY: ${{ secrets.CUBICLE_API_KEY }}
          # A cancelled job never reaches the script's finally block.
          - name: Delete the Box after a cancellation
            if: cancelled()
            run: |
              curl -fsSL https://api.cubicle.sh/downloads/install | sh -s -- --yes --no-tunnel
              ~/.local/bin/cubicle boxes delete "acme/$BOX_NAME"
            env:
              CB_CLI_API_URL: https://api.cubicle.sh
              CB_CLI_API_KEY: ${{ secrets.CUBICLE_API_KEY }}
  4. Push a commit. The Run tests in a Box step's log ends with:

    Linux box-ci-… GNU/Linux
    tests passed
  • The Box name is unique per run and attempt, because Box names must be unique among an organization's live Boxes.
  • The SDK sends an idempotency key with every create and exec, and reuses it when it retries, so its own retries never create a second Box or run the script twice. To make a retry from a new process safe, see Idempotency keys.
  • Keep the job's timeout-minutes above the sum of its waits, so the job is not killed before its cleanup runs. Waiting lists each call's default limit.
  • Errors and retries explains each error the driver can throw and which ones the SDK already retried.
  • All keys of a service account, and every process that uses them, share one rate-limit budget. When several jobs run at once, see Rate limits and Poll pacing and the rate-limit budget.

Clean up Boxes

A Box keeps its reserved CPU, memory and disk until it is deleted, even while it is stopped or hibernated. Delete every Box a job creates:

  • Delete in a finally block, as the driver above does. When the wait inside create fails, the Box still exists. The error usually carries its record, but creating with { wait: false } and then calling waitUntilReady() gives you the handle first.
  • Delete by the handle or the Box's ID rather than by name, so cleanup never removes another Box that has the same name.
  • delete waits until the Node has removed the Box and released its resources. Pass { wait: false } to return once the deletion is accepted.
  • Deleting a Box also deletes its snapshots.
  • A job that is killed skips its finally block. Add a cancellation step, as in the workflow above, or sweep for leftovers on a schedule:
const cutoff = Date.now() - 6 * 60 * 60_000 // 6 hours ago

for await (const info of cubicle.boxes.list()) {
  if (
    info.name.startsWith("ci-") &&
    info.deletion === null &&
    Date.parse(info.createdAt) < cutoff
  ) {
    await cubicle.boxes.delete(info.id, { wait: false })
  }
}

Use the CLI in scripts

Set CB_CLI_API_URL and CB_CLI_API_KEY to use a key instead of a stored login:

#!/bin/sh
set -eu
export CB_CLI_API_URL=https://api.cubicle.sh
export CB_CLI_API_KEY="$CUBICLE_API_KEY"

cubicle boxes create --org acme job-1 --no-start
trap 'cubicle boxes delete acme/job-1' EXIT
cubicle boxes start acme/job-1 --wait
cubicle exec acme/job-1 --timeout 300000 -- sh -c 'uname -a && python3 --version'
  • The script creates the Box stopped and sets the EXIT trap before it starts the Box, so a failed start still deletes it. If the create itself fails, for example because the name is taken, the trap is not set and no other Box is touched.
  • cubicle exec exits with the command's exit code, so set -e stops the script when the command fails. The trap deletes the Box either way.
  • Name Boxes as ORG/NAME or by ID. The CLI does not accept a bare name.
  • Add --json for machine-readable output. cubicle boxes get always prints JSON.
  • cubicle boxes create sends a new idempotency key on every run. Running it again after a lost response fails with 409 conflict "a box named job-1 already exists".
  • The CLI does not retry a 429 rate_limited response: it exits 1. Run only a few CLI waits at once per service account.
  • Commands that need a person's login, such as grants, ssh-config and ssh, do not work with a key. API-key mode lists which commands do.

Grant access to existing Boxes

A Box the service account creates is granted to it automatically. For any other Box, an owner or admin adds a grant with the service account's ID, the first column of cubicle service-accounts list --org acme, as in Grant access to a Box. It needs the grant even with the admin role: without one, exec fails with 403 forbidden "an explicit box grant is required". Right after a new grant, an exec can fail with an error ending in unknown guest account: retry it after a few seconds.

What a CI job cannot do

  • Workflow steps do not run in the Box. The runner runs the steps and actions; only the commands you send with exec run in the Box, and the runner's checkout is not copied into it. Send small inputs in the command, as the driver does with ci/test.sh, and larger ones as in Move files into and out of a Box.
  • No sudo. Commands sent with an API key cannot rely on sudo, so a job may not be able to install system packages. Create the job's Boxes from a template Box that has them instead; every Box created from a snapshot goes on the snapshot's Node.
  • At most 10 minutes per command, with output at the end. Output arrives when the command ends, with no live log, and is capped at 1 MiB. Split a longer suite as in Run work longer than 10 minutes.
  • About an hour per start. A Box hibernates 60 minutes after each start, whatever is running, and a command still running then ends unknown.
  • No connections into the Box from the runner. API keys cannot use SSH or the private network, and Boxes have no public IP address or port. To test a service the job starts in the Box, run its client in the Box with exec, against localhost.
  • No other Boxes or private networks. A Box reaches the public IPv4 internet only: not other Boxes, private address ranges or IPv6. Run every part of a multi-service test in one Box.

When not to use a Box covers the rest, such as GPUs and other CPU architectures.

Troubleshooting

  • 403 box_create_disabled "this organization cannot create boxes; ask an operator to enable it": the organization does not allow Box creation.
  • 409 conflict "a box named ci-… already exists": a Box with that name is still live. Use a unique name per run, or the same idempotency key for retries.
  • 503 capacity "no healthy node with the default base image has capacity for this box": no Node can place the Box now. Retry later, or ask for a smaller size.
  • SDK missing_api_url or missing_api_key: the job does not set CB_SDK_API_URL and CB_SDK_API_KEY. Set both, as the workflow above does, or pass apiUrl and apiKey to new Cubicle().
  • Key, scope, grant and rate-limit errors, such as 401 unauthenticated "API key is invalid, expired or revoked", 403 insufficient_scope, 403 api_key_not_allowed, 403 forbidden "an explicit box grant is required" or "box grant or organization admin role required", 429 rate_limited, 403 blocked and CB_CLI_API_KEY requires CB_CLI_API_URL: Troubleshooting has the fix for each.

On this page