> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useshipd.com/llms.txt
> Use this file to discover all available pages before exploring further.

# GitHub Actions example

> Two complete workflows: preview flag changes on pull requests and push shipd.config.ts on merge to main.

## Overview

Two complete GitHub Actions workflows for a repo that owns `shipd.config.ts`. `flags-diff` previews flag changes on every pull request that touches the file. `flags-push` applies them when the change merges to `main`. Each authenticates with its own service account token (`shipd_sa_…`), so pull request jobs can read flags but never change them, and both handle the CLI's exit codes explicitly.

## What's in it

* `@shipdit/cli` is a dev dependency, so CI runs the same CLI version as your machine.
* `flags-diff.yml` runs `shipd diff --exit-code`, writes the plan to the job summary, and checks that `src/generated/flags.ts` is committed.
* `flags-push.yml` runs `shipd push --ci` and turns exit codes into errors or warnings.
* Both jobs target project `web-app`, environment `production`.

```text theme={null}
web-app/
├── .github/
│   └── workflows/
│       ├── flags-diff.yml
│       └── flags-push.yml
├── package.json
├── pnpm-lock.yaml
├── shipd.config.ts
└── src/
    └── generated/
        └── flags.ts
```

## Set up the tokens

1. In the dashboard, go to **Settings → Service accounts** and click **Create service account**. Name it `github-actions`.
2. Open the account and click **Create token** twice, binding both to project `web-app` (not to an environment). Scopes match an exact action, so `flag:update` does not grant `flag:read`:

   | Secret | Used by | Scopes |
   | - | - | - |
   | `SHIPD_DIFF_TOKEN` | `flags-diff` (pull requests) | `flag:read`, `environment:read` |
   | `SHIPD_PUSH_TOKEN` | `flags-push` (main) | `flag:update`, `flag:create`, `flag_state:update`, `flag_production:toggle`, `environment:read` |

   Add `flag:archive` to the push token only if you push with `--prune`. See [Sync flags in CI](/config-as-code/ci) for the reasoning.
3. Copy each token. They are shown once.
4. In GitHub, go to **Settings → Secrets and variables → Actions** and add both as repository secrets.

## Files

```json package.json theme={null}
{
  "name": "web-app",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@10.15.0",
  "scripts": {
    "flags:diff": "shipd diff",
    "flags:push": "shipd push"
  },
  "dependencies": {
    "shipdit": "^0.3.0"
  },
  "devDependencies": {
    "@shipdit/cli": "^0.3.2",
    "typescript": "^5.9.0"
  }
}
```

`pnpm/action-setup` reads the pnpm version from `packageManager`.

```ts shipd.config.ts theme={null}
import { defineFlags } from "shipdit/config";

export default defineFlags({
  "new-checkout": {
    type: "boolean",
    default: false,
    description: "Redesigned checkout flow",
    tags: ["checkout"],
  },
  "search-variant": {
    type: "variant",
    variants: ["control", "semantic", "hybrid"] as const,
    default: "control",
    description: "Search ranking experiment",
    tags: ["search", "experiment"],
  },
});
```

````yaml .github/workflows/flags-diff.yml theme={null}
name: flags-diff

on:
  pull_request:
    paths:
      - shipd.config.ts
      - src/generated/flags.ts

permissions:
  contents: read

jobs:
  diff:
    runs-on: ubuntu-latest
    env:
      SHIPD_API_KEY: ${{ secrets.SHIPD_DIFF_TOKEN }}
      SHIPD_API_URL: https://api.useshipd.com
      SHIPD_PROJECT: web-app
      SHIPD_ENV: production
    steps:
      - uses: actions/checkout@v5

      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Check generated types are committed
        run: |
          pnpm exec shipd types gen
          git diff --exit-code -- src/generated/flags.ts \
            || { echo "::error title=shipd::src/generated/flags.ts is stale. Run 'shipd types gen' and commit."; exit 1; }

      - name: Diff against production
        run: |
          set +e
          pnpm exec shipd diff --exit-code > diff.txt 2>&1
          code=$?
          set -e

          cat diff.txt
          {
            echo "### shipd diff (web-app / production)"
            echo '```text'
            cat diff.txt
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

          case "$code" in
            0) echo "No flag changes." ;;
            2) echo "::notice title=shipd::This PR changes flags. They apply to production when it merges to main." ;;
            4) echo "::error title=shipd::Unlinked flags. See the diff output."; exit 4 ;;
            *) echo "::error title=shipd::shipd diff failed (exit $code)."; exit "$code" ;;
          esac
````

````yaml .github/workflows/flags-push.yml theme={null}
name: flags-push

on:
  push:
    branches: [main]
    paths:
      - shipd.config.ts
  workflow_dispatch:

permissions:
  contents: read

# Never run two pushes against the same environment at once.
concurrency:
  group: flags-push-production
  cancel-in-progress: false

jobs:
  push:
    runs-on: ubuntu-latest
    env:
      SHIPD_API_KEY: ${{ secrets.SHIPD_PUSH_TOKEN }}
      SHIPD_API_URL: https://api.useshipd.com
      SHIPD_PROJECT: web-app
      SHIPD_ENV: production
    steps:
      - uses: actions/checkout@v5

      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Push to production
        run: |
          set +e
          pnpm exec shipd push --ci 2>&1 | tee push.txt
          code=${PIPESTATUS[0]}
          set -e

          {
            echo "### shipd push (web-app / production)"
            echo '```text'
            cat push.txt
            echo '```'
          } >> "$GITHUB_STEP_SUMMARY"

          case "$code" in
            0) echo "Flags are in sync." ;;
            3) echo "::error title=shipd::Conflict. Someone changed these flags since the plan. Run 'shipd pull', reconcile, and merge again."; exit 3 ;;
            5) echo "::warning title=shipd::A default change is queued for approval and is not live yet. Approve it in the dashboard." ;;
            *) echo "::error title=shipd::shipd push failed (exit $code)."; exit "$code" ;;
          esac
````

## Exit codes

| Code | Command | Meaning | Workflow result |
| - | - | - | - |
| `0` | both | No changes (`diff`) or applied (`push`) | Pass |
| `2` | `diff` | Local definitions differ from the environment. On a PR that edits flags, this is expected | Pass with a notice |
| `3` | `push` | Conflict: a flag changed after the plan (stale version), or an approval request is already pending | Fail |
| `4` | `diff` | Unlinked flags | Fail |
| `5` | `push` | A protected default change is queued for approval. It is not live | Pass with a warning |
| `1` | both | Any other error (auth, network, invalid config) | Fail |

The full list lives in the [CLI reference](/reference/cli#exit-codes).

## Run it

```bash theme={null}
pnpm add -D @shipdit/cli
pnpm add shipdit
pnpm exec shipd types gen   # writes src/generated/flags.ts
git add .github package.json pnpm-lock.yaml shipd.config.ts src/generated/flags.ts
git commit -m "ci: sync shipd flags"
```

Open a pull request that edits `shipd.config.ts`. The `flags-diff` job summary shows the plan. Merge it and `flags-push` applies the plan to production.

## Adapt it

* To block pull requests on any drift, including changes someone made in the dashboard, make exit code `2` fail: replace its `case` line with `exit 2`.
* To fail the push job while an approval is pending, make exit code `5` fail the same way.
* For more environments, copy the push job and change `SHIPD_ENV`. `default` values apply per environment. Definition fields apply to the whole project.
* `SHIPD_API_URL` is required in CI. Only `shipd login` falls back to `https://api.useshipd.com`. You can also set `apiUrl` in `.shipd/config.json`.
* Secrets are not passed to workflows triggered from forks, so `flags-diff` fails there. Skip the job for forks with `if: github.event.pull_request.head.repo.full_name == github.repository`.
* Use `--prune` only after you confirm that no one manages flags in this project outside `shipd.config.ts`. It archives remote-only flags.

## Next

* [Sync flags in CI](/config-as-code/ci)
* [CLI reference](/reference/cli)
* [Generated types](/config-as-code/types)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.