# CI/CD

Running the CLI from a pipeline. The pieces you need are environment-variable
authentication, `--non-interactive`, `--json`, and exit codes.

## Authentication

`sinch auth login` prompts for a project ID, key ID and key secret, then writes
the secret to the OS keychain. Neither works on a runner with no stdin and no
keychain. Instead, set `SINCH_PROJECT_ID`, `SINCH_KEY_ID` and `SINCH_KEY_SECRET`,
described in
[Configuration → Environment variable override](/docs/functions/cli/configuration.md#environment-variable-override),
and the CLI skips both the keychain and the login step entirely. All three values
are in the [Sinch Dashboard](https://dashboard.sinch.com), under your project's
Access Keys section.

Of the three, only `SINCH_KEY_SECRET` is sensitive: mask it. The other two are
identifiers. Storing them as secrets too, as the GitHub Actions example below
does, is fine.

## Before the first deploy

Deploy builds a Node.js function's configuration from its `.env` file.
`sinch functions init` adds `.env` to `.gitignore`, so a fresh checkout on a
runner has none, and deploy fails validation with exit 78 (`MISSING_ENV_FILE`).
Committing `.env` is not the fix: deploy warns when `.env` is tracked in git
(`ENV_IN_GIT`).

Instead, commit a `.env.example` next to `sinch.json` that lists the same keys
as your local `.env`, with secret values left blank (see
[Secrets a function needs at runtime](#secrets-a-function-needs-at-runtime)).
Then create `.env` from it in the job, before deploying:

```sh
cp .env.example .env
```

Both pipeline examples below include this step.

## Deploying a function

Every command works from the function's own directory. `deploy` reads
`sinch.json` out of the working directory and takes the name from it, so the job
only has to be in the right folder:

```sh
cd my-function
sinch functions deploy --non-interactive
```

There is a `--name` flag, but pass it only when you deliberately want to deploy
under a different name than `sinch.json` says. In a pipeline it is a liability:
rename the function in the repo and the job keeps deploying to the old name
without complaining.

`--non-interactive` answers prompts with their defaults rather than blocking on
stdin, which a runner does not have. For `deploy`, a set `CI` environment
variable has the same effect, so on GitHub Actions and GitLab CI, which both set
`CI`, deploy runs non-interactively even without the flag.

One deploy prompt does not take its default. Deploy asks "Use a managed key for
this function?", which defaults to yes, but a non-interactive run picks
`profile` instead of `managed`. To use a managed key, set `accessKey` to
`"managed"` in `sinch.json` or pass `--managed-keys`. The
[functions command reference](/docs/functions/cli/commands/functions.md#sinch-functions-deploy)
covers how deploy picks the access key.

Without `--non-interactive`, commands still do not hang. When there is no terminal, a yes/no question
declines, a question with a default takes it, and a question with no answerable
default fails naming itself. Declining is deliberate: it means an unattended run
never approves a billable or irreversible action that a person was supposed to
approve. Pass the flag that authorises the action instead.

The one place that matters for deploy is validation warnings. A non-interactive
deploy, which includes any deploy where `CI` is set, goes ahead despite them.
With `CI` unset, no terminal and neither `--force` nor `--non-interactive`,
deploy stops with exit 64 and tells you which flag settles it, rather than
exiting successfully having shipped nothing.

Deploy waits for the deployment to finish and exits non-zero if it fails. Add
`--no-wait` to return as soon as the upload is accepted — useful when a later
job polls, but it means a green step no longer means a running function.

Other flags that matter in a pipeline:

| Flag                    | Effect                                                          |
| ----------------------- | --------------------------------------------------------------- |
| `--public`              | Deploy to the public namespace (internet-accessible)            |
| `--private`             | Deploy to the private namespace (internal access only)          |
| `--force`               | Deploy despite validation warnings instead of prompting         |
| `--managed-keys`        | Use a managed access key for this function                      |
| `--no-docs`             | Skip doc generation                                             |
| `--strict-audit`        | Fail the deploy on dependency audit findings at `--audit-level` |
| `--audit-level <level>` | Severity the audit reports on (default `high`)                  |

Pass `--public` or `--private` explicitly. Without either, deploy reuses the
`accessLevel` that an earlier deploy saved in `sinch.json`, and a
non-interactive deploy with nothing saved picks public. Relying on that means a
change to `sinch.json` can change the namespace without the pipeline saying so.
The examples below pass `--public` so the function can be called from the
internet. Pass `--private` instead for internal access only.

## GitHub Actions

```yaml
name: Deploy function

on:
  push:
    branches: [main]

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

      - uses: actions/setup-node@v4
        with:
          node-version: '24'

      - run: npm install -g @sinch/cli@0.8.1

      - name: Deploy
        env:
          SINCH_PROJECT_ID: ${{ secrets.SINCH_PROJECT_ID }}
          SINCH_KEY_ID: ${{ secrets.SINCH_KEY_ID }}
          SINCH_KEY_SECRET: ${{ secrets.SINCH_KEY_SECRET }}
          # Function secrets: one line per key left blank in .env.example
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
        working-directory: my-function
        run: |
          cp .env.example .env
          sinch functions deploy --non-interactive --public
```

## GitLab CI

```yaml
deploy:
  image: node:24
  only:
    - main
  before_script:
    - npm install -g @sinch/cli@0.8.1
  script:
    - cd my-function
    - cp .env.example .env
    - sinch functions deploy --non-interactive --public
```

Set `SINCH_PROJECT_ID`, `SINCH_KEY_ID` and `SINCH_KEY_SECRET` under
**Settings → CI/CD → Variables**, with `SINCH_KEY_SECRET` marked _Masked_. Add
each function secret there too, masked, with the same name as its blank key in
`.env.example`.

## Reading output in a script

`--json` prints the result to stdout with no spinner or table, so it can be
piped straight into `jq`:

```sh
sinch functions list --json | jq -r '.functions[] | select(.status != "Running") | .name'
sinch functions status --json | jq -r '.deployment.status'
sinch templates list --runtime node --json | jq -r '.templates[].name'
```

`functions list` returns the first 50 functions. Pass `--limit` (up to 200) and
`--page` to read more.

`functions deploy --json` prints one object and nothing else, so a pipeline can
pick the deployed URL straight out of it:

```sh
URL=$(sinch functions deploy --non-interactive --public --json | jq -r '.url')
curl --fail "$URL"
```

```json
{
  "name": "my-function",
  "functionId": "01M10PWQBM37KJSGJW0CZTP3CF",
  "runtime": "node",
  "status": "Running",
  "accessLevel": "public",
  "url": "https://supreme-hertz.fn.sinch.com"
}
```

`warnings` carries anything pre-flight validation flagged, a `.env` tracked in
git for instance, and warnings from the deploy itself. Validation warnings also
go to stderr, so they are visible in the job log even though stdout stays a
single document. The key is absent when there are none.

Deploy prints this document only after the function has shipped, so a check on
`warnings` here cannot stop a deploy. To fail closed on warnings for a Node.js
function, run `validate` first. It deploys nothing and prints `valid`, `errors`
and `warnings`:

```sh
set -o pipefail
sinch functions validate --json | jq -e '.valid and (.warnings | length == 0)'
sinch functions deploy --non-interactive --public --json
```

Set `pipefail` in any step that pipes the CLI into `jq`. Without it, the step
takes `jq`'s exit code and can pass after the CLI failed.

`status` is the settled state. `--json` waits for the function to reach
`Running` or `Failed` before printing, so a URL in the payload is a URL you can
call. The wait has two parts. Deploy first follows the deployment for up to 5
minutes, and exits 104 if it does not finish. Then it checks the status for up
to 3 more minutes. If neither state is reached by then, deploy prints the last
status it read and still exits 0, so check that `status` is `Running`. Under `--no-wait` there is no URL yet and the field is omitted
rather than reported as null.

On failure, `deploy --json` prints `{"error": "...", "exitCode": 78}` to stdout and exits
with that code. Missing or rejected credentials (100 and 101) are the exception:
the object is `{"error": "..."}` with no `exitCode`, so read the code from `$?`.

## Update checks in CI

The CLI never checks for or installs updates in CI. The check is skipped when any of these is true:

- `CI` is set, or the process is running in Docker
- stdout is not a TTY (so anything piped or redirected)
- `--json` or `--non-interactive` is present

If you run somewhere that does not set `CI`, set `SINCH_NO_AUTO_UPDATE` so the
CLI never installs an update on its own:

```yaml
env:
  SINCH_NO_AUTO_UPDATE: '1'
```

It stops the install only. Unless one of the conditions above holds, the check
and the "Update available" notice still run.

Pin an exact version in CI regardless, so a pipeline cannot change behaviour because a new release
landed:

```sh
npm install -g @sinch/cli@0.8.1
```

Read the [changelog](./changelog.md) before you bump the pin.

## Exit codes

The CLI follows BSD `sysexits` conventions plus a Sinch-specific range, so a
pipeline can tell a bad credential apart from a bad request.

| Code | Meaning         | Usual cause in CI                                                                                                                                                        |
| ---- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 0    | Success         |                                                                                                                                                                          |
| 1    | Generic failure | Also an unknown command, an unknown option or a missing argument                                                                                                         |
| 64   | Usage error     | A flag with an empty value, `init --area-code` without `--rent-number`, a `voice v1` command with no application key, or deploy warnings with no terminal and `CI` unset |
| 65   | Data error      | Malformed credentials file                                                                                                                                               |
| 66   | No input        | A named file does not exist, a `sip` or `conversation` delete, remove or stop needs confirmation and there is no terminal, or `--json` needs a flag in place of a prompt |
| 69   | Unavailable     | API refused the connection, or answered HTTP 429 or 5xx                                                                                                                  |
| 70   | Internal error  |                                                                                                                                                                          |
| 73   | Cannot create   | Not returned by any command today                                                                                                                                        |
| 74   | I/O error       | No response from the API, such as a DNS failure, a reset connection or a request past the 60-second timeout                                                              |
| 77   | No permission   | Key lacks access to the project                                                                                                                                          |
| 78   | Config error    | Missing or contradictory configuration                                                                                                                                   |
| 100  | Auth required   | The three environment variables are not set                                                                                                                              |
| 101  | Auth failed     | Wrong or revoked key secret                                                                                                                                              |
| 102  | Not found       | No such function or template                                                                                                                                             |
| 103  | Conflict        | Name already taken                                                                                                                                                       |
| 104  | Timeout         | Deployment did not become ready in time, or the network connection to the API timed out                                                                                  |
| 130  | Interrupted     | `sinch functions init` stopped with Ctrl+C                                                                                                                               |

`100` and `101` are the two worth branching on: both mean the pipeline's
credentials are wrong, and no retry will help. Missing credentials do not always
reach 100: with no credentials set, a credential key left blank in `.env`, such
as `PROJECT_ID_API_SECRET`, fails validation first, so deploy exits 78.

Capture the code with `|| code=$?`. By default GitHub Actions runs `run` steps
with `bash -e`, and GitLab stops a job at its first failing line, so a plain
`case $?` after a failed deploy is never reached:

```sh
code=0
sinch functions deploy --non-interactive || code=$?
case $code in
  0)   echo "deployed" ;;
  100|101) echo "check SINCH_KEY_ID / SINCH_KEY_SECRET"; exit 1 ;;
  104) echo "deploy timed out; check sinch functions status"; exit 1 ;;
  *)   echo "deploy failed"; exit 1 ;;
esac
```

## Secrets a function needs at runtime

For a Node.js function, list each secret in `.env.example` (and your local
`.env`) with an empty value, for example `STRIPE_SECRET_KEY=`. Then add a masked CI variable with the same name and pass
it to the deploy step. At deploy, the CLI fills each empty key from the job's
environment and sends it to the platform as a secret. Keys that have a value
ship as plain configuration, so keep secret values out of `.env.example`.

`sinch.json` does not carry secrets to the deployed function. Deploy does not
read its `variables`: `sinch functions dev` reads them, and `sinch env init`
copies them into a new `.env`.

Do not add a `sinch secrets add` step to CI. It writes to the OS keychain of the
machine that runs it, which on Linux needs a Secret Service daemon
(`secret-tool`). Use a CI variable instead.

## Debugging a failing job

```sh
SINCH_DEBUG=2 sinch functions deploy --non-interactive
```

Level 2 logs HTTP method, URL and status. Level 3 adds the request headers,
redacted. Response bodies are not logged, except the body of a failed OAuth
exchange, which is logged in full. See
[Configuration → Debug environment variables](/docs/functions/cli/configuration.md#debug-environment-variables)
for every level.

## Log output

Progress reporting goes to stderr and animates only when stderr is a terminal.
With no terminal, as on any CI runner or with a local `2> file`, each step
prints as plain lines instead, so the log does not fill with spinner
frames and cursor escapes. Nothing to configure.

Under `--json` there is no progress output at all: stdout carries the one
document. Validation warnings still go to stderr, as described in
[Reading output in a script](#reading-output-in-a-script).

## See also

- [Configuration](/docs/functions/cli/configuration.md) — profiles, config file, global flags
- [functions command reference](/docs/functions/cli/commands/functions.md)
