CI and releases
Connect a repository once and the loop holds without you: builds run when you push, releases ship while you sleep.
A build the GitHub App started for you is a push build. Free carries 25 of them a month for the whole workspace. When the project names a production branch in Project settings, Deployment, only pushes to that branch build and the rest 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, and the production branch filter does not apply to it: a pull request's head is a feature branch by definition. That lane has its own switch in the same section, Build pull requests. It is on for every project, so pull requests build and a reviewer can try the extension before merge. Turn it off and they stop building, and they stop spending. The twenty sixth push build of the month is refused, and the message names the free way to run the same commit: click Build in the console. Pro carries 350, the same number as its monthly dispatch total, so the push ceiling is never the reason a paid workspace is refused. See extension.dev/pricing.
Where builds run
Every project gets a mirror repository in the platform's org. 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:
| Decision | What happens |
|---|---|
| dispatch | The build starts on the mirror 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 |
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 mirror as a workflow_dispatch, so the reason a build started is recorded on our side and nowhere else. The usage counters carry five labels, and these are their words:
| 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 that endpoint declaring cli, mcp or api |
The label follows the caller, not the door. The dispatch endpoint takes an origin of console, cli, mcp, code, import or api; any other value answers 400 INVALID_ORIGIN, and leaving it out reads as console.
Six origins reach five labels because the labels answer one question: whether a person was at the keyboard. cli, mcp and api all answer it the same way, so they share the api label. A sixth label would change what the other five mean.
The dispatch endpoint takes either a signed-in session or a project token. See what a project token reaches.
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.
What you configure
You never edit workflow YAML. The console holds the whole build configuration:
- Install command, build command, and output directory, per browser
- Environment variables, per environment
- Channels
- Store credentials
Change any of these in project settings and the next dispatch uses them.
Auth inside the run
The runner mints a GitHub Actions OIDC assertion for the audience extension-dev-registry and trades it for a 1-hour registry token, via 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 are holding and the one to mint instead, and the rest never read the header at all. The exchange says the same thing on the way out: its answer carries lane: "registry" and a note.
The exchange degrades open, and "open" means a project declared public. The registry serves a project only on that declaration, so 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 is a different job, and it 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. The cap is the trade: a token that leaks stays useful for days rather than forever. See Access tokens.
What the platform writes to your repository
Always, on every project:
- GitHub Deployments, one per browser, in the environments
<browser>-stableon a push and<browser>-previewon a pull request, each with an environment URL to the public build page - One pull request comment, upserted in place, with a build results table
- Version-bump release PRs from the cut flow, plus a comment on that PR when the release ships or fails
Also, once the matching feature is in use:
- GitHub Deployments for store submissions, in the environments
chrome-web-store,edge-add-onsandfirefox-amo, updated after each review poll - A GitHub Release, a tag, and the built archives as assets, only when Project settings, Release channels turns on Create a GitHub Release on promote. It is off by default, and a non-stable channel is marked as a pre-release.
The platform writes no GitHub check run, on your repository or on the mirror. A branch protection rule cannot require an Extension.dev build: read the per-browser Deployments instead, or the pull request comment.
A release PR can arrive armed: merging it promotes automatically the moment the build goes green.
When it fails
- The push built all month and stopped. That is the push build ceiling, 25 a month on free. The refusal names the ceiling it counted against and reads "This workspace has used all 25 push builds this month". 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 cancelled 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 and explained 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 failing 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 1-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