How to continuously deploy a Chrome Extension

Community Engineer, CircleCI

Note from the publisher: You have managed to find some of our old content and it may be outdated and/or incorrect. Try searching in our docs or on the blog for current information.
Google Chrome remains the most popular browser in the world, and its ecosystem of extensions is thriving more than ever. From simple utilities like tab counters to powerful tools for developers and businesses, Chrome Extensions provide opportunities to enhance productivity and tailor user experience. As developers iterate on features, squash bugs, and respond to user feedback, the need for a seamless and automated deployment process becomes clear.
For this guide, I built and published a simple Chrome Extension called Tab Counter. It displays the number of open tabs directly on the extension icon. Lightweight and practical, this minimalist extension serves as a real-world example that’s ideal for learning CI/CD pipelines. You can find it live on the Chrome Web Store, and feel free to substitute it with your own project as you follow along. The source code is also available on GitHub for reference.
We’ll walk through how to build a robust continuous deployment pipeline for a Chrome Extension using CircleCI. Whether you’re building for yourself, an open-source tool, or a team, the ability to push updates reliably without manual overhead is essential and fully achievable using CircleCI, the Chrome Web Store API, and a few well-placed shell commands.
Why automate Chrome extension deployment?
Manually uploading a new zip file to the Chrome Web Store each time you fix a bug or make an improvement gets old very quickly, especially when you’re deploying often. Automation ensures every update is delivered reliably, securely, and consistently. It reduces the potential for human error, saves time, and lets you focus on building rather than deploying. As your workflow matures, having a CI/CD process in place becomes not just a nice-to-have, but an expectation for scalable software development.
Prerequisites
Before we start, make sure you have the following:
- A published or ready-to-publish Chrome Extension built with Manifest V3
- A Google Chrome Developer account. You might need to pay a one-time $5 registration paid.
- A CircleCI account
- A GitHub account
Set up Google OAuth credentials
To publish your extension automatically, you’ll need to set up Google OAuth credentials. This allows CircleCI to authenticate with the Chrome Web Store API and perform actions like uploading and publishing your extension.
To begin, visit the Google Cloud Console and create a new project. Once created, make sure it’s selected in the top project dropdown.
Enable the Chrome Web Store API
In the API Library, search for and enable the Chrome Web Store API. This gives your project access to the publishing endpoints needed to deploy extensions.
Generate OAuth credentials
Go to APIs & Services, then Credentials. Click Create Credentials → OAuth client ID.
You may need to configure the OAuth consent screen first. Choose External for the user type, and fill in the required fields like application name and support email. You can skip Scopes
and Test Users
for now.
When creating the OAuth client ID, select the application type, choose Desktop App, even though there’s a “Chrome Extension” option available in the dropdown. This might seem like the right choice, but it’s not intended for automated tools like CI/CD pipelines. “Chrome Extension” is meant for OAuth flows directly from within extensions, while “Desktop App” is appropriate for tools like CircleCI that perform external API requests.
Give your client a name like Chrome CI/CD App
.
Once created, copy your Client ID and Client Secret — you’ll need these for authentication.
Authorize and get refresh token
Before continuing, you may encounter an error like:
“Access blocked: [App Name] has not completed the Google verification process.”
This happens because your OAuth client is still in testing mode. Google restricts apps in testing mode to authorised test users only.
To resolve this, go to the Google Cloud Console, open the OAuth consent screen under APIs & Services -> Audience, and scroll to the Test users section. Add the Google account you’ll use for CircleCI deployment and save your changes. This grants the necessary access to complete the OAuth flow while the app remains in test mode.
Now, to authorize the app and get a token, visit the URL from the next code block in your browser. Replace $CLIENT_ID
with your actual value:
https://accounts.google.com/o/oauth2/auth?response_type=code&scope=https://www.googleapis.com/auth/chromewebstore&client_id=$CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob
Approve the permissions. You’ll receive a temporary code
. This is your authorization code. Exchange that for a refresh_token
using this curl
command:
curl "https://accounts.google.com/o/oauth2/token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "code=$CODE" \
-d "grant_type=authorization_code" \
-d "redirect_uri=urn:ietf:wg:oauth:2.0:oob" | jq '.refresh_token'
Copy and store the refresh_token
securely. This token can be used to obtain short-lived access tokens without needing to log in again.
Retrieve Extension App ID
To get your Chrome Extension’s App ID
, open its Chrome Web Store page and copy the last part of the URL. That ID uniquely identifies your extension when uploading and publishing from the API.
Build your CI pipeline with CircleCI
To automate the deployment process, set up a CircleCI pipeline that builds and deploys your Chrome Extension.
Create a new file in your repository called .circleci/config.yml
. Paste this content into it:
version: 2.1
jobs:
build:
docker:
- image: cimg/base:stable
steps:
- checkout
- run:
name: Install Dependencies
command: |
sudo apt-get update
sudo apt-get install -y curl jq zip
- run:
name: Package Extension
command: zip -r tab-counter.zip . -x '*.git*' -x '*.circleci*' -x '*README.md' -x '*test/*'
- run:
name: Upload & Publish to Chrome Web Store
command: |
ACCESS_TOKEN=$(curl -s -X POST https://accounts.google.com/o/oauth2/token \
-d client_id=${CLIENT_ID} \
-d client_secret=${CLIENT_SECRET} \
-d refresh_token=${REFRESH_TOKEN} \
-d grant_type=refresh_token \
-d redirect_uri=urn:ietf:wg:oauth:2.0:oob | jq -r .access_token)
UPLOAD_RESPONSE=$(curl -s -w "%{http_code}" -o upload_output.json \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-X PUT -T tab-counter.zip \
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/${APP_ID}")
if [[ "$UPLOAD_RESPONSE" -ne 200 ]]; then
echo "❌ Upload failed:"
cat upload_output.json
exit 1
fi
PUBLISH_RESPONSE=$(curl -s -w "%{http_code}" -o publish_output.json \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-H "Content-Length: 0" \
-X POST \
"https://www.googleapis.com/chromewebstore/v1.1/items/${APP_ID}/publish")
if [[ "$PUBLISH_RESPONSE" -ne 200 ]]; then
echo "❌ Publish failed:"
cat publish_output.json
exit 1
fi
workflows:
deploy:
jobs:
- build
This is a working example that you can modify for your Chrome extension. This configuration checks out your code, installs tools like curl
, jq
, and zip
, bundles your extension into a zip archive while ignoring Git-related files, and then publishes it to the Chrome Web Store.
It handles authentication by exchanging a stored refresh_token
for a short-lived access token, which is then used to authorize the upload and release. Once everything succeeds, your extension goes live, no manual steps needed
Save this file, commit your changes and push to your GitHub repository.
Log in to CircleCI, select your project, enter the branch housing your .circleci/config.yml
file, and click Set Up Project.
CircleCI will automatically detect the configuration file and start building your project. This would failed to deploy at the moment, as the required environment variables are not set yet.
Set up environment variables
In CircleCI, go to your project’s Project Settings → Environment Variables and add the following:
CLIENT_ID
CLIENT_SECRET
REFRESH_TOKEN
APP_ID
The APP_ID
is your extension’s unique identifier from the Chrome Web Store URL. Defining it as an environment variable makes your configuration cleaner and easier to manage.
Rerun the build, and it should complete successfully. You can check the logs to see the upload and publish steps.
Remember to always update your version number with every new deployment to avoid errors from the Chrome Web Store API.
Advanced practices
Handling development vs. production builds
During development, it’s common to load your extension in unpacked mode. But when both development and production versions are loaded in the same browser, you might end up with duplicate icons and versions, making it hard to distinguish them.
To avoid this, configure your manifest.json file with placeholder or dev-specific values like a version of 9.9.9.9 or an alternate icon:
{
"manifest_version": 3,
"name": "Tab Counter",
"version": "9.9.9.9",
"action": {
"default_icon": "dev-icon.png"
}
}
Then, just before building the extension in your CI pipeline, automatically replace those values using a short Bash script that increments the patch version and sets the production icon:
- run:
name: Set Production Icon & Version
command: |
current_version=$(jq -r '.version' manifest.json)
major=$(echo "$current_version" | cut -d. -f1)
minor=$(echo "$current_version" | cut -d. -f2)
patch=$(echo "$current_version" | cut -d. -f3)
new_version="$major.$minor.$((patch + 1))"
jq --arg v "$new_version" '.version = $v | .action.default_icon = "icon.png"' manifest.json | sponge manifest.json
This keeps your versioning consistent and avoids accidental reuse of an existing published version.
Use Git tags to control deployments
Rather than deploy on every commit to main
, you can require a semantic version Git tag like v1.2.3
to trigger a release. This adds control and avoids unintended deployments.
Here’s how to configure that:
workflows:
release:
jobs:
- build:
filters:
tags:
only: /^v.*/
- publish:
requires:
- build
filters:
branches:
ignore: /.*/
tags:
only: /^v.*/
This workflow defines two jobs, build
and publish
that only run when you push a Git tag starting with v
, like v1.0.2
. The build
job handles the packaging, while publish
runs afterward to deploy the extension. Note that this pattern assumes you’ve split your pipeline into two separate jobs. If you’re keeping everything in one build
job, you don’t need the publish
job block.
Use workspaces for multi-step workflows
If your packaging and publishing happen in separate jobs, use persist_to_workspace
and attach_workspace
to share the zipped extension between them.
Complete the CircleCI configuration example
Here’s what a full production-ready .circleci/config.yml
file might look like:
version: 2.1
jobs:
build:
docker:
- image: cimg/base:stable
steps:
- checkout
- run:
name: Install Dependencies
command: |
sudo apt-get update
sudo apt-get install -y curl jq zip moreutils
- run:
name: Set Production Icon & Version
command: |
current_version=$(jq -r '.version' manifest.json)
major=$(echo "$current_version" | cut -d. -f1)
minor=$(echo "$current_version" | cut -d. -f2)
patch=$(echo "$current_version" | cut -d. -f3)
new_version="$major.$minor.$((patch + 1))"
jq --arg v "$new_version" '.version = $v | .action.default_icon = "icon.png"' manifest.json | sponge manifest.json
- run:
name: Package Extension
command: zip -r tab-counter.zip . -x '*.git*' -x '*.circleci*' -x '*README.md' -x '*test/*'
- run:
name: Upload & Publish to Chrome Web Store
command: |
ACCESS_TOKEN=$(curl -s -X POST https://accounts.google.com/o/oauth2/token \
-d client_id=${CLIENT_ID} \
-d client_secret=${CLIENT_SECRET} \
-d refresh_token=${REFRESH_TOKEN} \
-d grant_type=refresh_token \
-d redirect_uri=urn:ietf:wg:oauth:2.0:oob | jq -r .access_token)
UPLOAD_RESPONSE=$(curl -s -w "%{http_code}" -o upload_output.json \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-X PUT -T tab-counter.zip \
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/${APP_ID}")
if [[ "$UPLOAD_RESPONSE" -ne 200 ]]; then
echo "❌ Upload failed:"
cat upload_output.json
exit 1
fi
PUBLISH_RESPONSE=$(curl -s -w "%{http_code}" -o publish_output.json \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-goog-api-version: 2" \
-H "Content-Length: 0" \
-X POST \
"https://www.googleapis.com/chromewebstore/v1.1/items/${APP_ID}/publish")
if [[ "$PUBLISH_RESPONSE" -ne 200 ]]; then
echo "❌ Publish failed:"
cat publish_output.json
exit 1
fi
workflows:
deploy:
jobs:
- build
Conclusion
That’s it! Your Chrome Extension now has a modern, automated deployment pipeline. With CircleCI handling packaging and publishing, you’ve eliminated manual steps and reduced the chance of deployment mistakes.
From here, you can iterate confidently, scale faster, and focus on building features instead of managing releases.
Happy shipping!