DEV Community

Hamza Hamidi
Hamza Hamidi

Posted on Originally published at hamidihamza.com

Publishing a Chrome extension from GitHub Actions without a stored secret

Publish to Chrome Web Store is a GitHub Action, at v1.1.0 since 26 September 2026, that uploads an extension through the Chrome Web Store API v2 and submits it for review. It takes a short-lived access token, so the recommended setup stores no secret in the repository, and it has no runtime dependencies. For items opted in to Verified CRX Uploads, a companion action signs the package in a separate job.

Google's v1.1 reference says the old API is deprecated and only supported until 15 October 2026. When I searched the GitHub Marketplace on 26 September, I found 22 actions that publish to the Chrome Web Store. The latest release of 16 of them called only v1.1, 5 called v2, and 1 called v2 only when given a publisher ID.

Why I built it

Yoke, my Chrome extension for driving a signed-in browser from an MCP client, had no CI upload on purpose. A comment in its release workflow said why: the chromewebstore OAuth scope covers every item of a publisher, so a leaked credential can push code through silent auto-update to every user of every extension on the account. Yoke holds debugger and <all_urls>. I uploaded each version by hand rather than keep a long-lived credential for that in a repository.

Most of the existing actions I looked at take an OAuth client ID, client secret and refresh token. That refresh token is the long-lived credential. Google expires it after 7 days while an External consent screen is in Testing status; publishing the consent screen stops the expiry, and Google does not require verification for an app only its owner uses. Either way, a secret that can publish every item of the publisher sits in the repository. cssnr/webstore-publish-action takes a bearer token or a service account key instead, but a service account key is a long-lived secret too.

The setup that stores nothing

Google Cloud can trust GitHub's OIDC tokens through Workload Identity Federation, and the Chrome Web Store accepts a service account linked to the publisher. Together they remove the stored credential:

tag push, approved in the chrome-web-store environment
    ↓  GitHub OIDC token (identifies repository, ref and environment)
Google Workload Identity provider
    ↓  checks: owner ID, repository ID, tag ref, environment
service account linked to the publisher
    ↓  30 minute access token, chromewebstore scope
publish-to-chrome-web-store
Enter fullscreen mode Exit fullscreen mode

The provider's condition for Yoke uses numeric IDs, so renaming the repository keeps it working and a new repository created under the old name cannot use it:

assertion.repository_owner_id == '22576950'
  && assertion.repository_id == '1342304725'
  && assertion.ref_type == 'tag'
  && assertion.environment == 'chrome-web-store'
Enter fullscreen mode Exit fullscreen mode

The environment has me as a required reviewer and only accepts v* tags. The two values the workflow needs, the provider path and the service account email, are identifiers and live as repository variables. It costs nothing to run: the Google Cloud project has no billing account and only holds the service account and a Workload Identity pool with one provider per repository. The one-time setup is eight steps in the README, six of them a command block to paste.

The publishing job only downloads the ZIP, gets the token and runs the action:

publish:
  needs: build
  runs-on: ubuntu-latest
  environment: chrome-web-store
  permissions:
    id-token: write
  concurrency:
    group: chrome-web-store
    cancel-in-progress: false
  steps:
    - uses: actions/download-artifact@v8
      with:
        name: extension
    - id: auth
      uses: google-github-actions/auth@v3
      with:
        workload_identity_provider: ${{ vars.CWS_WIF_PROVIDER }}
        service_account: ${{ vars.CWS_SERVICE_ACCOUNT }}
        token_format: access_token
        access_token_scopes: https://www.googleapis.com/auth/chromewebstore
        access_token_lifetime: 1800s
        create_credentials_file: false
        export_environment_variables: false
    - uses: hamzahamidi/publish-to-chrome-web-store@v1
      with:
        access-token: ${{ steps.auth.outputs.access_token }}
        publisher-id: your-publisher-id
        item-id: your-32-letter-extension-id
        zip: extension.zip
Enter fullscreen mode Exit fullscreen mode

The build runs in a separate job without id-token: write, so no build dependency ever runs next to the token.

Yoke 0.1.7 was the first release through it, on 26 September. The job got its token through the provider above, found 0.1.6 published and nothing in review, uploaded 0.1.7, submitted it, and confirmed that 0.1.7 was the version now in review.

What the store API does not tell you

The v2 API has four calls that matter here: fetchStatus, a media upload, publish and cancelSubmission. The action uses the first three, and much of its store code exists because of what they leave out.

There is no handle on the draft. fetchStatus reports the published revision, the submitted revision and the state of the last asynchronous upload. It does not say which package is sitting in the draft. And publish takes no version: it submits whatever was uploaded last. If two runs upload to the same item, the first one to call publish can submit the other run's package. The action reads the status again after submitting and fails if a different version went to review, but that is detection, not prevention. The README states one writer per item as a requirement, and the job above has a concurrency group so that two releases never upload at once.

A review blocks uploads. While a version is in review the store refuses new packages, and a version that is approved but staged blocks them too, until someone publishes or cancels it or the store reverts it to a draft 30 days after approval. The action checks both before uploading and stops with a message that says which version is blocking and what to do in the dashboard.

Versions only go up. An upload whose manifest version is not higher than the published one is rejected. The action compares versions the way Chrome does, with missing parts counting as zero, so 1.2.0 against a published 1.2 fails before any upload.

Uploads can be asynchronous. The upload call may answer IN_PROGRESS (Google's field descriptions call it UPLOAD_IN_PROGRESS, and the action accepts both), and fetchStatus then reports progress in lastAsyncUploadState, which the discovery document describes as set only for an asynchronous upload in the past 24 hours. The action polls it, and it also waits when an earlier run left an upload processing, instead of racing it. When the upload completes at once, the response carries the version the store read from the package, and the action refuses to submit if that differs from the ZIP it sent.

To try it on a real item without uploading anything, set dry-run: true: the action reads the ZIP and the item's status, reports what it would do, then stops. Before the first release I ran it with the service account against the live store. It reported Yoke 0.1.6 as published, a package at 0.1.7 as ready to upload, and refused 0.1.5 as not higher. With a wrong publisher ID the store answered HTTP 403 "Permission denied on resource", and the action added the likely causes: scope, API not enabled, account not linked, or a wrong publisher ID.

Signed uploads, if you opt in

Verified CRX Uploads go one step further: once an item is opted in on its Package tab, the store accepts only a CRX signed with the developer's RSA key. A leaked store token alone can then publish nothing. The feature is per item and optional.

Signing needs the private key, and putting that key in the job that holds the store token would give an attacker both at once. So the upload action never signs. A companion action, hamzahamidi/publish-to-chrome-web-store/sign@v1, runs in its own job, writes the CRX, and hands it over as an artifact. The key lives in a crx-signing environment secret, not a repository secret, because anyone with write access can read repository secrets from any branch. The provider only issues the store token to jobs in chrome-web-store, so the signing job can never get it:

sign:
  needs: build
  runs-on: ubuntu-latest
  environment: crx-signing
  permissions: {}
  steps:
    - uses: actions/download-artifact@v8
      with:
        name: extension
    - id: sign
      uses: hamzahamidi/publish-to-chrome-web-store/sign@v1
      with:
        zip: extension.zip
        private-key: ${{ secrets.CRX_PRIVATE_KEY }}
    - uses: actions/upload-artifact@v7
      with:
        name: extension-crx
        path: ${{ steps.sign.outputs.crx }}
Enter fullscreen mode Exit fullscreen mode

The publish job then waits on needs: sign, downloads that artifact and passes crx: extension.crx instead of zip, and the action sends it with the X-Goog-Upload-Protocol: raw and X-Goog-Upload-File-Name headers that Google's update page lists for CRX uploads.

A CRX3 is Cr24, a version, a header length, a protobuf header with the public key and an RSA SHA-256 signature, then the ZIP unchanged. The format is small enough to write in 50 lines of TypeScript with Node's crypto, and for the same archive and key the output is byte-identical to what Chrome's --pack-extension writes. A CI job checks that against google-chrome on every change.

For LabelQuick, a small extension of mine in a private repository, the signing key was generated in memory and piped straight into a secret, so no other copy exists. Its public half is committed, and the signing job fails unless the CRX was signed with it. If the secret is ever lost, Chrome Web Store support can register a new key, which Google says can take up to a week.

What runs and where the token goes

An action that holds a token able to publish every extension of a publisher has to be easy to audit:

  • Code. About 800 lines of TypeScript in src/ and sign/, importing only Node.js built-ins. Node 24 strips the types when it loads the files, so there is no bundle and no dist/: the runner executes what you read. TypeScript itself is a development dependency for the type check in CI.
  • Network. The README has a table of every request the action can make. Credentials go to chromewebstore.googleapis.com, and to oauth2.googleapis.com for the refresh token route. Redirects are refused rather than followed, because a 307 or 308 redirect would resend the form body, client secret included, to whatever host it names.
  • Logs. Every credential input is masked before the first log line, and so is a token minted from a refresh token. Text from the store is escaped before it is logged, so a response cannot start a workflow command such as ::add-mask:: or ::stop-commands::.
  • Tests. 140 tests run on Linux, Windows and macOS with a 95% line coverage floor. Separate CI jobs run the action from action.yml against a mock store, with a ZIP and with a CRX from the sign action, and compare sign with Chrome's packer.
  • Releases. Releases are immutable, and a workflow moves v1 to the newest 1.x.y release through the GitHub API.

A review before the first release caught a mistake in that last workflow. On a release event, GITHUB_SHA is the last commit of the tagged release, so a plain actions/checkout fetched the release commit, and the script that moved the tag with contents: write came from the release being processed. The workflow now checks out that script from the default branch, which only changes through pull requests with passing checks. The workflow file itself still runs from the tagged commit, as on every release event, so the real limit is who can publish a release: anyone with write access, which on this repository is only me.

Limits

  • A Google Cloud project is required whichever credential you use. Google only issues Chrome Web Store tokens to an OAuth client or a service account, and both belong to a project with the API enabled.
  • One service account per publisher, and it can manage every item of that publisher. Bind only repositories you control.
  • The API cannot create an item or change its visibility. After a visibility change in the dashboard, publish once by hand before the API can publish again.
  • The signed CRX path follows Google's documented headers and matches Chrome's packer byte for byte, but no opted-in item has received an upload through it yet. LabelQuick will be the first, once its current review ends and the opt-in button unlocks.

Try it

  • The Marketplace listing and the repository.
  • The README has the Google Cloud setup in eight steps, what each permission grants, and a FAQ.
  • Run it once with dry-run: true before the first real release.
  • For Verified CRX Uploads, opt the item in, store the RSA private key as a crx-signing environment secret, and add the sign job above.

Top comments (0)