> ## 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

> Publish a project from GitHub Actions without storing a long-lived credential

The CI publish lane lets a GitHub Actions workflow publish a project without storing a Metabind credential anywhere in your repository. GitHub proves which repository a workflow run belongs to using its own OIDC token, and Metabind exchanges that for a Metabind access token scoped to exactly what a publish needs, good for 15 minutes.

<Note>
  The CLI ships as a macOS binary today. The publish 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 only push file changes and publish a package from components that already exist. It cannot create or edit a component, manage users, mint API keys, or change project settings — including the binding that grants this whole capability.

## Bind a Repository to a Project

Tell Metabind which repository is allowed to publish to 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 publish.             |
| `ref`        | No       | Restrict further to one branch, for example `refs/heads/main`. |

Without a `ref`, any branch pushed to the bound repository can publish — 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>
  Send the full `ci` object every time you update it. Project settings are merged one level deep, so an update that sends only `{"settings":{"ci":{"ref":"..."}}}` replaces the whole `ci` object and drops the `repository` you set earlier, leaving a binding that can never match. Read the current settings first if you're only changing one field.
</Warning>

## Example Workflow

```yaml theme={null}
name: Publish to Metabind

on:
  push:
    branches: [main]

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

jobs:
  publish:
    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 and publish
        run: |
          metabind push
          metabind publish
```

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`.
* `core.setSecret()` masks the token in the job log. It expires in 15 minutes regardless, but there's no reason to leave it unmasked.

## 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 a publish is denied, check in order:

| Check                                                       | Common mistake                                                        |
| ----------------------------------------------------------- | --------------------------------------------------------------------- |
| `settings.ci.repository` is set on the project              | Never configured, or dropped by a later update that only sent `ref`   |
| It matches `owner/name` exactly                             | An organization or repository rename                                  |
| `settings.ci.ref` matches the branch you're publishing from | Pinned to `main` while publishing 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 |
| -------------------------------------------------------- | ------- |
| Push file changes and publish drafts                     | Yes     |
| Publish a package from components that already exist     | Yes     |
| Create, edit, or delete a component                      | No      |
| Read or modify users, roles, or invitations              | No      |
| Create or read API keys                                  | No      |
| Change project settings, including the CI binding itself | No      |

Publishing a package is a release action over code that already exists; authoring a component is a write action over the code a release is built from. A token minted automatically for a third-party runner gets the first, never the second — and it can't re-point the binding at a different repository even if the token itself leaks.

## Preview Projects for Pull Requests

Pair the publish lane with an ephemeral preview so every pull request gets its own project to test against, without anyone having to remember to clean it up:

```bash theme={null}
metabind preview create --ttl-hours 24 --name "PR ${{ github.event.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>
