# Promote gates

> Every refusal a promote, a dispatch, a rollback or a cut can return, with meaning and fix.

Canonical: https://docs.extension.dev/builds/promote-gates
This page as Markdown: https://docs.extension.dev/builds/promote-gates.md
Section: Console: build and release
Siblings: [Console overview](https://docs.extension.dev/console/overview.md) · [Usage and limits](https://docs.extension.dev/console/usage.md) · [Builds](https://docs.extension.dev/builds/overview.md) · [Channels](https://docs.extension.dev/builds/channels.md) · [Releases](https://docs.extension.dev/builds/releases.md) · [Store releases](https://docs.extension.dev/builds/store-releases.md) · [Integrity](https://docs.extension.dev/builds/integrity.md)
Map of every page: https://docs.extension.dev/llms.txt · Whole corpus: https://docs.extension.dev/llms-full.txt

---

# Promote gates

Promote gates are the refusals a promotion, a build dispatch, a rollback or a
release cut can return. Reach for this page when a request came back with a
`code` and you need its meaning and its fix.


## How it works

Every gate runs before anything is dispatched, metered or written. A refusal
spends nothing and moves nothing: the channel stays where it was.

The refusals group by what they check. Request gates read the body. Channel,
environment and build index gates read the project's records. Workflow gates
read the mirror. Rollback and cut gates belong to those two endpoints alone.
The operator gate and the quota gates read the workspace.

Access refusals are decided at the door, before any gate on this page runs.
They apply to every endpoint here and sit in the last table.

Not every refusal carries a `code`. The [refusals with no code](#refusals-with-no-code)
come back with a message alone, so a client that switches on `code` must
handle that shape too.

## Reference

### Request gates

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `INVALID_BODY` | `400` | The body does not match the endpoint's schema. `details` names the failed fields. `GET /api/projects/{id}/releases/promotion-runs` answers the same code without `ids`, with the message `Missing dispatch ids to correlate.` | Send the fields the endpoint documents. For a promote, `buildId` is the only required field. See [the API reference](/automate/api) |
| `INVALID_ORIGIN` | `400` | The dispatch body named an `origin` the platform does not know. The accepted values are `mcp`, `code`, `console`, `cli`, `cli-unknown`, `import` and `api` | Send one of those seven, or leave `origin` out. A dispatch with no origin is recorded as `console` |

### Channel gates

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `UNKNOWN_CHANNEL` | `404` | The promotion names a channel this project does not declare. The message lists the declared channels, which always include `stable` and `preview`. A release cut answers the same code when it names an undeclared channel | Create the channel under **Project settings, Release channels**, or promote to a channel in the list. See [Channels](/builds/channels) |
| `UNKNOWN_SOURCE_CHANNEL` | `404` | The promotion names a `sourceChannel` this project does not declare. `canary` is accepted as the legacy name of `preview` | Promote from a channel in the list, or leave `sourceChannel` unset. A promote to `preview` sources from `preview`; any other channel sources from itself |
| `CHANNEL_STATE_INDETERMINATE` | `409` | The channel list could not be read at decision time, so the gate cannot tell whether the channel exists. A cut answers the same code | Retry |

A channel is promotable one second after it is created, because this gate
reads the same channel record the console writes.

### Environment gates

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `BUILD_ENV_MISMATCH` | `409` | The build's stamped environment does not match the environment bound to the target channel | Rebuild the commit in the channel's environment, or promote to a channel bound to the build's environment |
| `UNSTAMPED_BUILD_BLOCKED` | `409` | The build carries no environment stamp and the project's environments are populated, so the gate cannot place it | Rebuild the commit. New builds are stamped |
| `ENV_STATE_INDETERMINATE` | `409` | The environment state could not be read at decision time | Retry. If it persists, check [Environments](/guides/environments) |

### Build index gates

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `UNKNOWN_BUILD` | `404` | The sha is not in the build index. The platform never produced that build | Build the commit first, or correct the `buildId` |
| `REBUILD_REPLACES_LIVE_ARTIFACT` | `409` | The rebuild would overwrite artifacts a live channel serves, for an environment other than the one that channel requires | Rebuild with the environment the channel requires. A person in the console can overwrite anyway with **Replace and rebuild**, which sends `force: true`; a project token is refused `403 REBUILD_FORCE_NEEDS_A_PERSON` for it, so its message never offers it. A production rebuild of a commit that only `preview` serves passes without force |

### Workflow gates

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `RELEASE_WORKFLOW_NOT_FOUND` | `404` | Promote, rollback and `GET /api/projects/{id}/releases/promotion-runs` read the project's release workflow, and the mirror carries none | Reconnect the project under **Project settings, GitHub integration**, which restores the mirror's workflows |
| `RELEASE_DISPATCH_FAILED` | `500` | One promote run dispatches per browser, and every one failed | Retry. A promote where at least one browser dispatched answers `200` and names the browsers that did not |

### Rollback gates

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `NO_ROLLBACK_TARGET` | `409` | The channel's latest promotion left no earlier build: a first promotion, a promotion of the build the channel already held, or a channel with no event log | Promote a build by id with `POST /api/projects/{id}/releases`. The channel gains a target once a second, different build enters it |
| `ROLLBACK_HISTORY_UNREADABLE` | `503` | The promotion history could not be read. A timeout, a registry error, a rejected token and a non-JSON answer all read this way | Retry. If it persists, the registry is the thing to check |
| `ROLLBACK_DISPATCH_FAILED` | `502` | A target resolved and every browser's dispatch failed | Retry. A run where some browsers dispatched answers `200` and names the ones that did not |

### Release cut gates

A cut bumps the manifest version in your source repository and opens a
release PR, so its refusals are about that repository.

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `NO_SOURCE_REPO` | `409` | The project is not connected to a source repository | Connect one under **Project settings, GitHub integration** |
| `APP_NOT_INSTALLED` | `409` | The extension.dev GitHub App is not installed on the source repository | Reconnect the project, which reinstalls the App |
| `MANIFEST_NOT_FOUND` | `409` | No `manifest.json` with a version was found. The cut looks in the project's root directory first, then in the usual locations | Bump the version by hand and push, or set the root directory to the folder that holds the manifest |
| `VERSION_INVALID` | `400` | The requested version is not one to four dot separated integers from 0 to 65535, such as `0.3.0` or `1.2.3.4` | Send a version in that shape, or send a `level` of `patch`, `minor` or `major` instead |
| `VERSION_NOT_AHEAD` | `400` | The requested version is not greater than the one the manifest carries. The message names the current one | Ask for a higher version |
| `VERSION_UNPARSEABLE` | `409` | The manifest's current version could not be read as a version | Name the version you want with `version`, or correct the manifest and push |
| `SOURCE_REPO_UNREACHABLE` | `409` | The source repository answered `404` through the GitHub App. Renamed, deleted and uncovered by the installation all read this way | Reconnect the project under **Project settings, GitHub integration** |

### Operator gate {#build-dispatch-disabled}

| Code | Status | Meaning | Fix |
| --- | --- | --- | --- |
| `BUILD_DISPATCH_DISABLED` | `429` | An operator paused build dispatch. Every request that dispatches a run on your mirror answers this code while the pause holds | None on the client. Wait for the operator to lift the pause |

The pause holds the console **Rebuild** button, `POST /builds/dispatch`, a
promote, a rollback, a release cut, a store submission from the console, the
API or the CLI, a rollout raise, a submission cancel and a store credential
recheck.

The lanes with nobody waiting hold too. The push webhook answers its delivery
with the code `DISPATCH_DISABLED` and skips the build. The dispatch sweep
records a `kill_switch` refusal. The store-status sweep returns paused. A
merged release PR stays pending and builds after the pause lifts. The first
build after an import is skipped with a line in the import stream, and the
project builds from the Builds page once dispatch resumes.

### Refusals with no code

| Status and message | Endpoint and cause |
| --- | --- |
| `400` `No commits found on the target branch.` | Dispatch. The `commitSha` was not found, or the branch carries no commits |
| `400` `GitHub App installation not found for this project.` | Dispatch. No App installation is recorded. Reconnect the project |
| `404` `No build workflow found for this project.` | Dispatch. The mirror carries no build workflow. Reconnect the project |
| `405` `Method not allowed`, with an `Allow` header | Any endpoint here, reached with the wrong method |
| `500` `Internal server error` | Any endpoint here, when the handler threw. Retry, and read the run history before promoting again |

### Quota reasons

Every quota refusal names one of twelve reasons. Where the answer lands
depends on the endpoint, so read this table with the [door codes](#quota-door-codes)
below it.

| Reason | Meaning | Fix |
| --- | --- | --- |
| `monthly_cap` | The monthly build allowance is spent | Wait for the monthly reset, or raise the plan at [extension.dev/pricing](https://www.extension.dev/pricing) |
| `burst_cap` | Too many push builds inside a sliding 60 minute window: 12 on free, 30 on pro. Decided on the push webhook and recorded, not answered | Wait. The window slides and capacity returns as older dispatches age out |
| `kill_switch` | An operator paused dispatch. This is the reason behind `BUILD_DISPATCH_DISABLED` | None on the client |
| `push_builds_exhausted` | The workspace spent its push builds for the month: 25 on free, 350 on pro. Only a git push on a branch the project builds is counted | Click **Rebuild** in the console to run the same commit now. The count resets next month. See [CI and releases](/automate/ci) |
| `counter_unavailable` | The usage counter could not be read, so the gate refuses rather than dispatches unmetered | Retry |
| `entitlement_unavailable` | The workspace's plan could not be read, so no ceiling could be established | Retry. This is never a statement about your allowance |
| `unattributed` | The push could not be tied to a workspace, so nobody could be billed. Decided on the push webhook and recorded, never answered | None on the client. The fault is in the platform's record of the project |
| `fork_pull_request` | The commit sits on a pull request whose head branch lives in another repository. A build runs that code and spends your allowance, so a fork PR does not start one | Read the change, then click **Rebuild** in the console to run that commit yourself, or merge the PR, which builds it as a push |
| `macos_runner_not_included` | The plan carries no Safari lane. Safari builds and App Store submissions run on a macOS runner and belong to the paid plan. A free workspace is refused before any dispatch. A build that names Safari with other browsers drops the Safari leg and builds the rest; Safari alone is refused | Raise the plan at [extension.dev/pricing](https://www.extension.dev/pricing). Chrome, Edge and Firefox are never touched by this |
| `macos_notarization_not_included` | A macOS launcher must be signed and notarized before macOS opens it. That signing belongs to the paid plan, and a free workspace is refused before anything is signed or submitted to Apple. The promotion itself succeeds | Raise the plan and promote again. Signing with your own Apple Developer account stays free |
| `safari_submissions_exhausted` | The 15 App Store submissions a paid workspace carries in a month are spent. A submission also spends one from the monthly build allowance | Wait for the month to roll, or submit to the other three stores |
| `testing_workspace` | The workspace is marked as a testing workspace and is read-only: builds, store submissions, releases, shares and AI generation are refused. Only an operator sets or clears the marker | None on the client. No retry or reset clears it |

### Quota door codes

An endpoint with a person waiting answers at the door. The code comes in two
dialects.

| Endpoint | Spent allowance | Testing workspace | Unreadable counter |
| --- | --- | --- | --- |
| `POST /builds/dispatch` and the console **Rebuild** button | `429` with the reason as the code, such as `monthly_cap` | `403 testing_workspace` | `503 counter_unavailable` |
| Promote, rollback, cut and store submission | `429 BUILD_QUOTA_EXHAUSTED` | `403 TESTING_WORKSPACE_READ_ONLY` | `503 BUILD_COUNTER_UNAVAILABLE` |
| Safari build or App Store submission | `403 macos_runner_not_included` or `403 safari_submissions_exhausted` | | `503 SAFARI_ALLOWANCE_UNAVAILABLE` |

The push webhook answers nobody, so `push_builds_exhausted`,
`fork_pull_request`, `burst_cap` and `unattributed` are recorded against the
workspace and read back in the console under
[Usage and limits](/console/usage).

`macos_notarization_not_included` is decided while a release is being
promoted, after the promotion succeeded. It never sets the response status.
It is reported inside the promote response under `notarization.refused`,
with the code `MACOS_NOTARIZATION_NOT_INCLUDED`, the browsers it applies to
and an upgrade link. Every extension package is published, and the Linux and
Windows executables are built and downloadable.

### Access gates

| Code | Status | Meaning |
| --- | --- | --- |
| `UNAUTHENTICATED` | `401` | No session and no token |
| `BAD_TOKEN` | `401` | The token does not verify, or carries no project |
| `INVALID_PROJECT_ID` | `400` | The `{id}` in the path is not a project id |
| `TOKEN_PROJECT_MISMATCH` | `403` | The token names a different project |
| `OWNER_REQUIRED` | `403` | The action is owner only, and the caller is not one |

A cut, a rollback and `GET /api/projects/{id}/releases/promotion-runs` are
owner only. A request that carries a token is judged on the token and never
falls back to the session. See [the API reference](/automate/api#token-refusals).

## When it fails

- **A code is not on this page.** The refusal came from the build itself, not
  from a gate. Open the build's logs with
  `GET /api/projects/{id}/builds/{buildId}/logs`.
- **A `429` with no reason you can place.** Read the `code`. The dispatch
  endpoint answers the reason itself, while promote, rollback, cut and
  submission answer `BUILD_QUOTA_EXHAUSTED`. Both mean the same spent month.
- **A promote answered `200` and a browser is missing.** Read
  `failedBrowsers` in the body. Only a promote where every dispatch failed
  answers `RELEASE_DISPATCH_FAILED`.
- **A promote answered `200` and no macOS launcher shipped.** Read
  `notarization.refused` in the body. The plan carries no signing, and the
  rest of the release went out.

## Next

- [Releases](/builds/releases)
- [Channels](/builds/channels)
- [Builds](/builds/overview)
