braze-sync 0.11.0

GitOps CLI for managing Braze configuration as code
Documentation

braze-sync

GitOps CLI for managing Braze configuration as code.

braze-sync lets you keep Braze workspace state in a Git repository and synchronize it to Braze with the same workflow you'd use for terraform plan / kubectl diff — including dry-run previews, drift detection in CI, and an --allow-destructive gate that has to be crossed explicitly before anything is dropped.

Status: v0.10.0 (5 resources + init)

braze-sync manages Braze configuration as code. The five managed resource kinds are:

  • Catalog Schema (field definitions, types, constraints)
  • Content Block (reusable Liquid fragments)
  • Email Template (HTML/Liquid templates)
  • Custom Attribute registry (definition-level; deprecation toggle)
  • Tag registry (workspace tags referenced by other resources; derived from local frontmatter because Braze exposes no tag API)

Out of scope: runtime data like catalog items, user attribute values, events, and campaigns. Those have their own systems of record; use the Braze REST API or data pipelines directly. See docs/scope-boundaries.md.

Command What it does
braze-sync init Scaffolds a new workspace (config, directories, .gitignore)
braze-sync export Pulls current Braze state into local files
braze-sync diff Shows drift between local files and Braze
braze-sync apply Applies local intent to Braze (dry-run by default)
braze-sync validate Local-only structural and naming checks (no API call)

Content Block specifics

Content Blocks live as content_blocks/<name>.liquid files: YAML frontmatter (name, description, tags, state) followed by the Liquid body. braze-sync apply can create new blocks and update existing ones, but the Braze API has no DELETE for content blocks, so blocks that exist in Braze but not in Git become orphansdiff flags them and apply does nothing about them by default. Pass --archive-orphans to rename them remotely with an [ARCHIVED-YYYY-MM-DD] prefix; the data is never silently dropped.

When a block body references another block via the Liquid include syntax {{content_blocks.${other} | id: '...'}}, apply topologically sorts so the referenced block is created before the referrer. Without this, Braze rejects forward references at create time with an opaque HTTP 500. Cycles abort the apply with a named-blocks error before any write fires. Set resources.content_block.apply_order: alphabetical to restore pre-v0.11 ordering.

Install

Pre-built binaries (recommended):

Download from GitHub Releases for Linux (x86_64, aarch64), macOS (x86_64, Apple Silicon), and Windows (x86_64).

Homebrew (macOS / Linux):

brew install uny/tap/braze-sync

cargo install (requires Rust toolchain):

cargo install braze-sync

Build from source:

cargo install --path .

Quick start

  1. Scaffold a new workspace (config, directories, .gitignore):

    braze-sync init
    

    This writes a commented braze-sync.config.yaml (pointing at the EU default endpoint — edit if your instance is elsewhere) plus empty catalogs/, content_blocks/, email_templates/, and custom_attributes/ directories. Safe to re-run: existing configs are kept unless --force is passed.

  2. Set your Braze API key in an environment variable:

    export BRAZE_DEV_API_KEY="your-key-here"
    
  3. Pull the current Braze state into the scaffolded layout:

    braze-sync export
    

    Or do steps 1 and 3 in one shot:

    braze-sync init --from-existing
    
  4. Edit a resource (e.g. add a catalog field) and check the drift:

    braze-sync diff
    
  5. Apply the change — dry-run first, then for real:

    braze-sync apply              # dry-run, makes zero write calls
    braze-sync apply --confirm    # actually applies
    
  6. In CI, fail builds on drift or local validation issues:

    braze-sync validate               # exits 3 if any local file is invalid
    braze-sync diff --fail-on-drift   # exits 2 if Braze drifted from Git
    

    validate is local-only and does not need an API key, so it runs cleanly on fork PRs that don't have access to repository secrets.

Safety by default

braze-sync apply is dry-run by default. You must pass --confirm to write to Braze. Destructive operations (field deletes) require an additional --allow-destructive flag — apply exits with code 6 if you try to drop a field without it.

braze-sync apply --confirm                     # add fields ok, drop fields → exit 6
braze-sync apply --confirm --allow-destructive # field drops permitted

API keys never live in the config file. The config only references the name of the environment variable (api_key_env), and the key is held in secrecy::SecretString from the moment it leaves the OS so that tracing / Debug / panic messages cannot leak it.

Limitations

These will be lifted across the v0.x → v1.0 milestones:

  • No catalog delete. apply creates new catalogs and adds fields, but it will not delete a catalog that exists in Braze and is missing from Git — the side effect (loss of all items) is too large to do implicitly. Drop the catalog manually in the Braze dashboard.
  • No field type changes. Changing a field's type from string to number (or similar) is not auto-applied because the operation is data-losing on the field. Drop the field manually in Braze, then run braze-sync apply to re-add it with the new type.
  • No DELETE for content blocks. Braze's content blocks API does not expose a DELETE endpoint, so blocks that exist in Braze but not in Git become orphans. diff flags them; apply does nothing about them unless you pass --archive-orphans, which renames them remotely with an [ARCHIVED-YYYY-MM-DD] prefix instead of pretending they were dropped.
  • Content block state is local-only and not observable. The state: active|draft field in content_blocks/<name>.liquid frontmatter is a purely local authoring annotation. Braze's /content_blocks/info endpoint does not return state, so braze-sync export writes no state: line for any block fetched from Braze rather than defaulting to active and pretending it knows. If you want the annotation, add it to the file by hand after export. apply writes the field exactly once — when creating a new block — and never sends it on updates, so editing state on a block that already exists in Braze has no effect and the next export will strip it again. The diff layer also ignores the field to prevent an "infinite drift" loop (Braze has no DELETE, so a persistently-Modified Content Block is a trap).
  • --archive-orphans is a two-step read-modify-write. The rename fetches /content_blocks/info to preserve the body, then posts /content_blocks/update with the archived name. If another operator edits the same block in the dashboard between those two calls, the update clobbers their change with the pre-rename body. Safe for the single-operator GitOps workflow v0.2.0 targets; a compare-and-swap header would lift it, but Braze's content_blocks API does not currently document one.
  • --no-color only affects tracing output. Table and diff output do not currently emit ANSI colors, so the flag only suppresses ANSI escapes from the tracing subscriber on stderr.

Exit codes

These are frozen at v1.0: scripts and CI configs can rely on them across all v1.x releases.

Code Meaning
0 Success
1 General error
2 Drift detected (diff --fail-on-drift)
3 Config / argument error (or validate issues)
4 Authentication failed (invalid API key)
5 Rate limit retries exhausted
6 Destructive change blocked (pass --allow-destructive)

Output formats

The global --format flag picks between human-readable and machine-readable output for diff and apply:

braze-sync diff --format table   # default — emoji + indented text
braze-sync diff --format json    # frozen v1 schema with `version: 1`

The JSON shape is frozen at v1.0 with an explicit version: 1 field on the root. Future schema bumps will increment version, so CI consumers can branch on it.

Verifying release artifacts

Release archives from GitHub Releases are signed with Sigstore cosign in keyless mode — the signing identity is the release workflow itself, not a long-lived key. Each .tar.gz / .zip ships with a .cosign.bundle carrying the signature and Fulcio certificate. To verify, download both and run:

cosign verify-blob \
  --bundle braze-sync-<target>.tar.gz.cosign.bundle \
  --certificate-identity 'https://github.com/uny/braze-sync/.github/workflows/release.yml@refs/tags/v<version>' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  braze-sync-<target>.tar.gz

A successful run prints Verified OK. Any mismatch — tampering, wrong repo, or a build from a different workflow — fails. The SHA-256 digests (.sha256) are still published for consumers that only need a content hash.

Further reading

License

MIT