Getting started

Keep components in sync

Once your design system publishes from CI, every change your engineers ship shows up automatically in every Statecraft prototype that uses it. New button variant? Tightened spacing? Renamed prop? Designers see it the moment the PR merges — no re-importing, no editor toil, no prototypes drifting from production.

What happens

A typical day after this is wired up:

  1. An engineer merges a PR to main that touches your component library.
  2. Your CI rebuilds the bundle against the canonical codebase and uploads it to Statecraft.
  3. Every prototype using that design system, in every workspace, picks up the new bundle on its next render. Designers open the canvas and see the updated component immediately.

No re-import, no version pinning, no "is the tray still running?". CI publishing is the authoritative path for teams where the component library is touched by many people on many branches — same rebuild cadence as your Storybook or Chromatic pipeline.

When to use CI vs. the desktop tray

The two routes coexist. The desktop tray is the simplest path for a single engineer iterating fast — saves rebundle in ~2 seconds. For a team, CI is usually the better fit:

  • Every merge to main rebuilds against the canonical codebase, with the same Node version and lockfile your prod build uses.
  • No "is the tray running?" — your build runs in a clean cloud environment, and the rebuilt bundle lands on Statecraft regardless of who's at their laptop.
  • Your CI already does dependency caching and lockfile validation; the Statecraft publish step reuses all of it instead of doing its own install.
  • The design-system manifest lives in the repo and goes through PR review like any other config — adding a component to the catalog or rewriting a palette snippet is a diff your team can comment on, not an editor edit that lands silently.

If a tray and CI both publish for the same design system, last-write-wins; the row's status labels rotations from a CI runner as "Bundle rotated by CI" so engineers know to expect their tray's next save to overwrite it.

First time onboarding a library? Two paths converge here: either run statecraft import (or the desktop tray's Add a design system… wizard) once from your laptop to gather the install config interactively, or author statecraft.yaml by hand and let CI bootstrap the design-system row on its first publish. Either way the manifest in your repo ends up as the source of truth.

1. Author statecraft.yaml in your repo

The design-system manifest lives next to your bundle entry, like package.json. One file covers both sides: the editor-facing YAML body uploaded to Statecraft (name, source, scope, components, fallbackProps, providers, tokens) plus CI-only blocks telling statecraft publish how to compile the bundle — build: (entry / framework / frame — or an inline frameSource wrapper, see below), install: (installCommand / packageManager / skipInstall), tailwind.build: (mode / globalsCss / config / content — sibling of the editor's runtime tailwind.render: block), cssEngine: + optional pandaConfig:, resolveConditions:, aliases:, scssShared:, and defines:. This one file is the single source of truth — the desktop daemon reads it on every rebuild and statecraft publish reads the same file from CI, so there's no second config to keep in sync.

# packages/ui/statecraft.yaml
name: Acme UI
source:
  kind: live

scope:
  - Button
  - Card
  - FormInput

components:
  - label: Button
    tagName: Button
    category: Actions
    snippet: '<Button variant="solid">Click</Button>'

build:
  entry: ./src/index.ts
  framework: react
  # frame: ./src/Frame.tsx   # optional provider wrapper (a file in your repo)
  # --- OR, instead of frame:, inline the wrapper when no such file exists ---
  # frameSource: |
  #   import './src/styles/globals.css';   # paths are relative to the repo root
  #   export default ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
  # frameLanguage: tsx                      # tsx | jsx | vue

Every CI-only block (build, install, cssEngine, pandaConfig, resolveConditions, aliases, scssShared, defines, and the tailwind.build: sub-block) is stripped client-side before the YAML lands on the row — they describe how to compile the bundle, not what the canvas renders. The sibling tailwind.render: block IS row-side runtime config and survives the strip. scope: is hand-authored; Statecraft writes the bundle's named exports to _design-systems/<slug>.exports.txt in the sync folder as a copy-from list, but doesn't auto-populate scope: itself.

Components rendering unstyled? Library components rarely carry their own styling context — it usually lives in your app shell (a global stylesheet the entry never imports, a ThemeProvider, a CSS reset). Reproduce it with a frame: point build.frame at an existing wrapper module, or inline one with build.frameSource when none exists. Statecraft wraps every rendered state in it. (For Tailwind globals, use the tailwind.build block instead.) Write any relative imports in frameSource relative to the repo root.

First publish into a fresh workspace? No setup step needed — statecraft publish creates the design-system row on its first run (framework + name come from the manifest; you can rename in the editor later). Subsequent publishes rotate the bundle on the same row.

2. Create an API token

The CI workflow authenticates with a workspace-scoped bearer token. Mint one from Workspace settings → API tokens (for CI) in the editor, or from the CLI:

$ statecraft tokens create --workspace acme --name ci-production
API token created for workspace 'Acme' (acme).
Save this token NOW — it can't be shown again:

  sck_E-6V7s5dUFScXENOLyYKE5g-Ok2usszfFxOP94mKBx0

Token id: mn74d7hr11xmdsrfn1m9e2gkx186gcgn
Suggested env var: STATECRAFT_TOKEN

Tokens grant bundle:publish on the workspace — nothing more. They can publish kind: live design systems in that workspace (creating the row on first publish, rotating the bundle on subsequent ones), but can't read projects, manage members, or touch unrelated workspace data. The server stores only sha256(token), so even an attacker with read access to the database can't replay the plaintext.

Copy the sck_… string immediately and paste it into your CI's secret store. The plaintext isn't retrievable later — if you lose it, revoke and mint a new one (statecraft tokens list shows active tokens, statecraft tokens revoke takes either the token name or its id).

Treat the token like a database password. Don't commit it. Don't paste it into Slack. Use secrets.STATECRAFT_TOKEN (GitHub Actions), BUILDKITE_AGENT_ENV, or whatever your CI's secret-management equivalent is. If you suspect a leak, revoke immediately and re-mint — there's no token-rotation ceremony, revoking one and creating another takes seconds.

3. Add the workflow file

For GitHub Actions, drop this into .github/workflows/statecraft-publish.yml:

name: Statecraft — publish DS bundle

on:
  push:
    branches: [main]
    paths:
      - 'packages/ui/**'
      - '.github/workflows/statecraft-publish.yml'
  workflow_dispatch:

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: yarn

      - name: Install dependencies
        run: yarn install --immutable

      - name: Download Statecraft CLI
        run: |
          curl -fSL \
            "https://github.com/statecraftapp/statecraft-cli/releases/latest/download/statecraft-linux-x86_64.tar.gz" \
            -o /tmp/statecraft.tar.gz
          tar -xzf /tmp/statecraft.tar.gz -C /tmp
          chmod +x /tmp/statecraft

      - name: Publish design system bundle
        env:
          STATECRAFT_TOKEN: ${{ secrets.STATECRAFT_TOKEN }}
        run: |
          /tmp/statecraft publish \
            --slug acme-ui \
            --manifest packages/ui/statecraft.yaml \
            --skip-install \
            --strict-scope \
            --status-file "$RUNNER_TEMP/statecraft-status.json"

      - name: Upload publish status
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: statecraft-publish-status
          path: ${{ runner.temp }}/statecraft-status.json
          if-no-files-found: ignore

The paths: filter is the same pattern Storybook users already follow — only run the workflow on changes that affect the bundled components or the manifest. Adjust to match where your library lives.

--skip-install is the key flag for CI use. The setup-node + yarn install step already ran, so Statecraft's bundler can skip its own install pass and reuse node_modules from the previous step. Without this, the bundler would try to run a second install which is slow and may pick a different package manager than your repo's lockfile.

--strict-scope is the safety net: if the bundle exposes a new named export (say a teammate added Spinner to ./src/index.ts) that isn't in the manifest's scope:, the publish fails with exit code 1 and a clear error pointing at the missing names. The PR that added the component fails CI and needs a manifest update before it merges. Drop the flag to warn-and-publish if you'd rather catch mismatches after the fact.

CSS engines. If your library uses a CSS-in-JS engine, declare it in the manifest YAML as cssEngine: vanilla-extract (or whichever engine you use). The CI publisher picks it up and installs the matching Vite / Babel / PostCSS plugin into the scratch toolchain. Three families:

  • Compile-time / plugin-wired: vanilla-extract, Linaria, Pigment CSS, StyleX, Compiled, Macaron, Windi CSS, Panda.
  • Runtime CSS-in-JS: Emotion, styled-components, Stitches, Goober, JSS, Theme UI, Fela. These ship as regular npm libraries — declaring the engine name tells Statecraft the published bundle has zero CSS by design (so the dashboard reports it as expected, not a warning).
  • UnoCSS: uses its own dedicated unocss: build: config: uno.config.ts presets: - uno block in the manifest, parallel to Tailwind's tailwind.build: block.

Panda additionally needs a top-level pandaConfig: ./panda.config.ts field for its pre-build codegen step, plus a cssEngineImports: [./src/styles/index.css] entry pointing at your @layer-directive CSS file (Panda's layer CSS lives at a user-defined path; without an import the bundle ships 0 KB CSS). For StyleX and Windi the publisher auto-imports the engine's virtual CSS module — no extra manifest fields needed. You can override any of these from CI flags (--css-engine / --panda-config) when you don't want to edit the manifest yet.

4. Add the token as a repo secret

In Settings → Secrets and variables → Actions → New repository secret, name it STATECRAFT_TOKEN and paste the sck_… value. The workflow above reads it as secrets.STATECRAFT_TOKEN and forwards it through env: to the publish step — the CLI picks it up automatically.

Push a commit that touches packages/ui/** and the workflow runs. Expect ~3–8 minutes end-to-end for a typical monorepo — the actual Statecraft work (download CLI + bundle + upload) is ~30 seconds; yarn install dominates.

What the publish step returns

On success, the publish step prints a single line of JSON to stdout describing the outcome — useful when you want CI summaries, Slack notifications, or downstream steps to react to the result:

{
  "status": "published",
  "designSystem": "acme-ui",
  "designSystemId": "jx749h…",
  "jsHash": "298bf18c…",
  "cssHash": "c3d2c226…",
  "bytes": { "js": 395549, "css": 352507 },
  "durationMs": 26511,
  "startedAtMs": 1778511801845,
  "warnings": [],
  "bootstrapped": false
}

The three possible status values:

  • published — bundle bytes differed from the previously-published version, uploaded and rotated the bundle pointer. The manifest YAML and bundle exports are written in the same atomic mutation.
  • nochange — the bundle SHA matched the row's existing jsHash; no upload happened. Common on re-running CI for a commit that didn't touch component source. The manifest YAML still goes through, so edits to scope: / components: / name: land even when the bundle bytes are stable.
  • failed — surfaced with error.title, error.detail, and (where applicable) error.suggestion populated from the bundler's classifier. Exit code 1.

The warnings array carries non-fatal issues that didn't block the publish but are worth knowing about. The most common one is scope_export_mismatch — the bundle exposes a named export that isn't declared in the manifest's scope:. Default behaviour warns and publishes anyway; --strict-scope upgrades the warning to a failure. Empty array on the happy path.

bootstrapped is true when this publish also created the design-system row (no row existed at this slug in the token's workspace before the run). Whenever it's true, the publish step also prints a line like First publish — bootstrapped design system 'acme-ui' in token's workspace. to stderr — a visible signal for CI log reviewers, so a typo in --slug doesn't silently mint an orphan row. After the first publish succeeds, every subsequent run returns "bootstrapped": false.

The --status-file flag (used in the example workflow above) writes the same JSON to a file, which the actions/upload-artifact step then attaches to the run for later inspection. To surface warnings as a yellow banner on the GitHub Actions run page (instead of having to download the artifact to see them), add this step right after your publish step:

      - name: Surface Statecraft warnings
        if: success()
        run: |
          STATUS="${{ runner.temp }}/statecraft-status.json"
          if jq -e '.warnings | length > 0' "$STATUS" > /dev/null 2>&1; then
            {
              echo "## ⚠️ Statecraft publish warnings"
              jq -r '.warnings[] | "- **\(.code)**: \(.message)"' "$STATUS"
            } >> "$GITHUB_STEP_SUMMARY"
          fi

How the editor sees a CI-managed design system

Once you've published with --manifest, the row flips to Managed by CI in the editor's Design systems page. The YAML view is read-only and shows the manifest body verbatim — engineers edit packages/ui/statecraft.yaml in the repo and let CI rotate the row, instead of typing into the editor and having a CI publish overwrite their work.

If a designer needs to fork a CI-managed design system into an editable copy (to experiment without disturbing the canonical library), the read-only banner carries a Duplicate this design system button. The fork lands as a fresh row; the original stays CI-managed.

Other CI providers

Nothing is GitHub-specific. The CLI is a single Linux x86_64 binary; any CI that can curl + execute a binary works. The --manifest path is repo-relative, so the same shape transfers across providers:

  • Buildkite — same curl … | tar -xz + invocation in a plugin step. Run from the checkout directory so the manifest path resolves.
  • GitLab CI — likewise. $CI_PROJECT_DIR is the checkout root if you need an absolute path; $STATECRAFT_TOKEN from a masked variable.
  • Self-hosted Jenkins / Drone / etc. — anywhere with glibc 2.28+ on x86_64 Linux.

Linux x86_64 only today. The cross-compiled binary is built for x86_64-unknown-linux-gnu. arm64 Linux (Apple Silicon runners, AWS Graviton, …) isn't shipped yet. GitHub Actions hosted runners default to x86_64, so this is a non-issue for the common case. If you need arm64, file an issue and we'll cut a multi-arch release.

Troubleshooting

"Token not recognized, or design system not found"

Statecraft returns the same generic error for both failure modes so a leaked token can't probe for slug existence by guessing. Check, in order:

  • The STATECRAFT_TOKEN secret is set on the right repo and the workflow's env: forwards it. Many CI providers won't expose secrets to PRs from forks — verify on a push from your own branch first.
  • The token hasn't been revoked. statecraft tokens list --workspace <ws> shows revocation status.
  • The slug exists in the token's workspace. The token is workspace-scoped — it can't rotate a slug in a different workspace, even if the slug name matches. Verify by looking for the design system in the editor's Design Systems page.

"Framework mismatch"

The build.framework field in your manifest must match whatever the design system was registered with via statecraft import. Statecraft refuses to rotate a React DS with a Vue bundle and vice versa — bundling under the wrong framework would produce a broken bundle that fails to render. Either fix the manifest's framework: or re-register the DS with the right framework.

"Bundle too large"

The per-bundle cap is 30 MB for JS, 16 MB for CSS. If your build crosses it, mark heavy dependencies as externals in the design system YAML so the canvas resolver fetches them at render time instead. The error message names the actual size and suggests common culprits (charting libs, icon kits as barrels, rich-text editors). 30 MB is the upper bound, not the new normal — iframe init time scales with bundle size, so externalising heavy deps is still the recommended path for anything beyond a few MB.

"Scope mismatch (--strict-scope)"

The bundle exposes names that aren't in the manifest's scope: block. The error message lists the missing names. Add the ones you want exposed to state JSX to scope: (and ideally to components: too, with a snippet so the palette can render them); drop the flag if you'd rather let exports drift and clean them up later.

"yarn install" takes forever on every push

Enable Yarn / npm / pnpm cache in setup-node:

- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: yarn          # or 'npm' / 'pnpm'

First run is still slow; subsequent runs reuse the cached deps directory keyed off the lockfile. For very large monorepos, also consider actions/cache for the full node_modules tree — Statecraft only needs the entry file's transitive deps to be resolvable.

"npm 11 arborist bug (Link.matches null)"

npm 11 on Node 22+ crashes during dep tree dedup on certain shapes — most often when rollup's platform-optional native binaries land alongside their no-op stubs. Statecraft recognises the stack signature and points at the standard workaround: switch the publish step to pnpm or yarn. pnpm and yarn don't share npm's arborist and resolve the same manifest cleanly; corepack ships with Node 22+ so neither needs a separate install. Update your workflow to pick the package manager explicitly:

- name: Publish design system
  run: pnpm dlx statecraft publish ./statecraft.yaml --strict-scope
  env:
    STATECRAFT_TOKEN: ${{ secrets.STATECRAFT_TOKEN }}

The flip is local to the CI workflow — your repo's own package.json and lockfile stay untouched. Generic npm install failures that don't match a specific Statecraft classifier also nudge toward the same pnpm remediation from the fallback hint, on the theory that if your manifest installs standalone with pnpm/yarn it'll install in CI with pnpm/yarn too.