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

# CI/CD

> Run the Xano SDK release flow from GitHub Actions: headless credentials, npm run xano:check and --frozen-lock on every change, a preview ephemeral for each pull request, a release cut and landed on every tag, the xano.lock landing record committed back, and how to handle exit codes 2, 7 and 9.

This page is the complete pipeline: every change is checked, every pull request gets a preview
on its own ephemeral, and every tag becomes a release that lands on your workspace or your
tenants. [Shipping workflow](/ship/shipping-workflow) explains the flow itself; this page is how
to run it unattended.

## Credentials for a headless run

CI signs in with three environment variables instead of `xanosdk login`, which waits for a
browser:

| Variable | Value |
| - | - |
| `XANO_INSTANCE_URL` | Your instance, such as `https://your-instance.xano.io`. |
| `XANO_WORKSPACE_ID` | The numeric workspace id, such as `3`. |
| `XANO_META_TOKEN` | A meta API token for that workspace. |

Store them as repository secrets. Set **all three or none**: setting some but not all is a hard
error that names the missing ones, so a misspelled secret never falls back to another credential.
Together they outrank every other credential, read no file and never rotate, so the same three
secrets work on every run.

Avoid `XANO_REFRESH_TOKEN` in CI. Xano rotates a refresh token each time it is used, so a stored
one works once and the next run fails.

### More than one workspace

One credential addresses one instance and one workspace. To reach a second one from the same
job, store a profile for each and pick it per command with `--profile`. Do not set the three
variables in that job: an environment credential beside an explicit `--profile` is a hard error.

```bash theme={null}
printf %s "$PROD_META_TOKEN" | npx xanosdk profile add prod --instance https://your-instance.xano.io --workspace-id 3
npx xanosdk promote v1.4.0 --profile prod --set-live --yes
```

[Across workspaces](/ship/across-workspaces) covers `release transfer` between them.

## Checks that need no credentials

A scaffolded project has `npm run xano:check`. It typechecks the project, then runs
`xanosdk export ./xano/index.ts --check --strict`: every check an export runs, writing nothing, so
it needs no secrets and no `xano/.env`.

* `--strict` turns every export warning into a failure.
* `--check` implies `--frozen-lock`: the run fails rather than change `xano/xano.lock`. That
  catches a change whose lock update was not committed, and stops CI minting an identity or a
  public URL token that the committed lock does not hold. It also fails on a lock entry no
  object matches, which is what an unfinished rename looks like. See
  [Git, locks & merges](/govern/git-and-merges).

`--frozen-lock` is also a flag on `deploy` and `export`, for a job that builds without running
the full check.

## Env values in CI

CI has no `xano/.env`. A deploy to an ephemeral replaces the ephemeral's env set, so it refuses
when a name your `workspaceConfig` declares has no value. Store the file's contents as a secret
(`XANO_BACKEND_ENV` below) and write it to `xano/.env` in the job. The file is gitignored, so the
job never commits it. A project that declares no env names can drop that step. See
[Env and secrets](/build/env-and-secrets).

## The workflow

<Tabs>
  <Tab title="Workspace as production">
    ```yaml theme={null}
    # .github/workflows/xano.yml
    name: xano
    on:
      pull_request:
      push:
        branches: [main]
        tags: ["v*"]

    jobs:
      # Every change: typecheck, check the export against the committed xano.lock.
      check:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with: { node-version: 20, cache: npm }
          - run: npm ci
          - run: npm run xano:check
          - run: git diff --exit-code

      # Every pull request: its own ephemeral, with the tests run there.
      preview:
        if: github.event_name == 'pull_request'
        needs: check
        runs-on: ubuntu-latest
        concurrency:
          group: xano-preview-${{ github.event.pull_request.number }}
          cancel-in-progress: true
        env:
          XANO_INSTANCE_URL: ${{ secrets.XANO_INSTANCE_URL }}
          XANO_WORKSPACE_ID: ${{ secrets.XANO_WORKSPACE_ID }}
          XANO_META_TOKEN: ${{ secrets.XANO_META_TOKEN }}
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with: { node-version: 20, cache: npm }
          - run: npm ci && npm run build
          - name: Write xano/.env
            run: printf '%s\n' "$XANO_BACKEND_ENV" > xano/.env
            env: { XANO_BACKEND_ENV: "${{ secrets.XANO_BACKEND_ENV }}" }
          - run: npx xanosdk deploy ./xano/index.ts --ephemeral --static ./frontend/dist --test

      # A pushed tag: test on an ephemeral, cut the release, promote it and go live.
      release:
        if: startsWith(github.ref, 'refs/tags/v')
        needs: check
        runs-on: ubuntu-latest
        environment: production          # add required reviewers to gate the landing
        concurrency:
          group: xano-release
          cancel-in-progress: false
        permissions: { contents: write, pull-requests: write }
        env:
          RELEASE: ${{ github.ref_name }}
          XANO_INSTANCE_URL: ${{ secrets.XANO_INSTANCE_URL }}
          XANO_WORKSPACE_ID: ${{ secrets.XANO_WORKSPACE_ID }}
          XANO_META_TOKEN: ${{ secrets.XANO_META_TOKEN }}
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with: { node-version: 20, cache: npm }
          - run: npm ci && npm run build
          - name: Write xano/.env
            run: printf '%s\n' "$XANO_BACKEND_ENV" > xano/.env
            env: { XANO_BACKEND_ENV: "${{ secrets.XANO_BACKEND_ENV }}" }
          - run: npx xanosdk deploy ./xano/index.ts --ephemeral --test --frozen-lock
          - run: npx xanosdk release create "$RELEASE" --from ephemeral
          - name: Promote and go live
            run: |
              LIVE=$(npx xanosdk workspace branch list --json | jq -r '.branches[] | select(.live) | .label')
              npx xanosdk promote "$RELEASE" --branch "$RELEASE" --set-live --expect-live "$LIVE" --yes
          - run: npx xanosdk publish ./frontend/dist --to workspace --release "$RELEASE" --branch "$RELEASE" --yes
          # promote recorded the landing and a new sync baseline in xano/xano.lock.
          - name: Commit the landing record
            if: ${{ !cancelled() }}
            env: { GH_TOKEN: "${{ github.token }}" }
            run: |
              git diff --quiet -- xano/xano.lock && exit 0
              git config user.name "github-actions[bot]"
              git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
              git switch -c "xano-lock/$RELEASE"
              git commit -m "xano.lock: $RELEASE landed on the workspace" -- xano/xano.lock
              git push origin HEAD
              gh pr create --base main --fill
    ```
  </Tab>

  <Tab title="With tenants">
    ```yaml theme={null}
    # .github/workflows/xano.yml
    name: xano
    on:
      pull_request:
      push:
        branches: [main]
        tags: ["v*"]
      workflow_dispatch:
        inputs:
          release:
            description: "Release to ship, such as v1.4.0"
            required: true
          tenant:
            type: choice
            options: [stage, prod]

    jobs:
      check:
        if: github.event_name != 'workflow_dispatch'
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with: { node-version: 20, cache: npm }
          - run: npm ci
          - run: npm run xano:check
          - run: git diff --exit-code

      preview:
        if: github.event_name == 'pull_request'
        needs: check
        runs-on: ubuntu-latest
        concurrency:
          group: xano-preview-${{ github.event.pull_request.number }}
          cancel-in-progress: true
        env:
          XANO_INSTANCE_URL: ${{ secrets.XANO_INSTANCE_URL }}
          XANO_WORKSPACE_ID: ${{ secrets.XANO_WORKSPACE_ID }}
          XANO_META_TOKEN: ${{ secrets.XANO_META_TOKEN }}
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with: { node-version: 20, cache: npm }
          - run: npm ci && npm run build
          - name: Write xano/.env
            run: printf '%s\n' "$XANO_BACKEND_ENV" > xano/.env
            env: { XANO_BACKEND_ENV: "${{ secrets.XANO_BACKEND_ENV }}" }
          - run: npx xanosdk deploy ./xano/index.ts --ephemeral --static ./frontend/dist --test

      # A pushed tag: test on an ephemeral, cut the release, deploy it to dev.
      release:
        if: startsWith(github.ref, 'refs/tags/v')
        needs: check
        runs-on: ubuntu-latest
        concurrency:
          group: xano-release
          cancel-in-progress: false
        permissions: { contents: write, pull-requests: write }
        env:
          RELEASE: ${{ github.ref_name }}
          XANO_INSTANCE_URL: ${{ secrets.XANO_INSTANCE_URL }}
          XANO_WORKSPACE_ID: ${{ secrets.XANO_WORKSPACE_ID }}
          XANO_META_TOKEN: ${{ secrets.XANO_META_TOKEN }}
          TENANT: dev
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with: { node-version: 20, cache: npm }
          - run: npm ci && npm run build
          - name: Write xano/.env
            run: printf '%s\n' "$XANO_BACKEND_ENV" > xano/.env
            env: { XANO_BACKEND_ENV: "${{ secrets.XANO_BACKEND_ENV }}" }
          - run: npx xanosdk deploy ./xano/index.ts --ephemeral --test --frozen-lock
          - run: npx xanosdk release create "$RELEASE" --from ephemeral
          - run: npx xanosdk tenant deploy "$TENANT" "$RELEASE" --yes
          - run: npx xanosdk publish ./frontend/dist --to "tenant:$TENANT" --yes
          - name: Commit the landing record
            if: ${{ !cancelled() }}
            env: { GH_TOKEN: "${{ github.token }}" }
            run: |
              git diff --quiet -- xano/xano.lock && exit 0
              git config user.name "github-actions[bot]"
              git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
              git switch -c "xano-lock/$RELEASE-$TENANT"
              git commit -m "xano.lock: $RELEASE landed on $TENANT" -- xano/xano.lock
              git push origin HEAD
              gh pr create --base main --fill

      # Run from the Actions tab: ship a release that passed dev to stage or prod.
      ship:
        if: github.event_name == 'workflow_dispatch'
        runs-on: ubuntu-latest
        environment: ${{ inputs.tenant }}   # add required reviewers to prod
        concurrency:
          group: xano-release
          cancel-in-progress: false
        permissions: { contents: write, pull-requests: write }
        env:
          RELEASE: ${{ inputs.release }}
          TENANT: ${{ inputs.tenant }}
          XANO_INSTANCE_URL: ${{ secrets.XANO_INSTANCE_URL }}
          XANO_WORKSPACE_ID: ${{ secrets.XANO_WORKSPACE_ID }}
          XANO_META_TOKEN: ${{ secrets.XANO_META_TOKEN }}
        steps:
          - uses: actions/checkout@v4
            with: { ref: "${{ inputs.release }}" }
          - uses: actions/setup-node@v4
            with: { node-version: 20, cache: npm }
          - run: npm ci && npm run build
          - run: npx xanosdk tenant deploy "$TENANT" "$RELEASE" --yes
          - run: npx xanosdk publish ./frontend/dist --to "tenant:$TENANT" --yes
          - name: Commit the landing record
            if: ${{ !cancelled() }}
            env: { GH_TOKEN: "${{ github.token }}" }
            run: |
              git diff --quiet -- xano/xano.lock && exit 0
              git config user.name "github-actions[bot]"
              git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
              git switch -c "xano-lock/$RELEASE-$TENANT"
              git commit -m "xano.lock: $RELEASE landed on $TENANT" -- xano/xano.lock
              git push origin HEAD
              gh pr create --base main --fill
    ```
  </Tab>
</Tabs>

Drop the `npm run build`, `--static` and `publish` lines if your project has no frontend.

### What each job does

* **`check`** runs on every push and pull request with no secrets. `git diff --exit-code` is the
  scaffold's own backstop: it fails when the check left anything changed in the working tree.
* **`preview`** deploys the pull request to an ephemeral and runs its tests there. `--test` exits
  `5` when a test fails and `6` when the tests cannot be reached, which fails the job. The job
  log prints the backend and frontend URLs for reviewers.
* **`release`** runs on a `v*` tag. It deploys the tagged commit to an ephemeral, runs the tests,
  and cuts the release from that ephemeral, so the release is exactly what passed. With the
  workspace as production it then promotes to a new Xano branch named after the release and
  switches live to it. With tenants it deploys the release to `dev`, and `ship` takes the same
  release to `stage` and `prod` from the Actions tab.
* **The last step** commits `xano/xano.lock` back. `promote` and `tenant deploy` record what they
  landed (and `promote` records a new [sync baseline](/govern/drift)) in CI's checkout, which is
  lost when the job ends. The step opens a pull request with the change; merge it like any
  other. It needs "Allow GitHub Actions to create and approve pull requests" turned on in the
  repository's Actions settings.

### Concurrency

Each job that writes to Xano sits in a GitHub `concurrency` group:

* **`xano-release`** with `cancel-in-progress: false`. Release jobs queue, one at a time, and a
  running landing is never cancelled halfway. Cancelling a release write mid-flight is how a run
  ends at exit `9`.
* **`xano-preview-<pull request number>`** with `cancel-in-progress: true`. A new push to a pull
  request replaces the preview run still going for the previous push.

Each preview run starts from a fresh checkout, with no record of an earlier ephemeral, and
deploys a new one, which expires on its own (after an hour, by default). Runs for different pull
requests do not collide: two checkouts deploying at once each get their own ephemeral, even with
the same display name.

### Going live safely

The promote step reads the live Xano branch, then passes it to `--expect-live`. Promote checks it
before landing and again immediately before switching, so if someone switched live by hand in
between, the new Xano branch stays landed but is not served, and the job fails with exit `2`
(`SDK_LIVE_BRANCH_MISMATCH`). `--branch "$RELEASE"` makes a re-run of the same tag safe: the label
already exists, so the second promote is refused before it lands anything.

The promote prints the rollback command in the job log, as
`Roll back with xanosdk workspace branch set-live <previous label>`. See
[Rollback and recovery](/govern/rollback-and-recovery).

### A release that changes a table

The workflow does not pass `--allow-shared-schema-changes`. A release that drops, retypes or
constrains a column is refused at the promote step (exit `2`, `SDK_SHARED_SCHEMA_CHANGE`) after
the release was cut. That is the point where a person takes over: read the change, back up, then
land the release that already exists from a terminal:

```bash theme={null}
npx xanosdk deploy ./xano/index.ts --to workspace --dry-run    # read droppedColumns
npx xanosdk promote v1.4.0 --branch v1.4.0 --set-live --expect-live v1.3.2 --allow-shared-schema-changes
```

Commit `xano/xano.lock` afterwards. With tenants, `tenant deploy --yes` does not stop on a table
change, so the review has to happen before the tag. See
[Schema changes](/govern/schema-changes).

## Exit codes in a pipeline

Commands print their result as JSON when stdout is not a terminal, so the job log holds each
run's document. Branch on the exit code:

| Exit | In CI |
| - | - |
| `0` | Done. |
| `1` | An ordinary failure. Worth a retry once the cause is fixed. |
| `2` | A refusal or disagreement: a schema change, a taken name, drift, a live-branch mismatch, a diff that differs. Re-running changes nothing. |
| `5`, `6` | Tests failed, or could not be reached. Do not cut the release. |
| `7` | `upgrade --check` only: a newer `@xano/sdk` is published. |
| `9` | The outcome is unknown. **Never re-run the step.** |

**Never retry a step that exited `9`**, and do not use "Re-run failed jobs" on it. The write was
sent and the answer was lost, so it may have landed, and a retried `release create` can leave a
second release under a suffixed name. Read the `resolveWith` command in the step's JSON output,
run it, and continue from what it says. [Rollback and
recovery](/govern/rollback-and-recovery#recover-a-release-write-whose-outcome-is-unknown-exit-9)
covers each command.

**`--yes` is required off a terminal.** `promote`, `tenant deploy`, `publish` to a workspace or
tenant, `workspace branch set-live` and `pull` ask before they write, and with no terminal to ask
on they refuse rather than guess. `--yes` answers the question. It never waives a refusal.

## Watch for SDK upgrades

`xanosdk upgrade --check` exits `7` when a newer `@xano/sdk` is published and `0` when you are
current. A pipeline that only wants to report it must not treat `7` as a failure:

```bash theme={null}
npx xanosdk upgrade --check || test $? -eq 7    # report only; a registry it cannot reach still fails (exit 1)
```

Let `7` fail the job only when you mean "fail until we upgrade". Upgrade in a pull request, so
the check job runs against the new version before anything ships.

## Next

[Git, locks & merges](/govern/git-and-merges): what to commit, and what a reviewer checks.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.