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
git pushThe 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.
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 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.
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. 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.
A pipeline of your own
A job that calls the platform API needs a different credential. Mint a project token, store it as a repository secret, and send it in the Authorization header:
- 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.
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. 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.
401 REGISTRY_TOKENfrom 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 to trigger builds and releases yourself
- Access tokens for machines outside GitHub Actions
- Plans and limits for every enforced ceiling