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

> ## Agent Instructions
> Cognite Data Fusion (CDF) is an industrial DataOps platform. Prefer English docs unless the user asks for another locale.
> Follow /_llms/ index links recursively until you reach page URLs ending in .md. Fetch those Markdown twins instead of HTML.
> For REST APIs, default to calendar version 20230101 (stable). Use 20230101-beta or 20230101-alpha only if the user is on a preview API. Cite paths under /api-reference/concepts/<version>/ and the Cdf-Version header. Prefer the Python SDK (cognite-sdk) unless the user is in JavaScript/TypeScript.
> Start here by task: platform overview /cdf/index.md; data modeling /cdf/dm/index.md; data integration /cdf/integration/index.md; access /cdf/access/index.md; CDF Toolkit /cdf/deploy/cdf_toolkit/index.md; REST quickstart /dev/quickstart.md; Python SDK https://cognite-sdk-python.readthedocs-hosted.com/en/stable/; Atlas AI /cdf/atlas_ai/concepts/index.md; Flows apps /cdf/flows/index.md; MCP and IDE setup /dev/guides/ide_ai_integration.md.
> For implementation work, also load /skill.md.

# Signing Flows custom apps from CI/CD

> Set up a GitHub Actions pipeline that deploys, signs, and activates a Flows custom app with a CI/CD signing key. Only the developer signature is covered, and only on projects with Dev status.

A pipeline signs with the same commands you use locally. The difference is where the key lives: it can't sit in Keychain, so you generate a movable key, encrypt it with a passphrase, and store both in CI secrets. `apps sign` reads the passphrase from `COGNITE_KEY_PASSPHRASE` instead of prompting.

> [!NOTE]
>
> Read [Signing Flows custom apps from the CLI](https://docs.cognite.com/cdf/flows/guides/code-signing.md) first. This page only covers the developer signature, and only on projects with [Dev status](https://docs.cognite.com/cdf/flows/guides/dev-status.md).

## Responsibility of the key owner

A signing key identifies a person, not a repository. Whoever can change what your pipeline runs can sign as you.

So as the key owner, you vouch for the pipeline: that it's healthy, that it hasn't been tampered with, and that changes to it get reviewed. [Restricting what can trigger signing](#restrict-what-can-trigger-signing) is part of setting this up, not an optional hardening step you get to later.

## Prerequisites

* A working local signing setup, as described in [Signing Flows custom apps from the CLI](https://docs.cognite.com/cdf/flows/guides/code-signing.md).
* [Dev status](https://docs.cognite.com/cdf/flows/guides/dev-status.md) on the target Cognite Data Fusion (CDF) project.
* A GitHub repository holding the app, with an OAuth client for deployment. See [Automated deployment](https://docs.cognite.com/cdf/flows/guides/deploying.md#automated-deployment-cicd).
* The [GitHub CLI](https://cli.github.com), for setting secrets without pasting them into a browser.

## Set up the pipeline

**1. Generate a passphrase**

Run the following command to generate a passphrase of 15 characters or more and copy it to the clipboard. The passphrase protects the key once it's in CI.

> [tab] macOS
>
> ```bash theme={"languages":{"custom":["/_languages/kuiper.json"]}}
> openssl rand -base64 32 | pbcopy
> ```

> [tab] Windows
>
> ```powershell theme={"languages":{"custom":["/_languages/kuiper.json"]}}
> openssl rand -base64 32 | Set-Clipboard
> ```

> [tab] Linux
>
> ```bash wrap theme={"languages":{"custom":["/_languages/kuiper.json"]}}
> openssl rand -base64 32 | xclip -selection clipboard
> ```

Paste the passphrase into your password manager before you continue. You'll need it again when you store the secret, and you can't recover it from the key.

**2. Generate a CI/CD key**

Run the following command to generate a key you can copy into CI:

```bash wrap theme={"languages":{"custom":["/_languages/kuiper.json"]}}
npx @cognite/cli@latest keys generate --no-keychain --interactive
```

`--no-keychain` gives you a key you can move. The private key goes in a file instead of Keychain.

When the CLI prompts, enter a key ID (`kid`), an email, an expiry, and the passphrase you just generated. The command writes the encrypted private key to `~/.cognite-cli/keys/<kid>.key.jwe` and the public key to `<kid>.pub.pem`.

Set the `kid` to a name for this pipeline, such as `acme-ci-001`. Keep this key separate from your local signing key so that a compromise of the pipeline exposes only the CI/CD key.

> [!WARNING]
>
> Never commit the `.key.jwe` file or the passphrase.

**3. Register the public key**

Submit the Zendesk form from the link `keys generate` shows in the terminal. The link opens a pre-filled form. Choose **Open in browser**, or pass `--copy-link` and submit the form later.

In the ticket, state that this is a CI/CD key. Most builders already have a local key registered, and without that note the request looks like a duplicate.

> [!WARNING]
>
> Wait until Cognite confirms that the key is registered, then run the signing job for the first time. Until then, `apps sign` fails in CI with a 403 and the job stops before `apps activate`. While you wait, you can store the secrets, pin the CLI, and add the workflow.

**4. Store the secrets**

In the app repository, store the signing key, the passphrase, and the key ID:

```bash wrap theme={"languages":{"custom":["/_languages/kuiper.json"]}}
gh secret set COGNITE_SIGNING_KEY_JWE < ~/.cognite-cli/keys/<kid>.key.jwe
gh secret set COGNITE_KEY_PASSPHRASE
gh variable set COGNITE_SIGNING_KID --body <kid>
```

`gh secret set COGNITE_KEY_PASSPHRASE` prompts for the passphrase, which keeps it out of your shell history.

Store the deploy client secret named by `deploySecretName` in `app.json` the same way:

```bash theme={"languages":{"custom":["/_languages/kuiper.json"]}}
gh secret set MY_APP_SECRET
```

> [!TIP]
>
> For additional security, consider putting the signing secrets in a separate GitHub [environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) with required reviewers, so signing waits for an approval.

**5. Pin the CLI**

In the app repository, install an exact version of the CLI and commit `package.json` and `package-lock.json`:

```bash theme={"languages":{"custom":["/_languages/kuiper.json"]}}
npm install --save-dev --save-exact @cognite/cli
```

The workflow runs `npx cognite`, which uses this install. `--save-exact` records `1.12.0` rather than `^1.12.0`, and the lockfile pins the bytes as well as the version. `npm ci` checks each tarball against that hash and fails the job on a mismatch. To upgrade the CLI, change the pinned version in a pull request. Dependabot or Renovate can open that pull request.

> [!NOTE]
>
> The signing job holds your key, so it must run the CLI version you committed. `npx @cognite/cli@latest` fetches whatever was published most recently.

**6. Add the workflow**

Save the sample below as `.github/workflows/sign-and-publish.yml`.

> [accordion] sign-and-publish.yml
>
> ```yaml wrap theme={"languages":{"custom":["/_languages/kuiper.json"]}}
> name: Deploy and sign
>
> on:
>   pull_request:
>   push:
>     branches: [main]
>
> permissions:
>   contents: read
>
> concurrency:
>   group: deploy-${{ github.ref }}
>   cancel-in-progress: false
>
> jobs:
>   deploy:
>     runs-on: ubuntu-latest
>     steps:
>       - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
>
>       - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
>         with:
>           node-version: 22
>           cache: npm
>
>       - run: npm ci
>       - run: npm run build
>
>       - name: Deploy as a draft
>         env:
>           MY_APP_SECRET: ${{ secrets.MY_APP_SECRET }}
>         run: npx cognite apps deploy --skip-build
>
>       - name: Sign and activate
>         if: github.ref == 'refs/heads/main'
>         env:
>           MY_APP_SECRET: ${{ secrets.MY_APP_SECRET }}
>           SIGNING_KEY_JWE: ${{ secrets.COGNITE_SIGNING_KEY_JWE }}
>           COGNITE_KEY_PASSPHRASE: ${{ secrets.COGNITE_KEY_PASSPHRASE }}
>           KID: ${{ vars.COGNITE_SIGNING_KID }}
>         run: |
>           KEY_FILE="$(mktemp)"
>           trap 'rm -f "$KEY_FILE"' EXIT
>           printf '%s' "$SIGNING_KEY_JWE" > "$KEY_FILE"
>
>           npx cognite apps sign --key "$KEY_FILE" -s "$KID"
>           npx cognite apps activate .
>           npx cognite apps status .
> ```

1. In the pull request, bump `versionTag` in `app.json`. Merging without a bump redeploys a version that's already published.
2. Open the draft deploy on the pull request and check it. Merging to `main` signs that draft and activates it.

If you edit the workflow:

* Pass secrets through `env` so they stay out of the generated shell script.
* Write the key with `printf '%s'` so the bytes stay unchanged.
* Remove the key file with `trap`, including when signing fails.
* Leave `cancel-in-progress` set to `false` so two merges cannot sign two versions into the same slot.

> [!NOTE]
>
> GitHub doesn't pass secrets to workflows triggered from a fork, so the draft deploy fails on pull requests from forks. If you accept outside contributions, gate the deploy step on `github.event.pull_request.head.repo.fork == false`.

> [!NOTE]
>
> The app's dependencies are installed and built on the same runner that holds the key. Review the dependency tree, or split the build into a separate job that passes the bundle as an artifact.

**7. Restrict what can trigger signing**

1. In the repository, open **Settings > Rules > Rulesets** and add a ruleset for `main`. Signing runs when a commit reaches `main`, so this ruleset is what keeps an unreviewed commit from being signed.

2. Require a pull request with at least one approving review. A commit can reach `main` only after someone approves the pull request. A direct push is blocked.

3. Turn on **Dismiss stale approvals**. If someone pushes new commits after an approval, that approval no longer counts. The new commits need a new review.

The workflow pins each action to a commit SHA. Keep those pins. A moved tag would change what runs in the job that holds your key.

> [!WARNING]
>
> Leave administrator bypass off. With it on, an administrator can merge without the pull request and approval rules.

**8. Confirm the signature**

After the pipeline runs on `main`, check the version:

```bash theme={"languages":{"custom":["/_languages/kuiper.json"]}}
npx @cognite/cli@latest apps status .
```

> [!TIP]
>
> The version is **ACTIVE** and your developer signature reads `VALID` for the `kid` the pipeline used.

## Renew the key before it expires

Signing keys are valid for 12 months by default. When the key expires, the pipeline fails. The person who created the key may no longer maintain the repository.

Set a shared calendar reminder for nine months before the key expires, so the repository maintainers can renew it. To keep the key longer, ask Cognite in a Zendesk ticket to extend its validity. Cognite usually completes that request quickly.

## Rotate a key used by a pipeline

Rotate when a key expires or you suspect it's compromised, and also when someone with access to the repository secrets leaves the team.

1. Generate a replacement with `keys generate --no-keychain --interactive`.
2. Register the new public key.
3. Update the `COGNITE_SIGNING_KEY_JWE` and `COGNITE_KEY_PASSPHRASE` secrets and the `COGNITE_SIGNING_KID` variable.
4. Ask your Cognite contact to revoke the old key.

Versions already published stay published.

## Other CI systems

The sample workflow is GitHub Actions. The signing steps are the same on Azure DevOps or another CI system:

* Store the encrypted key and its passphrase as secrets.
* Write the key to a temporary file, set `COGNITE_KEY_PASSPHRASE`, and run `apps sign` and `apps activate`.
* Limit who can change the pipeline and what can trigger it. Use the controls that system provides.

## Troubleshooting

> [accordion] The key is passphrase-encrypted, but no passphrase was supplied
>
> The key reached the CLI and the passphrase didn't. Confirm `COGNITE_KEY_PASSPHRASE` is set on the step that runs `apps sign`, and that the secret has a value. A blank value counts as unset. Secrets aren't passed to workflows triggered from forks, so a run from a fork sees an empty value.

> [accordion] The job fails with `decryption operation failed`
>
> The passphrase reached the CLI but doesn't match the key. Check that `COGNITE_SIGNING_KEY_JWE` and `COGNITE_KEY_PASSPHRASE` come from the same `keys generate` run. Rotating one without the other is the usual cause. Verify the pair locally before updating the secrets:
>
> ```bash wrap theme={"languages":{"custom":["/_languages/kuiper.json"]}}
> npx @cognite/cli@latest apps sign --key ~/.cognite-cli/keys/<kid>.key.jwe -s <kid>
> ```

> [accordion] `Invalid passphrase: must be at least 15 characters`
>
> The passphrase in the secret is too short. This is enforced on decryption as well as encryption, so a truncated or hand-typed value fails with this error rather than at key generation. Rotate the key with a generated passphrase.

> [accordion] The passphrase prompt appears and the job hangs
>
> `apps sign --interactive` prompts for the passphrase and ignores `COGNITE_KEY_PASSPHRASE`. Drop `--interactive` from the pipeline.

> [accordion] `apps sign` fails with 403: signing key is not in the public key registry
>
> The job fails at the sign step with:
>
> ```text theme={"languages":{"custom":["/_languages/kuiper.json"]}}
> signature (role=developer, kid=<kid>): signing key used to create the signature is not in the public key registry. | code: 403
>   You don't have the required CDF capabilities. Please contact your CDF admin.
> ```
>
> The first line is the cause: Cognite hasn't registered the CI/CD key's `kid` yet. The second sentence is a generic message the CLI shows for any 403. It is not an access problem.
>
> Confirm with your Cognite representative that the CI/CD key has been added, then re-run the job. Each run starts without the `.dev.sig` file that `apps sign` writes before it sends the signature, so the new run signs the bundle and activates it again. See [403: signing key is not in the public key registry](https://docs.cognite.com/cdf/flows/guides/code-signing.md#403-signing-key-is-not-in-the-public-key-registry).

## Related topics

* [Signing Flows custom apps from the CLI](https://docs.cognite.com/cdf/flows/guides/code-signing.md)
* [Deploying Flows custom apps](https://docs.cognite.com/cdf/flows/guides/deploying.md)
* [Dev status](https://docs.cognite.com/cdf/flows/guides/dev-status.md)
* [Becoming a certified Flows custom apps builder](https://docs.cognite.com/cdf/flows/guides/builder-certification.md)
