CI/CD with GitHub Actions

You need: a bootstrap token, and a project that deploys.

Every command runs headless

Exit codes is the contract a pipeline gates on, pithy token mints the credential it runs with, and an environment per feature is what a pull request gets.

Full flags, no required prompt, machine-readable output everywhere, and exit codes that mean something. That is a design rule rather than a happy accident, and it is why there is no CI mode to enable.

--json suppresses every prompt, and so does the absence of a terminal. What happens next is the same answer every time: the command refuses and names the flag. It does not pick a default and it does not proceed on a value nobody gave.

CI has no config directory, and that is fine

Credentials arrive as environment variables, overlaying the file per key. There is no file to write and nothing to mount.

- run: pithy migrate --env prod --json
  env:
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Those are the names of secrets your provider holds. No value appears in a workflow file, and no payload from any command can carry one.

Mint a least-privilege credential rather than using the bootstrap token

The bootstrap token works. It is not what CI should run under — it is the token every other token is minted from, and it holds the union of everything it can delegate.

pithy token mint ci-system --env prod

Its permissions are the base — deploy Workers, migrate remote D1, read and write the secret store — plus whatever your composed capabilities need CI to do. You never hand-list them, and composing a capability that contributes one takes effect on the next mint.

Getting the value across the gap

CI cannot read either secret store, so the credential has to cross by hand exactly once.

The mint writes the value to a file in your config directory, keyed by environment. Nothing in the CLI ever reads that file — you open it, copy the value, and paste it into your provider’s secrets.

It is a handoff to a person, deliberately.

The pipeline

- run: bun install
- run: bun run lint
- run: bun run typecheck
- run: bun run test
- run: pithy doctor --json
- run: pithy ui sync --check --worker api
- run: pithy migrate --env prod --json
  env: { CLOUDFLARE_ACCOUNT_ID: …, CLOUDFLARE_API_TOKEN: … }
- run: pithy deploy --env prod --json
  env: { CLOUDFLARE_ACCOUNT_ID: …, CLOUDFLARE_API_TOKEN: … }

Two of those steps are the ones people leave out, and both catch failures a test suite structurally cannot.

flowchart LR
    I["install"] --> L["lint"] --> T["typecheck"] --> S["test"]
    S --> D["pithy doctor"]
    D --> U["pithy ui sync --check"]
    U --> M["pithy migrate"]
    M --> P["pithy deploy"]

    D -.-> DN["A binding declared and not bound.<br/>A Workflow declared and never synced.<br/>A domain nothing routes."]
    U -.-> UN["A route the SPA shell is<br/>answering instead of your Worker."]
    S -.-> SN["Tests call handlers directly.<br/>The asset router is never in the picture."]

pithy doctor exits non-zero on a contradiction between your config and your wiring — a binding declared and not bound, a Workflow declared and never synced, a domain nothing routes, a capability composed without its prerequisites, a locale with gaps.

The unsynced Workflow is the one worth having: its entire symptom is that nothing happens.

pithy ui sync --check catches a route the SPA shell is answering instead of your Worker. No test suite of yours sees that, because tests call handlers directly and the asset router is never in the picture.

What doctor fails on, and what it does not

The rule is consistent, and knowing it stops you fighting the gate.

A finding fails. A contradiction between your own config and your own wiring.

A step not yet taken passes. No domain yet, an environment not provisioned, a secret registry never provisioned. Every project is in those states on day one, and turning the report red for everyone on day one teaches people to ignore it.

A check that could not run gates nothing and says so. Offline, no credentials, an account that did not answer: reported as skipped with the reason, never rendered as a pass.

Migrate and deploy are separate steps

Deploy never migrates. It warns when the schema is behind and ships anyway.

Promote first, then ship. Two gated steps, because the failure modes and the remedies differ.

A pipeline never commits back to the repository

That standing rule is what the feature mode is built around: a feature environment’s ids go to a git-ignored file, rebuilt every run, and the provision output says so in as many words.

A human running against a declared environment commits ids in a pull request. A pipeline running against a feature has nothing to commit.

Environment per pull request

- run: pithy provision --feature --json

Named from the branch, created idempotently, and torn down on merge:

- run: pithy feature destroy --json

Missing credentials are a hard failure on teardown rather than a silent skip — skipping would leak every resource while reporting success, and the teardown then deletes the branch the names are derived from, so a later attempt could no longer work out what to delete.

A job whose credentials did not propagate must fail loudly.

Reading a failed run

Every failure is one line on stderr carrying a stable code, the problem, and the action that fixes it.

A run that died partway still writes to stdout what it changed on the way — migrate marks a truncated report and names the database it died on and every one it never opened. That record is worth more on the run that failed than on the one that worked.

Check it worked

  • The whole pipeline runs with no terminal and no prompt
  • Doctor fails the build on a deliberately unsynced Workflow
  • The deploy step reports verified
  • A feature environment is created on a pull request and destroyed on merge, with nothing to commit
ESC