Skip to content
Docs menu

Deploying from CI

A deploy from a build machine should not hold an account. A deploy token is the credential it holds instead: minted in the app, bound to one instance, readable exactly once, and revocable — so losing one costs a revoke, not a password change.

The CLI is the only client that can stage a deploy (the files API speaks whole directories and seals a stage by renaming it; curl is not a way to do that), so CI runs cinnaboard.

1. Mint the token

Instance → Code → Deploy from your machine or CI → New token (it is named github-actions; rename it if you like). Copy it immediately: it is shown once and no route can show it again, not even to you. A PocketBase instance has no Code tab: the same block is on its Overview.

It deploys exactly one instance. Nobody else’s, no other workspace’s.

2. Put it in the repository

Name Where Value
CINNABOARD_TOKEN Actions secret the cnb_… value
CINNABOARD_SERVER Actions variable the deployment’s origin, e.g. https://cinnaboard.example
CINNABOARD_INSTANCE Actions variable the instance’s id — a token reads no rules, so there is no name to look up

The id is in the URL when the instance’s page is open, and in Deploy from your machine or CI, which fills it in for you along with the server.

3. The workflow

name: deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      # A released binary from this app's server, checked against its SHA256SUMS (see
      # "Installing the CLI"). The server pins the release, so the client matches it.
      - name: Install the CLI
        env:
          CINNABOARD_TOKEN: ${{ secrets.CINNABOARD_TOKEN }}
          CINNABOARD_SERVER: ${{ vars.CINNABOARD_SERVER }}
        run: |
          mkdir -p "$RUNNER_TEMP/cli" && cd "$RUNNER_TEMP/cli"
          for f in cinnaboard-linux-x64 SHA256SUMS; do
            curl -fsSL -H "Authorization: Bearer $CINNABOARD_TOKEN" -o "$f" "$CINNABOARD_SERVER/cli/$f"
          done
          sha256sum -c SHA256SUMS --ignore-missing
          install -m 755 cinnaboard-linux-x64 "$RUNNER_TEMP/cli/cinnaboard"
          echo "$RUNNER_TEMP/cli" >> "$GITHUB_PATH"

      # Assemble the payload locally: no session, no instance, nothing sent. This is
      # worth a job of its own on a pull request — an entry whose imports reach outside
      # the writable directories is bundled here, and a broken tree fails the build
      # rather than a deploy.
      - run: cinnaboard build . -o dist-deploy

      - name: Deploy
        env:
          CINNABOARD_TOKEN: ${{ secrets.CINNABOARD_TOKEN }}
          CINNABOARD_SERVER: ${{ vars.CINNABOARD_SERVER }}
        run: |
          cinnaboard deploy dist-deploy \
            --instance "${{ vars.CINNABOARD_INSTANCE }}" \
            -m "$(git log -1 --pretty=%s)"

--dry-run instead of the last step prints what would change and sends nothing.

What a deploy does

deploy <dir…> finds the engine’s writable directories — hooks/, routes/, cron/, migrations/, public/ for cinnabun — under each directory you name and sends them. What the command line does not name comes from cinnaboard.toml, the same file cinnaboard dev works from. CI still names both, because a deploy token is bound to one instance and --instance has to be its id.

  • Each top-level directory is replaced whole. Deleting a file in your repo removes it from the instance; a half-updated tree does not exist. An unchanged directory is not re-sent, so a deploy that changes nothing costs nothing.
  • *.test.* files are skipped — the engine’s loader never loads one — but a file a live entry imports is sent even when it is named like a test. A directory left holding only test files is refused rather than replaced by an empty one.
  • An entry whose imports reach outside the writable directories is bundled into a single file, so a monorepo’s @repo/… package can be deployed. --no-bundle reports those entries instead of bundling them, which is how you find out what would travel.

The message closes tasks

-m "fixes WEB-42 shipped the export" closes that task when the deploy lands, and the $(git log -1 --pretty=%s) above means an ordinary commit subject does it:

fixes WEB-42: export the CSV with a header row

The key resolves in the deployed project only, and fixes/closes/resolves (with or without a colon) is what closes; any other key in the message is merely listed on the deploy’s event.

Your site lives in public/

Files under public/ are served at the instance’s / — this is where a built frontend goes. A deploy of only hooks/ leaves the site alone, so backend and frontend can ship on their own schedules.

Schema changes

A deploy can carry migrations/, applied by the instance’s engine. Two things are not a deploy’s job:

  • Keys and rules are the console’s (/_/), or the cinnabun seed’s; the CLI does not write collections.
  • A restore is not a deploy. Restoring a database export from an older engine keeps that older shape silently — re-seeding is what repairs it.

Installing the CLI

This app’s server hands out the CLI: a binary per platform (cinnaboard-darwin-arm64, -darwin-x64, -linux-x64, -linux-arm64) and a SHA256SUMS to check them against, from the release the server pins. A download needs a signed-in session or a deploy token, and the server answers with a short-lived link to the file. Nobody needs access to the repository the release comes from.

Download your platform’s binary and SHA256SUMS with the buttons in the instance’s Code tab (Deploy from your machine or CI), into one folder — your Downloads folder, usually. Then check and install them.

On your own machine:

cd ~/Downloads
shasum -a 256 -c SHA256SUMS --ignore-missing
sudo mkdir -p /usr/local/bin
sudo install -m 755 cinnaboard-darwin-arm64 /usr/local/bin/cinnaboard

In CI, the workflow above fetches the Linux binary and SHA256SUMS from $CINNABOARD_SERVER/cli/<file> with the deploy token it already holds.

The macOS binaries are not signed or notarized; if Gatekeeper refuses one, xattr -d com.apple.quarantine cinnaboard-darwin-* clears the download’s quarantine.

There is no Windows build to download yet: bun run cli:build compiles one, and it is offered once it has run login, deploy, logs and rollback on Windows. Use the Linux one under WSL meanwhile.

Building from a checkout still works — bun run cli:build compiles the same binaries into release/cli/. A release is built the same way, from the commit production runs, so a deploy is never made with a client newer than the server it talks to. The server finds the release with CLI_RELEASES_TOKEN, an operator secret: a fine-grained GitHub token with Contents: read on the releases’ repository and nothing else.

Locally, before you trust CI

cinnaboard login --server https://cinnaboard.example     # once; the session file is 0600
cinnaboard link <instance-id> .                          # writes cinnaboard.toml
cinnaboard dev                                           # deploy, then on every save
cinnaboard deploy dist-deploy --instance <id> --dry-run   # what would change
cinnaboard ls --instance <id> -r                         # what is deployed
cinnaboard pull --instance <id> ./pulled                  # what is deployed, back
cinnaboard logs -f                                       # what the instance prints, live

cinnaboard dev is the fast loop: it watches the roots in cinnaboard.toml and sends the directory a save changed. It needs a session, not a token — a token is for the machine that has no person on it.

The instance’s secrets

The environment variables your code reads — an API key, a signing secret — are the instance’s secrets. They are set by a person, from the app’s Secrets tab or from a terminal:

cinnaboard secrets ls --instance <id>               # the names; a value is never shown
cinnaboard secrets set STRIPE_KEY --instance <id>   # type the value at a hidden prompt
cinnaboard secrets import prod.env --instance <id>  # every NAME=value line of a file
cinnaboard secrets rm STRIPE_KEY --instance <id>    # asks first; --yes does not

Without --instance the instance comes from cinnaboard.toml, as for dev.

  • A value is never an argument. secrets set NAME=value is refused, because a command line lands in shell history and in the process list. Type it at the prompt, or pipe it in: pass show stripe | cinnaboard secrets set STRIPE_KEY, or cinnaboard secrets set TLS_KEY < key.pem. One trailing newline is taken off a piped value, and nothing else is changed. An empty value is refused and changes nothing.
  • A value cannot be read back, by this command or any other. set over a name that exists replaces it and says so; rm of a name that is not there removes nothing and exits 1, so a typo does not read as a secret removed.
  • import sets many at once, from a dotenv file. Each NAME=value line is set in one request, and a secret the file does not mention is left as it is. - reads the file from stdin, so a decrypted file need not touch the disk: sops -d prod.env | cinnaboard secrets import -. It asks before it replaces a secret that exists (--yes does not ask, and a pipe needs it); adding one asks nothing.
    • A file that cannot be read as NAME=value sends nothing. Each problem is given as a line number and never quotes the line, since a broken line is often part of a value.
    • A name with no value (SENTRY_DSN=) is skipped rather than stored empty, and a name the platform sets itself (PORT, NODE_ENV) is skipped too, so a local .env can be imported as it is. Both are said. A name set twice, or a value past the limit, stops the import.
    • The file is read the way Bun and the dotenv package both read one: # starts a comment (in an unquoted value too, so quote a value that holds one), a leading export is ignored, single quotes, double quotes and backticks all quote, a quoted value may span lines, and inside double quotes \n and \r become the characters. Nothing is expanded: $OTHER is stored as those characters, where Bun reading the same file locally would substitute it, and the command names each secret this applies to.
  • The instance reads its secrets when it starts. One that is running keeps the environment it started with until its next start. A deploy that replaces code restarts it, so setting a secret and then deploying is the usual order. With no deploy to make, cinnaboard restart --instance your-subdomain stops the instance so that its next request starts it afresh; the app’s Restart button is the same thing. It needs a signed-in account. A change to a secret is recorded in the project’s Activity with who made it and which names, never a value.
  • It needs a session, not a token. A deploy token cannot read or change secrets, so a workflow cannot set them; CINNABOARD_TOKEN in the environment is ignored by this command and your own login is used.

A name is upper-case letters, digits and underscores, not starting with a digit, and not one the platform sets itself (PORT, SQLITE_PATH, BETTER_AUTH_… and the like). A value is at most 16,384 characters, and an instance holds at most 200 secrets.

Checking that the deploy started

A deploy that replaces code the engine reads stops the instance, and the next request starts it with the new code. Without --wait that request is a visitor’s, and a deploy whose code cannot boot still exits 0. With it, the CLI makes the request itself:

cinnaboard deploy dist-deploy --instance <id> -m "…" --wait

After the commit it requests the instance’s health check (/api/health, at the platform address the server names) and exits by what it finds:

Exit What happened
0 The instance answered: running: https://… answered after 2.1 s.
1 The new code did not start. The instance’s last log lines follow on standard error, then the rollback command that puts the previous code back.
1 The instance answered, but not with this deploy: a newer one is listed above it. The edge rolled it back, or another deploy followed. The last log lines follow on standard error.
1 The instance was not started at all — it is powered off, or its workspace is suspended, has no slot, or is over its storage. The edge’s own sentence is printed; the deploy is in place.
1 No answer within two minutes. What it last heard is printed.
2 The server cannot say where the instance stands (it is older than --wait). Refused before anything is sent.
130 Interrupted while waiting. The deploy itself is done.

What it does not do:

  • It does not roll back unless you ask. It tells you the command; whether to undo the deploy is yours to decide, because the database and applied migrations stay as they are either way (see “Rolling a deploy back”). --rollback, below, decides it in advance.
  • The platform may roll back before the CLI can. An edge puts the previous code back by itself when a deploy’s first start fails, and the next request is answered by that code. So an answer alone does not prove your commit is running: after one, the CLI reads the instance’s deploys, and if anything is listed above this deploy it exits 1 and says so. It cannot tell the edge’s rollback from a deploy somebody else made in the same seconds, and names both. A CLI from before this check (cli-v2026.10.11.1 and earlier) exits 0 for a deploy the edge rolled back.
  • It checks that the engine answers, not that your code is right. A hook that throws on the first real request still passes.
  • It waits two minutes, and that is not adjustable. One start is given 30 seconds by the edge; the rest is for an instance that is moving between edges.

It works with the same CINNABOARD_TOKEN a deploy uses.

Rolling back a deploy that did not start

cinnaboard deploy dist-deploy --instance <id> -m "…" --wait --rollback

When the new code does not start, the CLI puts back the code this deploy replaced, then requests the instance again:

the instance did not start with the new code
its last log lines:
…
rolling back this deploy
rolled back hooks/ routes/
waiting for https://… to answer
the previous code is running: https://… answered after 0.9 s

The exit is still 1: the deploy failed, and the job should say so. What changes is that the instance is serving the previous code while you fix it.

It rolls back only when all of these hold, and otherwise says which did not and leaves the instance as the deploy left it:

  • The start was recorded as failed. No answer within two minutes, or an instance that was not started at all, says nothing about your code, so nothing is undone.
  • This deploy is still the newest. A rollback to before a deploy also undoes every deploy after it, and one that landed while this was waiting is not yours to undo.
  • This deploy did not change migrations/ (pb_migrations/ on PocketBase). A rollback restores code and never the database, so the previous code would run on whatever the new migrations had already done. Re-sending the migrations the instance already holds, as every deploy does, is not a change; a file added, removed or of another size is. The command to roll back anyway is printed.

If the rollback’s answer is lost (a dropped connection, a timeout), the CLI does not guess. The code is swapped in before the answer is sent, so the rollback may have landed, and a request whose connection dropped can still be running. It says not rolled back only when the server refused the request. Otherwise it reads the instance’s deploys again: with anything newer than this deploy it says the rollback may have been applied, and with this deploy still the newest it says the rollback may still be applied. Both print cinnaboard deploys --instance <id> to see what the instance runs. Either way the command it prints for doing it by hand names this deploy (rollback --instance <id> --before <stage>), so it restores the code from before this deploy whatever has landed since.

The rollback command does the same when its own answer is lost: it says the rollback may or may not have been applied and prints the command to try again with --before. Run again without it, rollback undoes the newest deploy, which after a rollback that landed is that rollback.

An instance that cannot be rolled back yet is refused with exit 2 before anything is sent, and so is --rollback without --wait.

Showing the deploy on the commit

GitHub can show each deploy beside the commit it shipped, in the pull request that merged it and in the repository’s Deployments list, with a link to the instance and whether it started. The platform does not write to your repository; the workflow does, with the token GitHub already gives it.

Give the job leave to record deployments. permissions replaces the defaults, so contents: read is there for the checkout:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write

Then one step before the deploy, the deploy itself with --wait, and one step after:

- name: Tell GitHub a deploy has started
  id: record
  env:
    GH_TOKEN: ${{ github.token }}
  run: |
    id=$(jq -n --arg ref "$GITHUB_SHA" \
        '{ref: $ref, environment: "production", auto_merge: false, required_contexts: []}' |
      gh api "repos/$GITHUB_REPOSITORY/deployments" --input - --jq .id)
    echo "id=$id" >> "$GITHUB_OUTPUT"

- name: Deploy
  env:
    CINNABOARD_TOKEN: ${{ secrets.CINNABOARD_TOKEN }}
    CINNABOARD_SERVER: ${{ vars.CINNABOARD_SERVER }}
  run: |
    cinnaboard deploy dist-deploy \
      --instance "${{ vars.CINNABOARD_INSTANCE }}" \
      -m "$(git log -1 --pretty=%s)" --wait

- name: Tell GitHub how it went
  if: always() && steps.record.outputs.id
  env:
    GH_TOKEN: ${{ github.token }}
    CINNABOARD_TOKEN: ${{ secrets.CINNABOARD_TOKEN }}
    CINNABOARD_SERVER: ${{ vars.CINNABOARD_SERVER }}
    STATE: ${{ job.status == 'success' && 'success' || 'failure' }}
  run: |
    url=$(cinnaboard status --instance "${{ vars.CINNABOARD_INSTANCE }}" --json |
      jq -r '.address // empty') || url=
    jq -n --arg state "$STATE" --arg url "$url" \
        --arg log "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
        '{state: $state, log_url: $log, auto_inactive: true}
          + (if $url == "" then {} else {environment_url: $url} end)' |
      gh api "repos/$GITHUB_REPOSITORY/deployments/${{ steps.record.outputs.id }}/statuses" \
        --input -

What each part is for:

  • --wait is what makes the status mean something. Without it the deploy step exits 0 for code that cannot boot, and GitHub would be told “success”. With --wait --rollback, a deploy that was rolled back is still a failure here, which is right: that commit is not what is running.
  • required_contexts: [], because GitHub otherwise refuses to record a deployment while any check on the commit is unfinished, and this job is one of them. auto_merge: false, because GitHub would otherwise try to merge the default branch into the ref first.
  • environment is a name of your choosing. GitHub creates the environment the first time it sees one, and auto_inactive retires the previous deploy to the same environment when this one succeeds.
  • The address comes from status --json, which a deploy token may ask of its own instance. It is the platform address (https://your-subdomain.…); a custom domain is not told to a token, so write that in by hand if you would rather link to it. A server that names no address leaves the link out, and the status is still recorded.
  • if: always() runs the last step when the deploy failed too, and steps.record.outputs.id skips it when the first step never made a deployment.

What it does not do: it says nothing while the job is running (add a second call with state: in_progress if you want that), and a deploy made from a laptop is not recorded, since only the workflow tells GitHub.

Seeing where the instance stands

cinnaboard status --instance <id>
shop  cinnabun 0.4.6
status    stopped while idle: the next request starts it
address   https://shop.<apex>
domain    https://app.example.com
power     on
deployed  2h ago  (commit a1b2c3d)
storage   13 MB database, 2.0 KB files

It prints what the platform last recorded about the instance and requests nothing of the instance itself, so it never starts one. To find out whether an instance answers, use deploy --wait, which makes the request.

  • status says why, when the instance is down: stopped while idle or for a deploy (the next request starts it), powered off, crashed or did not start (cinnaboard logs shows what it printed), or stopped because its workspace is suspended, has no free slot, or is over its storage allowance.
  • deployed is the last deploy from anywhere: this CLI, the Files tab, a template or a rollback. The commit is shown when the deploy named one.
  • domain and storage appear when there is something to say: a custom domain once its DNS has been checked, and the sizes once the edge has measured them.
  • With a CINNABOARD_TOKEN it prints three lines: the instance id, the status and the address. A deploy token reads no rows, so the version, the last deploy and the storage are for a signed-in account.
  • It exits 0 whenever it could read the instance, running or not. It is a report, not a check: a workflow that has to fail when new code does not start uses deploy --wait.

Without --instance the instance comes from cinnaboard.toml, as for logs.

For a script, status, deploys and whoami take --json: the same answer as JSON, with the same exit code. Instants are ISO 8601 and anything not recorded is null.

cinnaboard status --instance your-subdomain --json | jq -r .status

Opening the instance in a browser

cinnaboard open            # the instance's page in the app
cinnaboard open --site     # what the instance serves
cinnaboard open --print    # print the address and open nothing

The address is printed on standard output every time, alone on its line, and then opened with the system’s own command (open on macOS, xdg-open on Linux). Over SSH, or anywhere with no browser, the command exits 1 and the address is still there to copy; --print is the same without the attempt, which is also how a script takes it: url=$(cinnaboard open --print).

  • --site is the custom domain once one is live, and the platform’s address otherwise.
  • --site starts a stopped instance, because loading the page is a request. The page in the app does not, and neither does --print.
  • It needs a signed-in account. The page sits under a project, and a deploy token reads no rows to learn which; a CINNABOARD_TOKEN in the environment is left alone.

Who the CLI is acting as

cinnaboard whoami
server   https://app.example.com
account  sam@example.com (Sam Lee), the session kept by `cinnaboard login`

The account is asked of the server, so a session that has ended is reported here (not signed in: the session kept for sam@example.com has ended) and not halfway through a deploy. Nothing kept on disk is changed, and no credential is printed.

  • With CINNABOARD_EMAIL and CINNABOARD_PASSWORD set it signs in with them, as every command does, and says so.
  • With CINNABOARD_TOKEN set a token line says that deploys use the token and not the account. The token itself is not checked: it is bound to one instance, so cinnaboard status --instance <id> is the command that proves it works.
  • It exits 0 when there is an account or a token to act as, and 1 when there is neither, so a script can ask before it starts.

The instance’s custom domain

cinnaboard domain --instance <id>
cinnaboard domain set app.example.com --instance <id>
cinnaboard domain check --instance <id>
cinnaboard domain rm --instance <id>

The app’s Domain tab from a terminal. A domain goes live once a CNAME record for it names this instance, so set usually answers with the record to create:

app.example.com  waiting for DNS
create this record at your DNS host, DNS only (not proxied):
  app.example.com CNAME <instance id>.<the platform's domain>
last check: no CNAME record
checked automatically every few minutes; to check now: cinnaboard domain check --instance shop

Create the record first and set answers live in the same request, which is also how a domain is switched without downtime.

  • domain alone says where the domain stands and exits 0.
  • set asks before replacing a domain that is live, because that one stops being served the moment another is requested. --yes skips the question; without a terminal it is required. Replacing a request that never went live asks nothing.
  • check has the platform look at the DNS now, and exits 0 only when the domain is live, so a script can wait on it. It is refused within a few seconds of the last check.
  • rm removes the domain, requested or live, and asks first unless --yes.

Like secrets, this needs a signed-in account (cinnaboard login, or CINNABOARD_EMAIL and CINNABOARD_PASSWORD): a deploy token deploys, and does not change where an instance is served.

Reading what the instance printed

cinnaboard logs --instance <id>        # the last 100 lines, then stop
cinnaboard logs --instance <id> -f     # the same, then each new line until Ctrl-C
cinnaboard logs --errors               # only what the instance wrote to stderr
cinnaboard logs --grep "GET /orders"   # only lines containing that text

Each line is its timestamp (UTC) and the text, on standard output; terminal escape sequences the instance printed are removed. Without --instance the instance comes from cinnaboard.toml, as for dev. -f reconnects when the stream drops and does not print a line twice.

--errors and --grep TEXT choose which of those lines are printed, and can be used together and with -f:

  • They search what the command reads, and no further back. Without -f that is the last 100 lines. When none of them match, the command says how many it read (no match among the last 100 log lines) and still exits 0: it is a report, as status is, and not a check.
  • --grep takes text, not a pattern, in any case: --grep "(50%)" finds those five characters. Spaces around the text are not part of it, the timestamp is not searched, and text that begins with a dash is written --grep=-1.
  • --errors is the stream, not the wording. A line is kept because the instance wrote it to stderr, whatever it says; an error the code printed to stdout is found with --grep.
  • --tail N reads further back. The last N lines in place of 100, up to 2000, so --tail 2000 --grep TEXT searches twenty times as much. A log of very long lines may come back with fewer than N, the newest ones.

The command works with the same CINNABOARD_TOKEN a deploy uses, so a workflow can show why the deploy it just made did not start. deploy --wait prints these lines itself when the new code does not start; this step is for a deploy made without it:

- if: failure()
  run: cinnaboard logs --instance "${{ vars.CINNABOARD_INSTANCE }}"
  env:
    CINNABOARD_SERVER: ${{ vars.CINNABOARD_SERVER }}
    CINNABOARD_TOKEN: ${{ secrets.CINNABOARD_TOKEN }}

An instance that has never run has no log yet, and the command says so (the server answered 404: not running on an edge yet).

Promoting one instance’s code to another

With a staging instance and a production one, promote deploys to the second the code the first runs now, without a checkout of it:

cinnaboard promote --from <staging> --to <production> --dry-run   # what would change
cinnaboard promote --from <staging> --to <production> --wait      # asks, sends, checks

It prints one line per directory before it asks. A directory staging holds files in replaces production’s; a directory only production holds files in is emptied, so a job you deleted on staging does not keep running; the rest is left alone.

  • It copies code, and nothing else. The database, the secrets, the engine version and every setting stay as they are on each instance.
  • Migrations go forward. Staging’s migrations/ replaces production’s, and production applies what is new at its next start. It is never emptied. If production holds a migration staging does not, staging is not ahead of it, and nothing is sent unless you pass --force.
  • It is an ordinary deploy on the destination. It is recorded as “Promoted from” and the source’s name (or as your -m, which closes tasks the way a deploy’s message does), and cinnaboard rollback --instance <production> undoes it.
  • It needs a session, not a token. A deploy token is bound to one instance and this reads one and writes another. In a workflow, set CINNABOARD_EMAIL and CINNABOARD_PASSWORD for an account on the project, and pass --yes.

Rolling a deploy back

A deploy keeps what it replaced for seven days by default (the edge’s TRASH_RETENTION_MS), so a bad one can be undone without a copy of the old code:

cinnaboard deploys --instance <id>                 # what can be rolled back to
cinnaboard rollback --instance <id> --yes          # undo the last deploy
cinnaboard rollback --instance <id> --before <point> -m "broke sign-in" --yes

A rollback restores the code as it was just before the deploy you name — hooks/, routes/, cron/, the site and its source. It does not touch the database: migrations already applied stay applied, and migrations/ is left as it is, so the older code runs against the current schema. A rollback is itself a deploy, so cinnaboard rollback again undoes it. In CI it needs --yes, and the same CINNABOARD_TOKEN a deploy uses.