---
name: setup-tundri-cicd
description: Configure GitHub Actions or GitLab CI automated workflows that run tundri whenever permifrost.yml is changed. Use when setting up CI/CD for dry run on PRs, production run on merge, DROP statement safety checks, or repository secrets.
disable-model-invocation: true
---

# Set Up CI/CD for Tundri

Configure automated workflows that run tundri whenever `permifrost.yml` is changed. Use this skill after you have successfully run tundri locally at least once.

## Context

Three workflows are set up:

- **Dry run** — Triggers on pull requests that modify `permifrost.yml`. Runs `tundri run --dry` to validate changes without applying them. Acts as a CI check on the PR. Also detects DROP statements and sets a `drop-statement-review` commit status.
- **Production run** — Triggers on merge to `main` when `permifrost.yml` is modified. Applies the changes to Snowflake.
- **DROP approval** — Listens for `/proceed-with-drop-statements` comments on PRs. When a user with write/admin access comments this, the `drop-statement-review` status is set to success, unblocking the merge.

The dry run and production workflows decode a base64-encoded private key from repository secrets at runtime. This avoids storing the raw key file in the repository.

### DROP statement safety check

When permifrost runs, it can DROP Snowflake objects (databases, schemas, roles, etc.) that exist in Snowflake but are not declared in `permifrost.yml`. This is dangerous when someone creates objects manually and forgets to add them — a merge would silently drop them.

The dry run workflow detects DROP statements in the tundri output and:
1. Sets a `drop-statement-review` commit status to **failure**
2. Posts a PR comment listing the DROP statements
3. Instructs the reviewer to comment `/proceed-with-drop-statements` to approve

The DROP approval workflow then:
1. Verifies the commenter has write/admin access
2. Reacts to the comment with an eyes emoji as acknowledgment
3. Sets the `drop-statement-review` status to **success**

Templates for GitHub Actions and GitLab CI are provided in the `assets/` folder of this skill and the [setup-tundri-repository](../setup-tundri-repository/) skill.

## Prerequisites

- The `<client>-snowflake` repository created with workflow files (via [setup-tundri-repository](../setup-tundri-repository/))
- A successful local tundri run (via [setup-tundri-local-dev](../setup-tundri-local-dev/))
- `op` CLI installed and signed in (see [1password skill](../../../../../security/1password/))
- The `permifrost` user's private key (`.p8` file) — either available locally or retrieve it from 1Password using `op` (see step 2 below)

## Steps

**Checklist:**

- [ ] 1. Verify workflow files are in the repository
- [ ] 2. Create repository secrets
- [ ] 3. Test the CI/CD pipeline
- [ ] 4. Enable branch protection

### 1. Verify workflow files are in the repository

Check that these files exist in the `<client>-snowflake` repository:

**For GitHub:**
- `.github/workflows/permifrost-run-dry.yml`
- `.github/workflows/permifrost-run-prod.yml`
- `.github/workflows/permifrost-drop-approval.yml`

**For GitLab:**
- `.gitlab-ci.yml`

If they are missing, copy them from the `assets/` folder of this skill. See the [setup-tundri-repository](../setup-tundri-repository/) skill for the GitHub Actions templates, or use `assets/gitlab-ci-self_hosted_and_shell_based_runner.yml` in this folder as a starting point for GitLab.

### 2. Create repository secrets

**Important:** Never print secrets or base64-encoded keys to the terminal. Always pipe values directly from `op` into `gh secret set` (or the GitLab equivalent).

The three required secrets are:

| Secret name | Description |
|---|---|
| `SNOWFLAKE_ACCOUNT` | The Snowflake account identifier |
| `PERMISSION_BOT_PRIVATE_KEY_ENCODED_BASE64` | The permifrost private key, base64-encoded |
| `PERMISSION_BOT_KEY_PASSPHRASE` | The passphrase for the private key |

**GitHub — set secrets via CLI (recommended):**

All values are piped directly from 1Password into `gh secret set` — nothing is printed or written to disk.

```bash
# Set SNOWFLAKE_ACCOUNT
op item get "<Client Name> - Snowflake: admin" --vault "<client_vault>" --field account_identifier --reveal 2>/dev/null \
  | gh secret set SNOWFLAKE_ACCOUNT --repo <org>/<repo>

# Set PERMISSION_BOT_KEY_PASSPHRASE
op item get "<Client Name> - Snowflake: permifrost" --vault "<client_vault>" --field passphrase --reveal 2>/dev/null \
  | gh secret set PERMISSION_BOT_KEY_PASSPHRASE --repo <org>/<repo>

# Set PERMISSION_BOT_PRIVATE_KEY_ENCODED_BASE64
# Read private key from 1Password, base64-encode it in memory, pipe to gh
op item get "<Client Name> - Snowflake: permifrost" --vault "<client_vault>" --format json 2>/dev/null \
  | python3 -c "import json,sys,base64; item=json.load(sys.stdin); [print(base64.b64encode(f['value'].encode()).decode()) for f in item['fields'] if 'private_key' in f.get('label','')]" \
  | gh secret set PERMISSION_BOT_PRIVATE_KEY_ENCODED_BASE64 --repo <org>/<repo>
```

**Note:** Use `op item get --field` instead of `op read` — the `op://` secret reference format does not support special characters (like colons) in item titles.

Verify secrets are set (this only shows names and timestamps, not values):

```bash
gh secret list --repo <org>/<repo>
```

**GitLab:**

Go to the repository: **Settings > CI/CD > Variables** and create the same three variables. Use `op read` to copy values from 1Password — do not echo them to the terminal.

### 3. Test the CI/CD pipeline

After the workflow files are merged to `main` and secrets are configured:

1. Create a branch with a small change to `permifrost.yml` (e.g. add a comment)
2. Open a pull request
3. Verify the dry run workflow triggers and passes
4. Merge the PR
5. Verify the production run workflow triggers and passes

**If the CI/CD fails:**

- **"Password empty"** — Double-check the `PERMISSION_BOT_KEY_PASSPHRASE` secret value
- **"JWT token invalid"** — Double-check the base64-encoded key; re-encode and re-paste if needed
- **"Connection refused"** — Verify the `SNOWFLAKE_ACCOUNT` secret has the correct format

### 4. Enable branch protection

Configure the repository to require checks to pass before merging:

**GitHub:**
1. Go to **Settings > Branches > Branch protection rules**
2. Add a rule for `main`
3. Enable **Require status checks to pass before merging**
4. Search for and select both the tundri dry run check and `drop-statement-review`
5. Save

**Note:** The `drop-statement-review` status check only appears in the search after it has been set at least once. Run a test PR first (step 3) before configuring branch protection. Also, this requires a paid GitHub plan (Team or higher) — on the free plan, you can still use the workflows but cannot enforce them as required checks.

**GitLab:**
1. Go to **Settings > Repository > Protected branches**
2. Configure `main` to require pipeline success

This prevents merging broken `permifrost.yml` changes and ensures DROP statements are explicitly approved before being applied to Snowflake.

## GitLab CI Template

For clients using GitLab with a self-hosted shell-based runner, a template is provided at `assets/gitlab-ci-self_hosted_and_shell_based_runner.yml`. Key differences from GitHub Actions:

- Uses `before_script` to set up the Python environment (via pyenv on the runner machine)
- Uses `rules` with `$CI_PIPELINE_SOURCE == "merge_request_event"` for dry runs
- Uses `rules` with `$CI_COMMIT_BRANCH == "main"` for production runs
- Requires the runner to have pyenv and the target Python version installed

Adapt the template to the client's GitLab runner setup (Docker executor, specific Python version, runner tags, etc.).

## Validation

- [ ] Workflow files exist in the repository at the correct paths
- [ ] All three repository secrets are created
- [ ] A test PR triggers the dry run workflow and it passes
- [ ] Merging the test PR triggers the production workflow and it passes
- [ ] Branch protection is enabled on `main` requiring the dry run check

## Reference: Secrets Summary

| Secret | 1Password source |
|---|---|
| `SNOWFLAKE_ACCOUNT` | `op item get "<Client> - Snowflake: admin" --vault "<vault>" --field account_identifier --reveal` |
| `PERMISSION_BOT_PRIVATE_KEY_ENCODED_BASE64` | Private key from permifrost entry, base64-encoded in memory (see step 2) |
| `PERMISSION_BOT_KEY_PASSPHRASE` | `op item get "<Client> - Snowflake: permifrost" --vault "<vault>" --field passphrase --reveal` |
