Publish & PR previews from CI

Two GitHub Actions workflows, wired the same way. The first republishes your design system whenever engineers change it, so prototypes never drift from production. The second puts a live visual preview on every pull request. Both need a token and the GitHub App; set those up once and add either workflow, or both.

What you need first

  • A kind: live design system — see Design systems. PR previews render against a published design system; they don't create one.
  • The Statecraft GitHub App installed on the account or org that owns the repo (Account → GitHub access → Install GitHub App), with this repo in the selected list. For org installs an admin may need to approve the request first.
  • A workspace API token. Scopes differ per workflow: bundle:publish for publishing, pr-review:run for PR previews.

Mint a token

From the editor: Workspace settings → API tokens → Create token. Or from the CLI:

$ statecraft tokens create --workspace acme --name ci-production --scope bundle:publish
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
Workspace settings → API tokens. The plaintext is shown once.
Workspace settings → API tokens. The plaintext is shown once.

A token is workspace-scoped and grants only the scopes you check. A bundle:publish token can create and rotate design systems in its workspace and nothing else — it can't read projects, manage members, or reach another workspace. The server stores only sha256(token), so the plaintext is unrecoverable: copy it into your CI secret store now, and if you lose it, revoke and mint another (statecraft tokens list, statecraft tokens revoke).

Add it as a repo secret under Settings → Secrets and variables → Actions. The workflows below expect STATECRAFT_TOKEN and STATECRAFT_PR_REVIEW_TOKEN respectively.

Treat the token like a database password — don't commit it, don't paste it into Slack. If you suspect a leak, revoke and re-mint; there is no rotation ceremony, it takes seconds.

Publishing your design system

Every merge to main that touches your component library rebuilds the bundle against the canonical codebase and uploads it. Every prototype using that design system picks up the new bundle on its next render — no re-import, no version pinning.

This is the authoritative path for a team. The desktop app rebuilds in a couple of seconds and is the better fit for one engineer iterating fast; CI uses your real Node version and lockfile, runs regardless of who's at their laptop, and puts the manifest through PR review like any other config. If both publish to the same design system, last write wins, and the row labels CI rotations as Bundle rotated by CI.

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

Adjust the paths: filter to match where your library lives. Two flags carry weight:

  • --skip-install — the setup-node and install steps already ran, so the bundler reuses node_modules instead of doing a second install pass with possibly the wrong package manager.
  • --strict-scope — if the bundle exposes a named export that isn't in the manifest's scope:, the publish fails with exit code 1 and names the missing exports, so the PR that added a component can't merge without updating the manifest. Drop the flag to warn and publish anyway.

Expect 3–8 minutes end to end on a typical monorepo. The Statecraft work — download the CLI, bundle, upload — is about 30 seconds; your install dominates, so cache it via setup-node's cache: key.

Nothing here is GitHub-specific. The CLI is a single Linux x86_64 binary and the --manifest path is repo-relative, so the same shape transfers to Buildkite, GitLab CI or Jenkins — anywhere with glibc 2.28+ that can curl and execute a binary. arm64 Linux isn't shipped yet; hosted GitHub runners are x86_64, so this rarely bites.

What the publish step returns

One line of JSON on stdout, and the same content written to --status-file so you can upload it as an artifact:

{
  "status": "published",
  "designSystem": "acme-ui",
  "jsHash": "298bf18c…",
  "bytes": { "js": 395549, "css": 352507 },
  "durationMs": 26511,
  "warnings": [],
  "bootstrapped": false
}
statusMeans
publishedBundle bytes differed; uploaded and rotated. The manifest YAML and bundle exports land in the same atomic mutation.
nochangeBundle SHA matched what's already there; no upload. The manifest YAML still goes through, so edits to scope: / components: / name: land even when the bundle is stable.
failedExit code 1, with error.title / error.detail / error.suggestion from the bundler's classifier.

bootstrapped: true means this publish also created the design-system row — no row existed at that slug in the token's workspace. The step prints a matching line to stderr, so a typo in --slug doesn't silently mint an orphan.

Visual previews on every PR

On every pull request, a survey agent reads the diff, works out which user-facing journeys the changed files touch, and posts a sticky comment with one preview link per affected journey:

**Statecraft preview** · 2 journeys affected by this PR:

- [PR #42: Checkout flow](https://statecraftapp.com/w/…/p/…)
- [PR #42: Account settings](https://statecraftapp.com/w/…/p/…)
The sticky comment updates in place as the author pushes more commits.
The sticky comment updates in place as the author pushes more commits.

Follow a link and you land in the editor on a project rendered at the PR's exact head commit, against the design system the PR is updating. Click through it, comment on specific elements, and the design discussion happens in context. Preview URLs stay stable across pushes — safe to bookmark.

A preview rendered at the PR's head commit.
A preview rendered at the PR's head commit.

A PR that touches no user journeys — CI config, docs, an internal refactor — gets a comment saying so, and no previews. Cancelling the workflow run in GitHub propagates back: the worker exits and cascades the cancel to any renders it spawned, so no credits burn past the cancel.

The action reads the design-system slug from a top-level slug: in your statecraft.yaml. Add one if it isn't there, or pass design-system: in the workflow's with: block — the input wins over the YAML. Then place this at .github/workflows/pr-review.yml:

name: Statecraft PR review
on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  review:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      pull-requests: write   # for the sticky comment
      contents: read
    steps:
      - uses: statecraftapp/pr-review-action@v2
        with:
          statecraft-token: ${{ secrets.STATECRAFT_PR_REVIEW_TOKEN }}
          # Optional: narrow what the survey agent considers.
          # scope: "checkout flow only"
          # Optional: override toolchain detection if heuristics misfire.
          # node-version: "20"
          # package-manager: pnpm

The scope: input is free text. On a monorepo where one PR can intersect a dozen journeys, it keeps the agent focused on the one you care about.

Use pull_request, never pull_request_target. The latter exposes secrets to fork PRs while defaulting to a base-branch checkout — a well-known security footgun.

@v2 (the default) installs Node and your package manager on the runner from your repo's own pins — .nvmrc, package.json#packageManager, the lockfile — then builds the PR's design-system bundle there. The toolchain that powers your canonical publish powers the per-PR build, so "works in my CI, breaks in Statecraft's" goes away. PRs that don't touch design-system source skip the build entirely. @v1 keeps the older server-side build and still works, but new setups should use @v2.

Troubleshooting

ErrorFix
Token not recognized, or design system not foundOne message covers both, so a leaked token can't probe for slugs. Check the secret is set on this repo and forwarded via env:; that the token isn't revoked (statecraft tokens list); and that the slug exists in the token's workspace — tokens can't reach across workspaces even when slugs match.
GitHub repository not foundThe GitHub App isn't installed on the repo's account or org, or the install doesn't include this repo. Whoever minted the token installs it from Account → GitHub access.
Design system slug is requiredNo top-level slug: in the manifest and no design-system: input. Add one.
Framework mismatchbuild.framework must match what the design system was registered with. Statecraft won't rotate a React design system with a Vue bundle.
Scope mismatch (--strict-scope)The bundle exposes names not in scope:. The error lists them — add the ones you want (and give them a components: entry so the palette can render them).
Bundle too largeThe cap is 30 MB JS / 16 MB CSS. Mark heavy dependencies as peers so the canvas resolves them at render time. Iframe init scales with bundle size, so externalise anything past a few MB regardless.
npm 11 arborist crash (Link.matches null)npm 11 on Node 22+ crashes on certain dep-tree shapes. Switch the publish step to pnpm or yarn — both resolve the same manifest cleanly, and corepack ships with Node 22+.
Agent credit limit reached (HTTP 402)The run was refused before any agents spawned. Upgrade, or add your own Anthropic API key on the Account page to bypass metering and pay Anthropic directly.