# CI and releases

> Builds run when you push, releases ship while you sleep.

Canonical: https://docs.extension.dev/automate/ci
This page as Markdown: https://docs.extension.dev/automate/ci.md
Section: Automate
Siblings: [HTTP API](https://docs.extension.dev/automate/api.md)
Map of every page: https://docs.extension.dev/llms.txt · Whole corpus: https://docs.extension.dev/llms-full.txt

---

# CI and releases

A push build is a build the GitHub App starts for you when you push. Reach
for this page to learn what a push spends, where the build runs, and what
the platform writes back to your repository.

## Quick start

```bash
git push
```

The GitHub App sees the push and starts a build on the project's build
repository. The build lands under **Builds** in the console within about two
minutes, and the pull request, if there is one, gets a comment with the
results.


## How it works

### What a push spends

Free carries 25 push builds a month for the whole workspace. Pro carries
350, the same number as its monthly dispatch total, so the push ceiling is
never the reason a paid workspace is refused. The twenty sixth push build of
the month on Free is refused, and the message names the free way to run the
same commit: click **Build** in the console. See
[extension.dev/pricing](https://www.extension.dev/pricing).

When the project names a production branch in **Project settings,
Deployment**, only pushes to that branch build. Pushes to other branches are
skipped without spending one. An empty field builds every branch you push.

A pull request opened, reopened or updated against the project also starts
a push build and spends one. The production branch filter does not apply to
it, because a pull request's head is a feature branch by definition.

Pull request builds have their own switch in the same section, **Build pull
requests**. It is on for every project, so a reviewer can try the extension
before merge. Turn it off and pull requests stop building and stop spending.

### Where builds run

Every project gets a build repository in the platform's organization. All CI
minutes burn there, never in your repository. Your repository stays the
source. The platform reads it and writes back only the
[footprint](#what-the-platform-writes) below.

### The trigger chain

The extensiondev GitHub App on the source repository sends `push` and
`pull_request` webhooks. Each webhook resolves to one of three decisions:
dispatch, debounce or drop-duplicate. The debounce window is 75 seconds and
the sweep runs every minute, so a coalesced push starts within about two
minutes rather than instantly.

Build concurrency is per ref with cancel-in-progress. A new push cancels the
older build instead of billing both.

### Why a build started

Every trigger reaches the build repository as a `workflow_dispatch`, so the
reason a build started is recorded on the platform and nowhere else. The
usage counters carry five labels. The label follows the caller, not the door:
`cli`, `cli-unknown`, `mcp` and `api` all mean nobody was at the keyboard,
so they share the `api` label.

Only `webhook` is a push build, and it is the only label the push ceiling
counts. `console`, `onboarding`, `release` and `api` are not counted against
it.

The dispatch endpoint takes either a signed-in session or a project token.
See [what a project token reaches](/automate/api#what-a-project-token-reaches).

### What you configure

You never edit workflow YAML. The console holds the whole build
configuration: the install command, build command and output directory per
browser, environment variables per environment, channels, and store
credentials. Change any of these in project settings and the next dispatch
uses them.

### Inside the run

The runner mints a GitHub Actions OIDC assertion for the audience
`extension-dev-registry` and trades it for a one-hour registry token at
[`POST /api/oidc/exchange`](/automate/api#token-exchange). The audience
belongs to the assertion the runner presents, not to the token you get back.
Nothing long-lived is stored in CI for this, because the runner mints a
fresh assertion on every run.

That token reads the registry, and that is all it reaches. Every platform
API endpoint that reads the `Authorization` header answers it
`401 REGISTRY_TOKEN`, naming the credential you hold and the one to mint
instead. The exchange says the same thing on the way out: its answer carries
`lane: "registry"` and a `note`.

The registry serves a project without a token only when the project is
declared public. A private project, and a project the platform holds no
declaration for, needs the token for every read. See
[Public project pages](/share/public-pages).

### A pipeline of your own

A job that calls the platform API needs a different credential. Mint a
[project token](/tools/access-tokens), store it as a repository secret, and
send it in the `Authorization` header:

```yaml
- name: Start a build
  run: |
    curl -s -X POST \
      "https://www.extension.dev/api/projects/$PROJECT_ID/builds/dispatch" \
      -H "Authorization: Bearer $EXTENSION_DEV_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"origin":"api"}'
  env:
    PROJECT_ID: ${{ vars.PROJECT_ID }}
    EXTENSION_DEV_TOKEN: ${{ secrets.EXTENSION_DEV_TOKEN }}
```

A project token lives at most seven days, so this secret needs rotating on
that cadence. A token that leaks stays useful for days rather than forever.
See [Access tokens](/tools/access-tokens#lifetime).

### What the platform writes

On every project, the platform writes one GitHub Deployment per build in the
`preview` environment, with an environment URL to the project's preview
channel page. When a build is promoted it writes one Deployment per browser
in `<browser>-<channel>`, with the URL of that channel's page. A build from a
pull request gets one comment, upserted in place, with a build results
table. The cut flow opens version-bump release PRs and comments on that PR
when the release ships or fails.

Once the matching feature is in use, it also writes GitHub Deployments for
store submissions, updated after each review poll. A GitHub Release, with a
tag and the built archives as assets, is off by default. Turn on **Create a
GitHub Release on promote** under **Project settings, Release channels**. A
non-stable channel is marked as a pre-release.

Every build also opens one GitHub check run per browser on the commit it
builds, named `Extension.dev / Chrome build`, `Extension.dev / Firefox build`
and so on, and completes it with that browser's result: `success`, `failure`,
or `cancelled` when a newer push replaced the build. **Details** opens that
browser's build page in the console.

To stop a broken extension merging, require the check. In your repository's
**Settings, Rules** (or **Branches, Branch protection rules**), add a required
status check and pick `Extension.dev / Chrome build`, plus one per browser you
ship. GitHub only offers a check in that picker after it has run on the
repository in the last week, so build once first.

A release PR can arrive armed: merging it promotes automatically the moment
the build goes green.

## Reference

### Webhook decisions

| Decision | What happens |
| --- | --- |
| `dispatch` | The build starts on the build repository now |
| `debounce` | The commit is queued, and a sweep starts it once the window closes |
| `drop-duplicate` | The same commit already dispatched on this ref, so nothing starts |

### Trigger labels

| Trigger | Started by |
| --- | --- |
| `console` | The console's Build button, and `POST /api/projects/{id}/builds/dispatch` declaring an `origin` of `console` or `code` |
| `onboarding` | The first build after an import, and that endpoint declaring `import` |
| `webhook` | A push, or a pull request opened, reopened or updated |
| `release` | A promote, a rollback, or a merged release |
| `api` | A store submission through `POST /api/cli/stores/submit`, and the dispatch endpoint declaring `cli`, `cli-unknown`, `mcp` or `api` |

The dispatch endpoint takes an `origin` of `console`, `cli`, `cli-unknown`,
`mcp`, `code`, `import` or `api`. It maps the older spellings `generation`,
`templates`, `www`, `dashboard` and `ui` onto those. Any other value answers
`400 INVALID_ORIGIN`, and a missing one reads as `console`.

### Limits

| Limit | Free | Pro |
| --- | --- | --- |
| Push builds a month | 25 | 350 |
| Dispatches a month, all triggers | 100 | 350 |
| Dispatches in a sliding hour | 12 | 30 |
| Debounce window | 75 seconds | 75 seconds |
| Debounce sweep | Every minute | Every minute |
| Registry token from the exchange | One hour | One hour |
| Project token | Up to seven days | Up to seven days |

### Footprint on your repository

| Write | When | Where |
| --- | --- | --- |
| GitHub Deployment | Every build | Environment `preview` |
| GitHub Deployment per browser | Every promote | Environment `<browser>-<channel>` |
| Pull request comment | Every build from a pull request | Upserted in place |
| Release PR and its comment | Every cut | The version-bump PR |
| GitHub Deployment per store | Every submission, after each review poll | `chrome-web-store`, `edge-add-ons`, `firefox-amo`, `safari-app-store` |
| GitHub Release, tag and archives | Every promote, when the setting is on | Pre-release on a non-stable channel |
| Check run per browser | Every build | `Extension.dev / <Browser> build` on the built commit |

## When it fails

- **No Extension.dev check run appears on a commit.** The check runs need the
  Checks permission on the Extension.dev GitHub App. An installation that
  predates it shows a pending permission request on the App's installation
  page in your GitHub settings. Until an owner accepts it, no check run is
  written. The build, its Deployments and its pull request comment are
  unaffected.
- **The push built all month and stopped.** That is the push build ceiling,
  25 a month on Free. The refusal reads "This workspace has used all 25 push
  builds this month" and names the way out. Click **Build** in the console to
  run the same commit now, free. The count resets at the start of the next
  month.
- **A pull request stopped building.** **Build pull requests** in **Project
  settings, Deployment** is off. The skip names the head branch and says no
  push build was spent. Turn the switch back on, or click **Build** in the
  console to run that commit now, free.
- **A build starts a minute or two after the push.** That is the 75 second
  debounce plus the sweep, not a hang.
- **Two pushes in a row produce one build.** The older run on the same ref is
  canceled by design, and the second push may also coalesce into the first.
- **A push with no build and no message.** The decision was drop-duplicate,
  or a dispatch refusal fired. The refusals reachable here are a kill switch,
  an unattributable workspace, unreadable counters, an unresolvable plan, the
  push builds for the month, the monthly cap, the burst window, a pull
  request opened from a fork, a Safari leg on a plan that does not carry one,
  and a workspace marked for testing. Each is named on
  [promote gates](/builds/promote-gates). Check usage in the console.
- **Builds stop part way through a busy hour.** That is the burst window, a
  separate ceiling on the same allowance, counted over a sliding hour: 12
  dispatches an hour on Free and 30 on Pro, whatever started them. It resets
  without you as the older dispatches age out.
- **Registry reads fail in CI on a project that used to read fine.** The
  OIDC exchange did not run, and the project is not declared public. Only a
  declared public project reads without the one-hour token. See
  [Public project pages](/share/public-pages).
- **`401 REGISTRY_TOKEN` from the API on a token the exchange just
  returned.** The exchange is a registry credential. Every platform API door
  that reads the header refuses it, and the refusal says so in words. Store a
  project token for that job instead.

## Next

- [HTTP API](/automate/api) to trigger builds and releases yourself
- [Access tokens](/tools/access-tokens) for machines outside GitHub Actions
- [Plans and limits](https://www.extension.dev/pricing) for every enforced ceiling
