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

# CI Publish Lane

> Push project files from GitHub Actions and understand the current publishing limitation

The CI lane lets a GitHub Actions workflow push project files without storing a long-lived Metabind credential in your repository. GitHub proves which repository a workflow run belongs to using its own OIDC token, and Metabind exchanges that for a project-scoped access token, good for 15 minutes.

<Warning>
  With CLI 0.10.5 and the current server, `metabind publish` fails with HTTP `403` when using a CI token. The command lists components before publishing, but the token lacks the required `read:components` scope. The token includes publishing permissions, but those do not authorize this prerequisite read. The workflow below pushes draft changes only; publish separately from Studio or a signed-in local CLI session until the server grants the missing scope.
</Warning>

Start with [Sync Your First Project](/cli/flat-file-tutorial) to see how pushing and publishing work before wiring the push step into CI.

<Note>
  The CLI ships as a macOS binary today. The job needs a macOS runner (`runs-on: macos-latest`) — an `ubuntu-latest` or `windows-latest` runner cannot install it.
</Note>

## Why This Instead of an API Key

A long-lived API key sitting in a repository secret is the credential that actually leaks in practice: it outlives whoever created it, gets copied into forks, and nothing expires it. The CI lane replaces that with a token that:

* is minted fresh for every workflow run and never stored anywhere,
* expires in 15 minutes,
* can edit existing components, tools, and content through push, but can't add new ones with CLI 0.10.5, manage users, mint API keys, or change project settings — including the binding that grants this capability.

## Bind a Repository to a Project

Tell Metabind which repository is allowed to obtain a CI token for a project. There's no dedicated CLI verb for this yet, so set it through a project update:

```bash theme={null}
metabind project update <projectId> \
  --data '{"settings":{"ci":{"repository":"my-org/my-app","ref":"refs/heads/main"}}}'
```

| Field | Required | Meaning |
| - | - | - |
| `repository` | Yes | `owner/name` of the repository allowed to obtain a CI token. |
| `ref` | No | Restrict further to one branch, for example `refs/heads/main`. |

Without a `ref`, any branch pushed to the bound repository can obtain a CI token — a pull request from a fork does not receive a token, but any branch pushed within the repository does. Pin `ref` to your release branch if you don't want that.

<Warning>
  Include `repository` every time you update `ci`: an update that sends only `{"settings":{"ci":{"ref":"..."}}}` is rejected. Fields you omit keep their stored value, so leaving out `ref` doesn't remove an existing branch pin. Send `"ref": null` to remove the pin, or `"ci": null` to remove the binding.
</Warning>

## Example Workflow

```yaml theme={null}
name: Push drafts to Metabind

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write   # required — core.getIDToken() fails without it

jobs:
  push-drafts:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Exchange the OIDC token for a Metabind token
        id: auth
        uses: actions/github-script@v7
        with:
          script: |
            const idToken = await core.getIDToken('metabind');
            const res = await fetch(
              `${process.env.MB_API}/v1/auth/ci/exchange`,
              {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                  token: idToken,
                  organizationId: process.env.MB_ORG,
                  projectId: process.env.MB_PROJECT,
                }),
              },
            );
            if (!res.ok) {
              core.setFailed(`CI token exchange denied (${res.status})`);
              return;
            }
            const { data } = await res.json();
            core.setSecret(data.accessToken);
            core.exportVariable('MB_TOKEN', data.accessToken);
        env:
          MB_API: https://api.metabind.ai
          MB_ORG: ${{ vars.METABIND_ORG_ID }}
          MB_PROJECT: ${{ vars.METABIND_PROJECT_ID }}

      - name: Install the Metabind CLI
        run: brew install metabindai/tap/metabind

      - name: Push draft changes
        run: metabind push --org "$MB_ORG" --project "$MB_PROJECT"
        env:
          MB_ORG: ${{ vars.METABIND_ORG_ID }}
          MB_PROJECT: ${{ vars.METABIND_PROJECT_ID }}
```

A few things this depends on:

* `permissions: id-token: write` at the job or workflow level. Without it, `core.getIDToken()` fails, and the failure reads as a missing function rather than a missing permission.
* The exchange response is wrapped in `data` — read `data.accessToken`, not a top-level `accessToken`.
* `--org` and `--project` on each CLI command. A runner has no saved context, so without them `push` stops before sending a request.
* `core.setSecret()` masks the token in the job log. It expires in 15 minutes regardless, but there's no reason to leave it unmasked.
* Only edits to entities that already exist. A push that includes a file with no recorded id in `.metabind/state.json` lists components first, and that read fails with `403` because the token lacks `read:components`. Add new files from a signed-in session.
* A current `.metabind/state.json` in the repository. `push` doesn't update it and the CI token can't run `pull`, so after the workflow pushes, run `metabind pull` in a signed-in session and commit the refreshed state. Otherwise the next change to an entity the workflow already pushed is refused as a conflict.
* No project settings changes. The token can't write `metabind.jsonc`, `mcp-instructions.md`, or `agent/`, so a push that includes one fails with `403` after writing the other changes. Push settings changes from a signed-in session.

After reviewing the pushed drafts, publish from Studio or run the following locally in a session authenticated with `metabind auth login`, with `MB_TOKEN` unset so the CLI uses your login:

```bash theme={null}
metabind publish --org <organizationId> --project <projectId>
```

## Diagnosing a Rejected Exchange

Every rejected exchange returns the same generic denial, on purpose — a more specific error would let a caller enumerate which repositories are bound to which projects. When an exchange is denied, check in order:

| Check | Common mistake |
| - | - |
| `settings.ci.repository` is set on the project | Never configured |
| It matches `owner/name` exactly | An organization or repository rename |
| `settings.ci.ref` matches the workflow's branch | Pinned to `main` while running from a feature branch |
| The workflow requested the `metabind` audience | `core.getIDToken()` called with no argument, or a different string |
| `permissions: id-token: write` is present | Omitted, or narrowed at the job level |
| The organization and project IDs are correct | Copied from a different environment |
| The job runs on a macOS runner | `ubuntu-latest` or `windows-latest` — there's no CLI build for either |

## What the Token Can Do

| Capability | Allowed |
| - | - |
| Edit an existing component, tool, or content item through push | Yes |
| Add a new component, tool, or content item through push | No — with CLI 0.10.5, a push that includes a file with no recorded id lists components first, and that read fails with `403` because `read:components` is missing |
| Publish drafts or packages with `metabind publish` | No — the component-list prerequisite fails with `403` because `read:components` is missing |
| Read or modify users, roles, or invitations | No |
| Create or read API keys | No |
| Change project settings, including the CI binding itself | No |

A push writes component source, so the token can change the code a release is built from. It can't re-point the binding at a different repository even if the token itself leaks.

## Preview Projects for Pull Requests

An ephemeral preview gives every pull request its own project to test against, without anyone having to remember to clean it up. The CI token can't create or delete previews, because it only works against the project it was minted for, so run these commands signed in with `metabind auth login`:

```bash theme={null}
metabind preview create --ttl-hours 24 --name "PR <number>"
```

* The copy is shallow: current draft state only, no version history and no published packages.
* Every preview expires — there's no option to create one that doesn't. The default is 48 hours, up to a maximum of 7 days.
* Delete it explicitly when the pull request closes:

```bash theme={null}
metabind preview delete <projectId>
```

`preview delete` refuses to run against a project that isn't marked as a preview, so a wrong id won't take down a real project.

## Next Steps

<CardGroup cols={2}>
  <Card title="Flat-File Sync" icon="folder-tree" href="/cli/flat-file-sync">
    Pull a project to disk, edit it, and push changes back.
  </Card>

  <Card title="Workflow Patterns" icon="list-checks" href="/cli/workflows">
    Build, publish, and roll back changes with the CLI.
  </Card>
</CardGroup>


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