# Apply from CI

> Promote firewall.config.json as source of truth by running apply --yes on merge with project, team, and token inputs.

- 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/api.ts`
- `src/config.ts`
- `README.md`

---

---
title: "Apply from CI"
description: "Promote firewall.config.json as source of truth by running apply --yes on merge with project, team, and token inputs."
---

`vwaffle apply --yes` is the non-interactive promotion path. `cmdApply` loads the desired file (default `firewall.config.json`) with `loadDesiredConfig(..., { strict: true })`, resolves `VERCEL_TOKEN` plus project and team through `resolveContext`, diffs `GET /v1/security/firewall/config/active` against the interpolated JSON, then `putConfig` issues `PUT /v1/security/firewall/config`. Without `--yes` (and without `--dry-run`) the command throws and the process exits `1`, so a merge job must pass `--yes`. When the live and desired JSON stringify identically, apply prints `No drift detected.` and skips the PUT.

<Warning>
`apply` without `--yes` is not a no-op plan. It fails immediately with `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.`
</Warning>

## When to run apply in CI

The README positions two complementary jobs:

| Job | Command | Role |
| --- | --- | --- |
| Pull request / branch check | `npx vwaffle plan --check` | Exit `1` when live config differs from the versioned file |
| Merge to the default branch | `npx vwaffle apply --yes` | PUT the versioned file so `firewall.config.json` wins over dashboard edits |

`plan --check` only reports drift. `apply --yes` overwrites the live Vercel Firewall config with the interpolated desired file. Re-running apply after a successful PUT is idempotent: the next run prints `No drift detected.` and does not call PUT.

## Prerequisites

- Node `>=18` (`package.json` `engines.node`) so `npx` / `bunx` can run the published `vwaffle` binary (`dist/index.js`).
- A committed desired file. Default path is `firewall.config.json` relative to `process.cwd()`. Override with `-c` / `--config FILE`.
- A Vercel API token in `VERCEL_TOKEN` ([account tokens](https://vercel.com/account/tokens)).
- A project ID. CI checkouts usually do not include `.vercel/project.json`, so set `VERCEL_PROJECT_ID` or pass `--project`.
- A team ID when the project is team-scoped: `VERCEL_TEAM_ID` or `--team`. `teamId` is omitted from the API URL when unset.
- Every `${VAR_NAME}` referenced in the desired JSON must be present in the apply job environment. Strict apply fails instead of skipping rules.

<Note>
`resolveContext` also reads `.vercel/project.json` (`projectId`, `orgId`) when that file exists. Prefer explicit env vars or flags in CI so the job does not depend on a linked local checkout.
</Note>

## Inputs

Context resolution is first-match:

| Value | Sources |
| --- | --- |
| Token | `VERCEL_TOKEN` only (required) |
| Project | `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId` |
| Team | `--team`, then `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId` |
| Desired file | `-c` / `--config` (default `firewall.config.json`, resolved from `process.cwd()`) |
| API host | `VERCEL_API_URL` (default `https://api.vercel.com`) |

<ParamField body="--yes" type="boolean" required>
Confirms the live PUT. Required unless `--dry-run` is set.
</ParamField>

<ParamField body="--dry-run" type="boolean">
Prints the redacted interpolated payload and returns without `resolveContext`, GET, or PUT. Interpolation is non-strict (`strict: false`), so missing `${VAR_NAME}` values warn and drop matching `rules` instead of failing.
</ParamField>

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

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

<ParamField body="--team" type="string">
Vercel team ID. Overrides `VERCEL_TEAM_ID` and `.vercel/project.json` `orgId`.
</ParamField>

<ParamField body="VERCEL_TOKEN" type="string" required>
Bearer token for `Authorization: Bearer ${token}` on both GET `/active` and PUT.
</ParamField>

<ParamField body="VERCEL_PROJECT_ID" type="string">
Project query parameter when `--project` is omitted.
</ParamField>

<ParamField body="VERCEL_TEAM_ID" type="string">
Team query parameter when `--team` is omitted.
</ParamField>

Any other environment variable named in a `${VAR_NAME}` placeholder is interpolated into string fields before the PUT body is sent. Interpolated values are replaced with `[REDACTED]` in the printed diff.

## Merge job

The published repository does not ship a workflow file. The documented pattern is a GitHub Actions step that injects token, project, and team, then runs `apply --yes` after merge.

<Steps>
<Step title="Keep the desired file in the checkout">
Commit `firewall.config.json` (or pass `--config` to a non-default path). `cmdApply` reads from `process.cwd()`, so run the job from the repository root that contains that file.
</Step>
<Step title="Inject token, project, team, and interpolation secrets">
Map CI secrets/vars onto `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID`, and every `${VAR_NAME}` used in the JSON. Do not commit secret values.
</Step>
<Step title="Run apply --yes">
Use `npx`, `bunx`, or a globally installed `vwaffle`. The `--yes` flag is what allows the PUT.
</Step>
<Step title="Confirm the success line">
Expect either `Applied firewall configuration` / `Applied firewall configuration (version N)` after a PUT, or `No drift detected.` when live already matches desired.
</Step>
</Steps>

<CodeGroup>

```yaml title="GitHub Actions merge step"
# .github/workflows/firewall.yml — apply on merge (README pattern)
- run: npx vwaffle apply --yes
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
    VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }}
    # Add every ${VAR_NAME} referenced in firewall.config.json, e.g.:
    # INTERNAL_TOKEN: ${{ secrets.INTERNAL_TOKEN }}
```

```sh title="npx with flags"
npx vwaffle apply --yes \
  --project "$VERCEL_PROJECT_ID" \
  --team "$VERCEL_TEAM_ID"
```

```sh title="bunx"
bunx vwaffle apply --yes
```

```sh title="Inspect payload only"
npx vwaffle apply --dry-run
```

</CodeGroup>

Pair this with `npx vwaffle plan --check` on pull requests so dashboard-only edits fail the branch build before merge. After merge, `apply --yes` makes the file the source of truth.

## Apply lifecycle

```mermaid
sequenceDiagram
  participant Job as CI job
  participant Apply as cmdApply
  participant Load as loadDesiredConfig
  participant Ctx as resolveContext
  participant API as Vercel Firewall API

  Job->>Apply: vwaffle apply --yes
  alt missing --yes and not --dry-run
    Apply-->>Job: exit 1 apply requires --yes
  else --dry-run
    Apply->>Load: strict false
    Load-->>Apply: interpolated config
    Apply-->>Job: redacted JSON, no API
  else --yes
    Apply->>Load: strict true
    alt missing ${VAR_NAME}
      Load-->>Job: exit 1 missing environment variables
    else interpolated
      Apply->>Ctx: token, project, team
      Ctx->>API: GET /v1/security/firewall/config/active
      API-->>Apply: live config
      alt No drift detected.
        Apply-->>Job: print message, skip PUT
      else live differs
        Apply->>API: PUT /v1/security/firewall/config
        API-->>Apply: optional version
        Apply-->>Job: Applied firewall configuration
      end
    end
  end
```

:::endpoint PUT /v1/security/firewall/config
Writes the interpolated desired `FirewallConfig` as the JSON body.

**Query**

| Name | Source |
| --- | --- |
| `projectId` | Resolved project ID (required) |
| `teamId` | Resolved team ID (omitted when unset) |

**Headers**

- `Authorization: Bearer ${VERCEL_TOKEN}`
- `Content-Type: application/json`

**Body** — interpolated desired config (`firewallEnabled`, `managedRules`, `rules`, `ips`, and other Firewall API fields). Placeholders such as `${INTERNAL_TOKEN}` are expanded before serialize.

**CLI stdout after a successful PUT**

```text
--- live firewall configuration
+++ desired firewall configuration
  ...
Applied firewall configuration (version 12).
```

Secret substrings in the diff are replaced with `[REDACTED]`. The version suffix is included only when the PUT response has `version`.
:::

:::endpoint GET /v1/security/firewall/config/active
Fetched on every live `apply --yes` before the PUT so the printed diff compares live versus desired. `getActiveConfig` unwraps `{ active: FirewallConfig }` when that wrapper is present.
:::

## Verification

<Check>
A successful apply that changed live config prints `Applied firewall configuration` or `Applied firewall configuration (version N)` and exits `0`.
</Check>

<Check>
A successful apply with no change prints `No drift detected.` and exits `0` without PUT.
</Check>

Failures print `vwaffle: <message>` to stderr and set `process.exitCode = 1`.

<RequestExample>
```sh
npx vwaffle apply --yes
```
</RequestExample>

<ResponseExample>
```text
No drift detected.
```
</ResponseExample>

## Interpolation in the apply job

`apply --yes` calls `loadDesiredConfig` with `strict: true`. Unset `${VAR_NAME}` values abort before any Vercel request:

```text
vwaffle: missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.
```

That is different from `plan` and `apply --dry-run`, which warn, run `removeRulesWithMissingEnv`, and continue. A merge job that omits a secret used by a bypass or allow rule will fail closed rather than PUT a config with that rule dropped.

## Failure modes

<AccordionGroup>
<Accordion title="apply requires --yes">
The job invoked `vwaffle apply` without `--yes` or `--dry-run`. Add `--yes` on the merge job.
</Accordion>
<Accordion title="VERCEL_TOKEN is required">
`resolveContext` found no `VERCEL_TOKEN`. Export it in the job environment.
</Accordion>
<Accordion title="A project is required">
No `--project`, no `VERCEL_PROJECT_ID`, and no `.vercel/project.json` `projectId`. Set the project in CI.
</Accordion>
<Accordion title="missing environment variables">
A `${VAR_NAME}` in the desired file is unset. Strict apply refuses to PUT. Add the variable to the job `env`.
</Accordion>
<Accordion title="Vercel API status statusText">
`request` received a non-OK response. The message includes the HTTP status and parsed body. Check token scope, `projectId` / `teamId`, and that the interpolated JSON is a valid Firewall config body.
</Accordion>
<Accordion title="unknown option / unknown command">
`parseArgs` rejected a flag or command. Apply accepts `--yes`, `--dry-run`, `--config` / `-c`, `--project`, `--team`, `--help`, and `--version`. `--check` is a `plan` flag only.
</Accordion>
</AccordionGroup>

<Warning>
`apply --yes` always fetches live config first. A missing token or project fails even when the desired file is unchanged. `--dry-run` is the only apply mode that skips authentication.
</Warning>

## Next

<CardGroup>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run `plan --check` on pull requests so dashboard edits fail the build before merge.
</Card>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
How `--check`, `--dry-run`, and `--yes` change exit codes and whether PUT runs.
</Card>
<Card title="Project and team context" href="/project-context">
How `resolveContext` picks token, project, and team from flags, env, and `.vercel/project.json`.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Strict apply vs skipped rules on plan, and `plan --check` exit `1` on drift.
</Card>
</CardGroup>
