Automating Chrome Web Store Uploads with the API
Publish to the Chrome Web Store from CI — OAuth credentials for the API, uploading a package, submitting for review with a staged rollout percentage, and reading the resulting status.
Table of Contents
Uploading a zip through the developer dashboard is fine for the first release and a liability by the tenth: someone uploads the wrong build, forgets to bump the version, or publishes to 100% when the plan was a 10% rollout. The Chrome Web Store API lets CI do the upload and submission with the exact artifact the pipeline built and tested. Setting it up involves an OAuth dance that is awkward once and invisible afterwards. This guide is part of CI and release automation.
What the pipeline does
Step-by-step
1. Create API credentials once
In Google Cloud, create a project, enable the Chrome Web Store API, and create an OAuth client of type Desktop app. Then obtain a refresh token for the Google account that owns the item by running the consent flow once, locally.
1# One-time: exchange an auth code (from the consent URL) for a refresh token.
2curl -s https://oauth2.googleapis.com/token \
3 -d client_id="$CWS_CLIENT_ID" \
4 -d client_secret="$CWS_CLIENT_SECRET" \
5 -d code="$AUTH_CODE" \
6 -d grant_type=authorization_code \
7 -d redirect_uri=urn:ietf:wg:oauth:2.0:oob | jq -r .refresh_token
Execution context: your local shell, once. The refresh token is the credential CI will use indefinitely; store it with the client id and secret in your CI secret store and nowhere else — the handling rules are in managing store credentials in CI secrets. The consent URL uses the https://www.googleapis.com/auth/chromewebstore scope.
2. Mint an access token per run
1ACCESS_TOKEN=$(curl -s https://oauth2.googleapis.com/token \
2 -d client_id="$CWS_CLIENT_ID" \
3 -d client_secret="$CWS_CLIENT_SECRET" \
4 -d refresh_token="$CWS_REFRESH_TOKEN" \
5 -d grant_type=refresh_token | jq -r .access_token)
6test -n "$ACCESS_TOKEN" && test "$ACCESS_TOKEN" != "null"
Execution context: the CI job. Access tokens last about an hour, which is ample for an upload and submit. The test line fails the job immediately if the refresh token has been revoked, rather than letting a later step fail with a confusing 401.
3. Upload the built package
1curl -sf -X PUT \
2 -H "Authorization: Bearer $ACCESS_TOKEN" \
3 -H "x-goog-api-version: 2" \
4 -T dist/extension.zip \
5 "https://www.googleapis.com/upload/chromewebstore/v1.1/items/$CWS_ITEM_ID" | tee upload.json
6
7jq -e '.uploadState == "SUCCESS"' upload.json
Execution context: the CI job, after the build and test stages. uploadState can also be IN_PROGRESS for large packages or FAILURE with an itemError array explaining why — most often a version number that is not higher than the published one. jq -e fails the job unless the upload succeeded.
4. Submit with a staged rollout
1curl -sf -X POST \
2 -H "Authorization: Bearer $ACCESS_TOKEN" \
3 -H "x-goog-api-version: 2" \
4 -H "Content-Length: 0" \
5 "https://www.googleapis.com/chromewebstore/v1.1/items/$CWS_ITEM_ID/publish?deployPercentage=10" | tee publish.json
6
7jq -e '.status | index("OK") or index("ITEM_PENDING_REVIEW")' publish.json
Execution context: the CI job. deployPercentage starts a partial rollout once review passes, which is the single most valuable safety net for a bad release — covered in rolling back a bad extension release. publishTarget=trustedTesters publishes to the trusted-tester group instead of the public.
5. Make it a pipeline stage with a manual gate
1# .github/workflows/release.yml (excerpt)
2 publish-chrome:
3 needs: [build, test]
4 runs-on: ubuntu-latest
5 environment: chrome-web-store # requires a reviewer's approval
6 steps:
7 - uses: actions/download-artifact@v4
8 with: { name: extension-chrome, path: dist }
9 - run: ./scripts/cws-publish.sh
10 env:
11 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
12 CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
13 CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
14 CWS_ITEM_ID: ${{ vars.CWS_ITEM_ID }}
Execution context: GitHub Actions. Publishing from the artifact the test job validated — not from a rebuild — guarantees the reviewed bytes are the tested bytes. The environment with required reviewers gives a human one click to confirm before anything reaches the store. The surrounding pipeline is in building a GitHub Actions pipeline for extensions.
Failure modes worth handling
The API is reliable; the surrounding assumptions are where automated releases go wrong.
The version was not bumped. The upload fails with ITEM_NOT_UPDATABLE or a version error. Guard it before the upload by comparing the manifest version against the published one from the API’s GET on the item — a failed release discovered at the end of a long pipeline is more expensive than one caught in the first minute.
An item is already in review. Submitting while a previous version is pending replaces the pending submission. That is sometimes what you want and sometimes a surprise; the job should read the item’s current state and refuse to overwrite a pending review without an explicit flag.
The refresh token was revoked. Tokens are revoked when the owning account changes its password, when the OAuth client is deleted, or after long disuse in some configurations. The per-run minting step in step 2 surfaces this immediately; alerting on it rather than discovering it on release day is worth one scheduled job that mints a token weekly.
The rollout percentage is forgotten. A job that publishes at the store’s default goes to everyone once review passes. Make deployPercentage a required input of the release job, not a default, so nobody can publish without choosing it.
Cross-browser variation
- Chrome Web Store: the API described here, with upload, publish,
deployPercentageandpublishTarget. Edge Add-ons has its own separate API with a different authentication model. - Firefox (AMO): uses
web-ext signwith an API key and secret rather than OAuth; the flow is covered in signing and publishing to AMO from CI. - Safari / App Store: releases go through App Store Connect, usually via
xcrun altoolornotarytooland Fastlane, with an App Store Connect API key. - All three: automate the upload, keep a human approval step before publication. Automated review submission is safe; automated publication to all users without a person looking is not.
Verification
- Run the script against a test item first — create an unlisted item for pipeline testing so a mistake never reaches real users.
- Confirm the item state after a run:
1curl -s -H "Authorization: Bearer $ACCESS_TOKEN" -H "x-goog-api-version: 2" \
2 "https://www.googleapis.com/chromewebstore/v1.1/items/$CWS_ITEM_ID?projection=DRAFT" | jq '{uploadState, crxVersion}'
3# { "uploadState": "SUCCESS", "crxVersion": "2.4.1" }
Execution context: your shell or CI. crxVersion should equal the manifest version the pipeline built.
- Revoke the refresh token in a test project and confirm the job fails at the minting step with a clear message.
- Confirm in the dashboard that the submission shows the chosen rollout percentage.
FAQ
Can I automate the store listing text and screenshots?
Not through this API — it handles packages and publication. Listing content is managed in the dashboard.
Should CI publish on every merge to main?
No. Publish from a tagged release with a human approval step. Uploading drafts on every merge is fine; submitting them for review is not.
Does a service account work instead of a user refresh token?
Historically the API required a user account that owns or has access to the item. Check current documentation — support for service accounts has been evolving — but a dedicated Google account for publishing, with 2-step verification, is the robust default.
How do I increase the rollout percentage later?
Call the publish endpoint again with a higher deployPercentage once the version is live. A small scheduled job that raises it — 10%, then 50%, then 100% — after checking error rates for the new version turns a staged rollout into a routine rather than a manual chore.
Related
- Building a GitHub Actions pipeline for extensions — the pipeline this stage belongs to.
- Managing store credentials in CI secrets — protecting the refresh token.
- Versioning and changelogs for extension releases — the version bump this depends on.
- CI and release automation — the parent guide.