Skip to main content

CI recipes

Two workflows cover most repositories: review every pull request, and push a version when a change lands on main. betteremail/design-system-action does both, one mode each.

Review pull requests

betteremail/design-system-action validates the design system and posts what changed as a comment on the pull request, so a reviewer reads the effect rather than only the Liquid:

name: Design System

on:
pull_request:

concurrency:
group: better-email-diff-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: write

jobs:
design-system:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: betteremail/design-system-action@v1
with:
api-key: ${{ secrets.BETTER_API_KEY }}

That is the whole job. The action installs the CLI, runs better check, and compares the checkout with the Live channel. pull-requests: write is what lets it comment; the concurrency group stops two runs on the same pull request racing each other while writing it.

The API key is an organization key from Settings → API Keys, stored as a repository secret. The checkout needs .better/config.json so the action knows which design system to compare against — the binding section below covers repositories that do not commit it.

What the comment says

One sticky comment per design system and channel, rewritten in place on every commit rather than piling up:

### Design System diff — Acme Newsletter vs live (Version 11)

**1 added · 1 edited · 1 removed · Template Base changed**

| Module | Change | Details |
| --- | --- | --- |
| Hero | Edited | content, settings |
| Testimonial | Added | Added module |
| Legacy banner | Removed | Removed module |
| Template Base | Edited | Base HTML |

_Generated by better ds diff_

The details column separates the three kinds of module change that matter to a reviewer: content is the Liquid, settings is the marketer-facing inputs, and behavior is metadata such as hidden or the content zones a module is allowed in. The template base gets its own row naming the parts that differ, and a reordered picker gets one too.

Inputs

InputDefaultWhat it does
api-keyrequiredOrganization API key.
base-urlhttps://app.better.emailBetter Email application URL.
working-directory.The checkout holding .better/config.json.
channelliveChannel to compare against.
checktrueRun better check. A failure still fails the job, but only after the diff has been posted.
commenttrueWrite the sticky comment.
cli-version^0.5.0Version of @better-email/cli to install. Set ^0.6.0 for the current release.

It also sets two outputs for later steps: has-changes, true when the checkout differs from the channel, and check-passed.

Fork pull requests get a read-only token from GitHub, so the action logs a notice and skips the comment rather than failing. GitHub also withholds repository secrets from untrusted forks, so decide deliberately whether authenticated validation should run on them at all.

Without the action

If you are not on GitHub Actions, the same two commands are the whole recipe. better check needs no credentials, no binding, and no network:

better check # validate the tree in place
better ds diff --against live --format markdown

better ds diff also takes --format json (or --json) when you want to route the result somewhere other than a comment. Every format exits 1 when there are changes and 0 when there are none, so a bare better check job stays a one-liner: that is what npm create better-email writes into new repositories as .github/workflows/better-check.yml, and the action above supersedes it.

Both formats arrived in @better-email/cli 0.5.0. The current release is 0.6.0, and the action still defaults to ^0.5.0, so pass cli-version: ^0.6.0 to pick it up.

better check also emits warnings — a colour input nobody can edit is the common one — and those never fail the job. Only errors set the exit code, so a warning shows up in the log without turning the build red.

What better check 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

The same action in push mode turns a merge into a version. It runs better check first and pushes only if that passes:

name: Push Design System

on:
push:
branches: [main]

permissions:
contents: read

jobs:
design-system:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: betteremail/design-system-action@v1
id: design-system
with:
mode: push
api-key: ${{ secrets.BETTER_API_KEY }}
- name: Report a Version awaiting publish
if: ${{ steps.design-system.outputs.version != '' && steps.design-system.outputs.staged == 'false' }}
run: echo "Version ${{ steps.design-system.outputs.version }} awaiting publish"

Keep it in its own workflow file rather than adding a job to the review one: the two run on different events and need different permissions, and push mode needs no pull-requests: write.

Push-mode inputs

InputDefaultWhat it does
push-nameCI push {sha}Names the version. {sha} becomes the short commit SHA and {ref} the git ref.
stagetrueStage the version as Candidate when that is possible. false passes --no-stage.
forcefalsePush over a design system that has moved since the checkout was bound.

api-key, base-url, working-directory, check, and cli-version mean the same as in review mode; channel and comment are ignored here.

Leaving force at false is the point. If someone saved or staged a version in the app after this checkout was bound, the push fails and the job goes red rather than laying the repository's tree on top of work nobody has looked at. Pull the newer version, reconcile, and merge again. The action never quietly retries with --force.

Push-mode outputs

version is the number of the version that was created, and staged is whether it went into the Candidate slot. version is empty when nothing was pushed, which is what an unchanged tree produces — the merge touched something the design system does not care about.

The step above is the useful pattern: when a version exists but was not staged, it is sitting in the timeline waiting for a person, and the workflow can say so in whatever channel your team reads.

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 action takes it as api-key and sets up the environment itself.

Driving the CLI directly instead, it reads two environment variables:

VariableValue
BETTER_API_KEYThe organization API key.
BETTER_BASE_URLhttps://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

Both modes read .better/config.json to know which design system they are working with, 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.

Repositories that gitignore it need a bind step before the action runs. For the review job, better ds bind is the whole step — it writes .better/config.json and nothing else:

- run: npm install -g @better-email/cli@^0.6.0
- name: Bind the checkout
env:
BETTER_API_KEY: ${{ secrets.BETTER_API_KEY }}
BETTER_BASE_URL: https://app.better.email
DESIGN_SYSTEM_ID: ${{ vars.BETTER_DESIGN_SYSTEM_ID }}
run: better ds bind "$DESIGN_SYSTEM_ID" --channel live

The push job wants more than a binding. bind records no baseline version, so the concurrency guard has nothing to compare against and the first push refuses unless you pass --force — which is the protection you were trying to keep. Pull 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.

- run: npm install -g @better-email/cli@^0.6.0
- name: Bind the checkout
env:
BETTER_API_KEY: ${{ secrets.BETTER_API_KEY }}
BETTER_BASE_URL: https://app.better.email
DESIGN_SYSTEM_ID: ${{ vars.BETTER_DESIGN_SYSTEM_ID }}
run: |
better ds pull "$DESIGN_SYSTEM_ID" --dir "$RUNNER_TEMP/binding" --channel latest --yes
mkdir -p .better
cp "$RUNNER_TEMP/binding/.better/config.json" .better/config.json

A binding made in the job is a fresh one, carrying the design system's newest version as the concurrency base, so force: false protects a window of seconds rather than however long ago someone last committed the file. --yes earns its place once the target directory already holds a design system: pull asks before replacing managed files that differ, and a job has nobody to answer, so it cancels and exits 1 instead.

An adapter project is the other case worth knowing, and the one where that matters: point working-directory at the out directory, and let the job pull into it and build before the action runs.

What the version looks like afterwards

The version lands in the design system's version timeline like any hand-saved one, numbered and named. It is attributed to the organization's API actor rather than to a person, which is why push-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

A push creates a version — the same numbered, named entry the editor's Save a version dialog makes. If the organization uses candidate testing and the design system already has a Live version, that version is also staged into the Candidate slot, and campaigns on the Candidate channel render with it — so the merge that landed the change is also the merge that made it testable.

Pushing never publishes Live. Someone opens the design system in the app and publishes the version from its timeline. When staged comes back false — because the organization does not use candidate testing, or because there is no Live version to test against yet — the version is simply sitting in the timeline waiting for that person, which is what the "Version N awaiting publish" step above is for.

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:

MessageMeaning
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 version 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 set stage: false.

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.

CI never publishes to Live

No CLI command and no action mode 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.