CI recipes
Two jobs cover most repositories: validate every pull request, and push a version when a change lands on main. The examples use GitHub Actions; nothing in them is specific to it.
Validate pull requests
better check needs no credentials, no binding, and no network. It validates the tree in place, which makes it the cheapest job in the repository:
name: Design System checks
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install -g @better-email/cli
- run: better check
npm create better-email writes this workflow into new repositories.
The job fails on a broken JSON file, a missing module file, a module directory absent from modules.order, a setting key used twice, or a value that would be rewritten on import. Every failure names the file. What it cannot see is anything that depends on the platform — whether the design system has moved on, whether a module you deleted is still used by campaigns, the 5 MB definition limit — because it never talks to the API. Those are checked again when you push.
Push on merge
Pushing needs credentials and a binding.
Credentials
Create an organization API key under Settings → API Keys (Admin only) and store it as a repository secret. Personal browser sessions from better login are for humans; a key belongs to the organization and survives people leaving.
The CLI reads two environment variables:
| Variable | Value |
|---|---|
BETTER_API_KEY | The organization API key. |
BETTER_BASE_URL | https://app.better.email |
Both are required together. With BETTER_API_KEY set and BETTER_BASE_URL missing, the CLI has no host to talk to and reports Not logged in. Run better login., which is a confusing way to say the base URL is absent. Set both and you never need to run better login in CI.
Only organization-scoped keys work. A key with any other scope is rejected with Only organization API keys can be used with the BetterEmail CLI.
The binding
better ds push writes to the design system recorded in .better/config.json, and npm create better-email keeps that file out of git — it is per-checkout state, rewritten by every pull and push, so committing it means constant churn and merge conflicts.
Recreate it in the job instead. better ds pull --dir writes into a directory of your choosing, so pulling into a scratch directory produces a binding without touching your tree:
name: Push design system
on:
push:
branches: [main]
paths:
- 'design-system.json'
- 'base.liquid'
- 'settings.json'
- 'modules.order'
- 'modules/**'
concurrency: push-design-system
jobs:
push:
runs-on: ubuntu-latest
env:
BETTER_API_KEY: ${{ secrets.BETTER_API_KEY }}
BETTER_BASE_URL: https://app.better.email
DESIGN_SYSTEM_ID: ${{ vars.BETTER_DESIGN_SYSTEM_ID }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install -g @better-email/cli
- name: Bind the checkout
run: |
better ds pull "$DESIGN_SYSTEM_ID" --dir "$RUNNER_TEMP/binding" --channel latest
mkdir -p .better
cp "$RUNNER_TEMP/binding/.better/config.json" .better/config.json
- name: Push a version
run: better ds push --yes --name "main@${GITHUB_SHA::7}"
A fresh binding is worth the extra call. It carries the design system's current newest version, which push sends as an optimistic-concurrency base: if someone stages or saves a version in the app between the two steps, the push is refused instead of quietly layering the repository on top. It also means an unchanged tree pushes nothing at all — the job logs No changes to push. and creates no version.
The paths filter keeps merges that only touch a README out of the design system's history. concurrency stops two merges from racing each other into a conflict.
The alternative — commit .better/config.json and push with --force — is simpler to read and gives up both of those properties: --force skips the concurrency check, and every run creates a version whether or not anything changed.
What lands on the platform
+ added Testimonial (cmp_testimonial)
~ Module order changed
Summary: 1 added, 0 edited, 0 removed, Module order changed.
Created Version 12: main@a1b2c3d.
Staged as Candidate.
--yes skips the confirmation prompt. A non-interactive runner would skip it anyway, but stating it makes the job independent of how it is invoked.
The version lands in the design system's version timeline like any other, numbered and named. It is attributed to the organization's API actor rather than to a person, which is why the name is worth spending: a commit SHA, a tag, or a release number turns the timeline into something you can trace back to the repository.
What "staged as Candidate" means
If the organization has candidate testing enabled and the design system already has a Live version, the push also promotes the new version into the Candidate slot. Campaigns created on the Candidate channel then render with it, so the merge that landed the change is also the merge that made it testable.
Staging replaces whatever was in the slot; there is no queue. Two merges in a row leave the second one staged.
If it did not stage, the CLI says why:
| Message | Meaning |
|---|---|
Candidate testing is off for this Organization. | The version is in the history, ready for an Admin or Designer to publish from the app. |
The Design System has no Live Revision to test against. | Candidate testing starts after the first publish. Publish this version from the app to establish Live. |
Candidate staging was disabled with --no-stage. | The job passed --no-stage. |
Occasionally the version is created but staging fails afterwards. The CLI warns rather than failing the job, and the next better ds push on an unchanged tree repeats the warning, pointing at the app's version timeline where you can stage it by hand.
No CLI command makes a version Live. The furthest a pipeline goes is the Candidate slot; publishing stays a deliberate action taken by an Admin or Designer in the app, after someone has looked at the result.
Checking for drift
better ds diff exits 1 when the local tree and the platform disagree, which turns it into a scheduled drift check — useful while a team is still moving from in-app editing to the repository:
- name: Fail if the platform has drifted from main
run: better ds diff --against live
It needs the same credentials and binding as the push job. The output names each module that differs and why.