# Detect drift in CI

> Run plan --check so a dashboard edit that diverges from the versioned file fails the build.

- Repository: jaredpalmer/vwaffle
- GitHub: https://github.com/jaredpalmer/vwaffle
- Human docs: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581
- Complete Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/llms-full.txt

## Source Files

- `src/index.ts`
- `src/diff.ts`
- `src/api.ts`
- `README.md`

---

---
title: "Detect drift in CI"
description: "Run plan --check so a dashboard edit that diverges from the versioned file fails the build."
---

`vwaffle plan --check` compares the active Vercel Firewall config (`GET /v1/security/firewall/config/active`) with the interpolated desired file (default `firewall.config.json`) and sets `process.exitCode = 1` when `diff()` does not return `No drift detected.` The command never calls `putConfig`. A dashboard edit, a missing interpolated rule, or any extra live field that is not in the versioned JSON fails the job.

## Command

<CodeGroup>

```sh title="npx"
npx vwaffle plan --check
```

```sh title="bunx"
bunx vwaffle plan --check
```

```sh title="installed binary"
vwaffle plan --check
```

</CodeGroup>

`--check` is only consumed by `cmdPlan`. `vwaffle plan` without `--check` prints the same stdout and still exits `0` on drift. `apply` ignores `--check`.

<ParamField body="--check" type="boolean">
When the printed result is not `No drift detected.`, set `process.exitCode = 1` after writing the diff. Does not call `process.exit(1)` immediately, so the full diff reaches CI logs.
</ParamField>

<ParamField body="-c, --config" type="string" default="firewall.config.json">
Path to the desired JSON, resolved from `process.cwd()`.
</ParamField>

<ParamField body="--project" type="string">
Vercel project ID. Overrides `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId`.
</ParamField>

<ParamField body="--team" type="string">
Vercel team ID. Overrides `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId`. Optional; omitted from the query string when unset.
</ParamField>

## Prerequisites

- Node `>=18` (package `engines`).
- A versioned desired file, typically produced by `vwaffle pull --output firewall.config.json` so the JSON shape matches what `getActiveConfig` returns.
- `VERCEL_TOKEN` in the job environment. `resolveContext` throws without it.
- A project ID via `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json`. The repo `.gitignore` lists `.vercel/`, so CI cannot rely on a local `vercel link` unless the runner recreates that file.
- Every `${VAR_NAME}` referenced by rules you want compared. `plan` loads the file with `strict: false`.

<Warning>
`.vercel/` is gitignored. Pass `--project` / `--team` or set `VERCEL_PROJECT_ID` / `VERCEL_TEAM_ID` in the job. A missing project fails with `A project is required.` and exit `1` before any diff.
</Warning>

## What the job does

```mermaid
sequenceDiagram
    participant Job as CI job
    participant Plan as vwaffle plan --check
    participant File as firewall.config.json
    participant API as GET /v1/security/firewall/config/active

    Job->>Plan: invoke
    Plan->>File: loadDesiredConfig(path, strict false)
    File-->>Plan: interpolated FirewallConfig
    Plan->>Plan: resolveContext(token, project, team)
    Plan->>API: getActiveConfig
    API-->>Plan: live.active or live body
    Plan->>Plan: diff(live, desired)
    alt identical tab-indented JSON
        Plan-->>Job: No drift detected. / exit 0
    else any line difference
        Plan-->>Job: unified diff / exit 1
    end
```

`diff()` stringifies both sides with `JSON.stringify(..., null, '\t')`. Key order, extra live fields (`id`, CRS entries, notes), and dropped rules all count as drift. There is no field allowlist.

<Info>
`plan --check` is detect-only. To overwrite live from the file on merge, use `vwaffle apply --yes` on a separate job. See [Apply from CI](/apply-from-ci).
</Info>

## GitHub Actions step

The repository does not ship a workflow file. The README documents this step:

```yaml title=".github/workflows/firewall.yml (example)"
- run: npx vwaffle plan --check
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
    VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }}
```

Add the same `env` entries for every `${VAR_NAME}` used in `firewall.config.json`. Optional flags:

```sh
npx vwaffle plan --check \
  --config firewall.config.json \
  --project "$VERCEL_PROJECT_ID" \
  --team "$VERCEL_TEAM_ID"
```

`VERCEL_API_URL` overrides the API host (default `https://api.vercel.com`). The path remains `/v1/security/firewall/config/active?projectId=…` with `teamId` when set.

## Exit codes

| Condition | stdout / stderr | Exit |
| --- | --- | --- |
| Live JSON equals desired JSON | `No drift detected.` | `0` |
| Any difference and `--check` | Unified diff starting `--- live firewall configuration` | `1` |
| Difference without `--check` | Same unified diff | `0` |
| Missing `VERCEL_TOKEN` | `vwaffle: VERCEL_TOKEN is required. …` | `1` |
| Unresolved project | `vwaffle: A project is required. …` | `1` |
| Missing or invalid desired file | `vwaffle: …` from `readFile` / `JSON.parse` | `1` |
| Firewall API non-OK | `vwaffle: Vercel API {status} {statusText}: …` | `1` |
| Unset `${VAR_NAME}` on plan | `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)` then a possible diff | `0` or `1` |

Errors go through `main().catch`, which prints `vwaffle: {message}` and sets `process.exitCode = 1`. Unset interpolation variables do **not** fail `plan` by themselves.

## Expected output

<ResponseExample>

```text title="In sync"
No drift detected.
```

```text title="Drift (check fails)"
--- live firewall configuration
+++ desired firewall configuration
  {
  	"firewallEnabled": true,
- 	"managedRules": {
- 		"owasp": {
- 			"active": false
- 		}
- 	},
+ 	"managedRules": {
+ 		"owasp": {
+ 			"active": true
+ 		}
+ 	},
  ...
```

</ResponseExample>

`-` lines are live. `+` lines are desired. Unchanged lines use two spaces; `withContext` keeps three lines of context and inserts `  ...` between hunks.

<Warning>
`cmdPlan` prints `diff(live, config)` without `redactText`. Interpolated secret values appear in CI logs whenever those lines differ. `apply` redacts secrets as `[REDACTED]`; `plan` does not. A green check (`No drift detected.`) does not print rule bodies.
</Warning>

## Interpolation in CI

`loadDesiredConfig(..., { strict: false })` expands `${VAR_NAME}` from `process.env`. Missing names stay as the literal placeholder and are recorded as `NAME ($.json.path)`.

| Surface | Behavior |
| --- | --- |
| `config.rules` | `removeRulesWithMissingEnv` drops any rule whose serialized text still contains `${NAME}` |
| Other fields (`ips`, `managedRules`, arbitrary strings) | Placeholder text is left in place and compared to live |
| `apply` (not this command) | `strict: true` throws `missing environment variables: …` |

If CI omits a secret that a versioned rule references, that rule disappears from the desired side while live still has it. `--check` then fails even though the committed file is the intended source of truth.

<Check>
Set every interpolation variable the compared rules need. Treat those values as required CI secrets, not optional local-only env.
</Check>

## Add the check

<Steps>
<Step title="Commit the live-shaped file">
Author from `vwaffle pull --output firewall.config.json` (or `vwaffle init` then pull) so extra API fields are not a permanent diff.
</Step>
<Step title="Inject token, project, team, and ${VAR_NAME} secrets">
`VERCEL_TOKEN` is required. Project is required. Team is optional but needed when the token is team-scoped.
</Step>
<Step title="Run plan --check on the PR or main pipeline">
Use `npx vwaffle plan --check` or `bunx vwaffle plan --check`. Fail the job on exit `1`.
</Step>
<Step title="Read the job log">
Green: `No drift detected.` Red: the unified diff, or a `vwaffle:` auth/API/file error.
</Step>
</Steps>

## Troubleshooting

<AccordionGroup>
<Accordion title="Check fails after a dashboard-only edit">
Expected. Live no longer matches the versioned file. Either revert the dashboard change or `vwaffle pull --output firewall.config.json`, review, and commit — or run `apply --yes` from CI to push the file.
</Accordion>
<Accordion title="Check fails with no dashboard change">
Common causes: unset `${VAR_NAME}` dropped a rule; desired file missing live-only keys; different key order; `--config` pointing at the wrong path; `VERCEL_PROJECT_ID` / `--project` targeting another project.
</Accordion>
<Accordion title="Job fails before a diff">
`VERCEL_TOKEN is required`, `A project is required`, or `Vercel API {status}` come from `resolveContext` / `request`, not from drift. Fix credentials and IDs first.
</Accordion>
<Accordion title="Warning about unset variables, then exit 1">
`plan` skipped rules that still contain `${NAME}`. Add those variables to the job `env` so the desired side includes the same rules as live.
</Accordion>
<Accordion title="plan without --check is always green">
The diff still prints. Only `--check` maps `result !== 'No drift detected.'` to exit `1`.
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Apply from CI" href="/apply-from-ci">
Promote `firewall.config.json` with `apply --yes` on merge.
</Card>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
`--check` versus `--dry-run` versus `--yes`.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Unset `${VAR_NAME}` on plan versus apply, and exit `1` on drift.
</Card>
<Card title="Diff output" href="/diff-output">
How `diff`, `diffLines`, and `withContext` render live versus desired.
</Card>
<Card title="Project and team context" href="/project-context">
How `resolveContext` picks token, project, and team.
</Card>
</CardGroup>
