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 or merge request. Both need a token and a connected git host; set those up once and add either workflow, or both.
What you need first
- A
kind: livedesign system — see Design systems. PR previews render against a published design system; they don't create one. - A connected git host. On GitHub that's the Statecraft GitHub App installed on the account or org that owns the repo (Account → Integrations → Install GitHub App), with this repo in the selected list — for org installs an admin may need to approve the request first. On GitLab, connect your account on the same page.
- A workspace API token. Scopes differ per workflow:
bundle:publishfor publishing,pr-review:runfor 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

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: ignoreAdjust the paths: filter to match where your library lives. Two flags carry weight:
--skip-install— thesetup-nodeand install steps already ran, so the bundler reusesnode_modulesinstead 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'sscope:, 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.
A GitHub Action, if you'd rather not hand-roll it
The workflow above downloads the CLI and calls it directly, which is what you want if you're on Buildkite or GitLab, or if you like seeing every step. On GitHub, this is the whole job:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: statecraftapp/publish-action@v1
with:
statecraft-token: ${{ secrets.STATECRAFT_TOKEN }}
manifest: packages/ui/statecraft.yamlIt checks out your repo, sets up the Node and package manager your repo already declares (.nvmrc, packageManager, lockfile), installs the CLI and publishes. Dependencies are installed by the CLI from statecraft.yaml's own install: config, so CI and your desktop app run the identical build rather than two that merely resemble each other.
If the job needs to own its setup — a specific ref, a sparse checkout, a build step first — pass checkout: false and skip-install: true and do those yourself. Though check the manifest first: installCommand takes a verbatim shell-out and workspaceBuildCommand runs between install and build, for monorepos whose siblings need building. Those belong in the manifest, because a step you add to the workflow instead is a step your desktop app never runs.
Other inputs: slug (only if your manifest doesn't declare one, or you're publishing to a differently-named row), dry-run (validate the manifest and stop — no install, no build, no upload; a manifest typo fails on the pull request instead of on the next push to main), node-version and package-manager if detection misfires, statecraft-cli-version to pin the binary, and clear-pending-edits. It sets a status output of published, nochange or dryrun_ok.
Resolving pending component edits
If your team edits design-system components from the canvas, each one shows as a pending edit on the design system until it reaches the repo. Publish is the moment your manifest and bundle become true, so it's the natural moment a landed edit stops being pending — that's what --clear-pending-edits (or the Action input) does.
It's off by default, and it should stay off unless you mean it. It clears all of that design system's pending edits, not only the ones this publish contained — edits are workspace-wide and nothing ties one to a particular commit — and clearing an edit deletes its component project, including its frames, version history and comments. Use it only in a job that runs after the component changes have actually merged, never on pull requests.
If you'd rather resolve edits deliberately, leave it off and use statecraft component revert <ws>/<ds> <Component> per component, or the Discard control in the workspace.
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
}status | Means |
|---|---|
published | Bundle bytes differed; uploaded and rotated. The manifest YAML and bundle exports land in the same atomic mutation. |
nochange | Bundle 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. |
failed | Exit 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/…)

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 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: pnpmThe 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.
On GitLab
Merge-request previews work the same way. Connect GitLab under Account → Integrations as the same user who minted the token — a fine-grained token needs Code: Download and Merge Request: Read for this — then add this to your .gitlab-ci.yml:
include: - remote: 'https://raw.githubusercontent.com/statecraftapp/pr-review-action/v2/ci-templates/gitlab/statecraft-pr-review.yml' # The template defines a `statecraft-pr-review` job that runs only on # merge-request pipelines. Override anything it sets by redeclaring the job: # # statecraft-pr-review: # variables: # STATECRAFT_PKG_MANAGER: npm # STATECRAFT_SCOPE: "checkout flow only"
The template runs only on merge-request pipelines and fetches its scripts from the same place the GitHub Action does, so both hosts run identical logic — only the bit that reads the trigger and posts the sticky comment differs.
GitLab needs one thing GitHub doesn't: a real token. Its built-in CI_JOB_TOKEN can clone your project but cannot post merge-request notes, so the sticky comment has nowhere to go without one. On GitLab 19.2 and later, generate a fine-grained personal access token limited to this project with Work Item: Read, Create, Update — that's every call the job makes, and it needs no repository access at all. On older GitLab, use a token with the api scope. Either way, add it as a masked CI/CD variable named STATECRAFT_GITLAB_TOKEN. The job fails at startup and says so if it's missing, rather than running and quietly skipping the comment.
The template sets GIT_DEPTH: 0 for you, and you need it: with GitLab's default shallow clone the job can't see the diff between your target and source branches, so every merge request looks like it changed nothing.
Self-managed GitLab works too — the host is read from CI_API_V4_URL, so there's no second setting to keep in sync. It does need to be reachable over HTTPS from Statecraft's servers.
Troubleshooting
| Error | Fix |
|---|---|
| Token not recognized, or design system not found | One 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 found | The 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 → Integrations. |
| Design system slug is required | No top-level slug: in the manifest and no design-system: input. Add one. |
| Framework mismatch | build.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 large | The 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. |