Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/extension-cancel-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Chrome Web Store cancel review
run-name: Cancel Chrome review ${{ inputs.version }} by @${{ github.actor }}

on:
workflow_dispatch:
inputs:
version:
description: 'Version currently pending review to cancel, e.g. 1.0.1'
required: true
type: string

permissions:
contents: read

jobs:
cancel-review:
if: github.ref == 'refs/heads/trunk'
runs-on: ubuntu-latest
timeout-minutes: 5
concurrency:
group: chrome-web-store-publishing
cancel-in-progress: false
env:
CWS_PUBLISHER_ID: ${{ vars.CWS_PUBLISHER_ID }}
CWS_EXTENSION_ID: ${{ vars.CWS_EXTENSION_ID }}
CWS_CANCEL_VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@v7
- name: Validate requested version
run: |
python3 -c 'import os, runpy; runpy.run_path("scripts/chrome-store.py")["version_tuple"](os.environ["CWS_CANCEL_VERSION"])'
- id: auth
name: Authenticate with service account JSON
env:
CWS_SERVICE_ACCOUNT: ${{ vars.CWS_SERVICE_ACCOUNT }}
CWS_SERVICE_ACCOUNT_KEY: ${{ secrets.CWS_SERVICE_ACCOUNT_KEY }}
run: node scripts/chrome-store-auth.mjs
- name: Cancel matching pending review
env:
CWS_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
run: python3 scripts/chrome-store.py cancel
70 changes: 70 additions & 0 deletions .github/workflows/extension-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@ on:
pull_request:
paths:
- '.github/workflows/extension-package.yml'
- '.github/workflows/extension-cancel-review.yml'
- 'apps/extension/**'
- 'scripts/package-extension.ts'
- 'scripts/chrome-store.py'
- 'scripts/test_chrome_store.py'
- 'scripts/chrome-store-auth.mjs'
- 'scripts/chrome-store-auth.node-test.mjs'
- 'package.json'
- 'bun.lock'
workflow_dispatch:
inputs:
check_store_access:
description: 'Only check Chrome Web Store authentication (no build, upload, or publish)'
type: boolean
default: true
push:
tags: ['extension-v*']

Expand All @@ -17,6 +27,7 @@ permissions:

jobs:
package:
if: github.event_name != 'workflow_dispatch' || !inputs.check_store_access
runs-on: ubuntu-latest
env:
WXT_API_BASE: https://crrt.ai/api
Expand All @@ -28,6 +39,15 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Test publishing guards
run: |
python3 -m unittest discover -s scripts -p 'test_chrome_store.py' -v
node --test scripts/chrome-store-auth.node-test.mjs
- name: Verify extension release tag
if: github.event_name == 'push'
run: |
python3 scripts/chrome-store.py validate-tag
git merge-base --is-ancestor "$GITHUB_SHA" origin/trunk
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
Expand All @@ -50,3 +70,53 @@ jobs:
path: artifacts/
if-no-files-found: error
retention-days: 30

store:
needs: package
if: >-
always() && !cancelled() &&
((github.event_name == 'workflow_dispatch' && inputs.check_store_access &&
(github.ref == 'refs/heads/trunk' || github.ref == 'refs/heads/feat/chrome-store-publishing')) ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/extension-v') && needs.package.result == 'success'))
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
concurrency:
group: chrome-web-store-publishing
cancel-in-progress: false
env:
CWS_PUBLISHER_ID: ${{ vars.CWS_PUBLISHER_ID }}
CWS_EXTENSION_ID: ${{ vars.CWS_EXTENSION_ID }}
steps:
- uses: actions/checkout@v7
- name: Check publishing configuration
env:
CWS_SERVICE_ACCOUNT: ${{ vars.CWS_SERVICE_ACCOUNT }}
CWS_SERVICE_ACCOUNT_KEY: ${{ secrets.CWS_SERVICE_ACCOUNT_KEY }}
run: |
: "${CWS_PUBLISHER_ID:?Set CWS_PUBLISHER_ID in Actions variables}"
: "${CWS_EXTENSION_ID:?Set CWS_EXTENSION_ID in Actions variables}"
: "${CWS_SERVICE_ACCOUNT:?Set CWS_SERVICE_ACCOUNT in Actions variables}"
: "${CWS_SERVICE_ACCOUNT_KEY:?Set CWS_SERVICE_ACCOUNT_KEY in Actions secrets}"
- uses: actions/download-artifact@v4
if: github.event_name == 'push'
with:
name: crrt-extension-${{ github.ref_name }}
path: artifacts/
- id: auth
name: Authenticate with service account JSON
env:
CWS_SERVICE_ACCOUNT: ${{ vars.CWS_SERVICE_ACCOUNT }}
CWS_SERVICE_ACCOUNT_KEY: ${{ secrets.CWS_SERVICE_ACCOUNT_KEY }}
run: node scripts/chrome-store-auth.mjs
- name: Check store access
if: github.event_name == 'workflow_dispatch'
env:
CWS_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
run: python3 scripts/chrome-store.py status
- name: Upload and submit extension for review
if: github.event_name == 'push'
env:
CWS_ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
run: python3 scripts/chrome-store.py publish
2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@thedesignproject/crrt-extension",
"private": true,
"version": "1.0.0"
"version": "1.0.1"
}
3 changes: 2 additions & 1 deletion apps/extension/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ describe('extension Store release', () => {
})

it('reads the single package version source and rejects invalid Chrome versions', () => {
expect(readExtensionVersion()).toBe('1.0.0')
const packageVersion = JSON.parse(readFileSync('apps/extension/package.json', 'utf8')).version
expect(readExtensionVersion()).toBe(packageVersion)
const directory = mkdtempSync(join(tmpdir(), 'crrt-version-'))
const file = join(directory, 'package.json')
writeFileSync(file, JSON.stringify({ version: 'one' }))
Expand Down
4 changes: 2 additions & 2 deletions apps/extension/tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, expect, it, vi } from 'vitest'
import { existsSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
vi.mock('wxt', () => ({ defineConfig: (config: unknown) => config }))
import config, { extensionIcons, extensionManifest } from '../wxt.config'

Expand All @@ -17,7 +17,7 @@ it('builds the extension with its canonical icon and declared permissions', () =
for (const [name, value] of Object.entries(production)) vi.stubEnv(name, value)
expect((config.manifest as (environment: { mode: string }) => unknown)({ mode: 'production' })).toEqual(manifest)
expect(manifest).toMatchObject({
version: '1.0.0',
version: JSON.parse(readFileSync('apps/extension/package.json', 'utf8')).version,
permissions: ['activeTab', 'identity', 'scripting', 'storage'],
host_permissions: ['https://crrt.ai/*', 'https://project-ref.supabase.co/*'],
icons: extensionIcons,
Expand Down
157 changes: 157 additions & 0 deletions docs/extension-publishing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Chrome Web Store releases

The extension has its own version in `apps/extension/package.json`. Neither the
root npm package version nor `packages/crrt-storybook/package.json` controls it.
Only `extension-v*` tags submit an extension release. The npm packages keep their
existing `v*` and `crrt-storybook-v*` release triggers.

## One-time setup

The Chrome Web Store publisher must link
`crrt-service@crrt-512315.iam.gserviceaccount.com` under Account → Service account.
This has been configured for publisher `ca1d74b5-f59b-4c6a-8a37-af60ce24d29f` and
extension `kckiejblkmbmdappaofepmbnpbejomon`.

1. Enable the Chrome Web Store API in Google Cloud project `crrt-512315`.
2. Create a JSON key for the linked service account. Save the entire unchanged
JSON as repository Actions secret `CWS_SERVICE_ACCOUNT_KEY`. Never commit it.
Rotate the key in Google Cloud and replace this secret when needed.
3. Configure these repository Actions variables:

| Variable | Value |
| --- | --- |
| `CWS_PUBLISHER_ID` | `ca1d74b5-f59b-4c6a-8a37-af60ce24d29f` |
| `CWS_EXTENSION_ID` | `kckiejblkmbmdappaofepmbnpbejomon` |
| `CWS_SERVICE_ACCOUNT` | `crrt-service@crrt-512315.iam.gserviceaccount.com` |
| `CRRT_SUPABASE_URL` | Production Supabase project origin |

4. Set Actions secret `CRRT_SUPABASE_ANON_KEY` to the production Supabase
**publishable or anon key**. It is bundled into the extension; never use a
service-role or secret key. These Supabase settings are required by the
existing package builder, independently of publishing authentication.
5. Merge the workflow into `trunk`, then run **Extension package → Run workflow**
on `trunk` with **check_store_access** selected (the default). This checks
authentication and reports store status without building, uploading, or
submitting anything. Uncheck it to build an artifact without publishing.

The key is available only in the store jobs, which run on extension tag pushes,
manually dispatched checks on `trunk` or `feat/chrome-store-publishing`, or manual cancellations on `trunk`.
Pull request builds cannot use those jobs. Authentication signs an RS256 JWT in memory using Node's built-in crypto library,
exchanges it directly with Google's OAuth endpoint for a temporary token with
the Chrome Web Store scope, and masks that token in Actions logs. It does not
write the JSON key to disk. No federation or service account impersonation IAM
roles are needed. Store operations are serialized across versions.

Before merging, the existing packaging workflow can be dispatched from the
publishing feature branch for a read-only authentication check:

```bash
gh workflow run extension-package.yml \
--repo thedesignproject/CRRT \
--ref feat/chrome-store-publishing \
--field check_store_access=true
```

This skips the package job and executes only authentication and `fetchStatus`.
Find and watch its run with `gh run list --repo thedesignproject/CRRT --workflow
extension-package.yml --branch feat/chrome-store-publishing --event
workflow_dispatch`, then `gh run watch RUN_ID --repo thedesignproject/CRRT
--exit-status`. The feature branch exception does not enable cancellation or
publishing from branch pushes.

## Release

1. Increment only `apps/extension/package.json` (for example `1.0.0` → `1.0.1`),
commit the change, and merge it into `trunk` through the normal review flow.
2. Pull `trunk`, then create and push the matching tag:

```bash
git switch trunk
git pull --ff-only origin trunk
git tag extension-v1.0.1
git push origin extension-v1.0.1
```

The workflow rejects mismatched tags, non-Chrome version syntax, and release
commits outside `trunk`. It tests and builds the extension, validates its packaged
contents, and verifies the deterministic ZIP. The store job verifies the build's
commit, ZIP checksum, manifest checksum, and version, then queries all published
and submitted distribution channels. Equal or older versions fail before upload.
Numeric comparison treats `1.0.0` and `1.0.0.0` as the same version.

Upload processing must finish successfully before submission. Failed, unknown,
and timed-out upload states stop the job. Submission blocks on store warnings
and publishes automatically after Google's review approval. A successful CI
submission does not mean the release is already live.

The job does not automatically bump versions, replace tags, skip review, or
cancel pending submissions. If a request fails after upload or submission,
inspect the dashboard before retrying. A version already submitted or published
will be refused on rerun; prepare a newer version for the next release.

## Cancel a pending review with GitHub CLI

After `.github/workflows/extension-cancel-review.yml` is merged into `trunk`,
an authenticated GitHub user or agent with repository write access can dispatch
the **Chrome Web Store cancel review** workflow. It uses the existing service
account secret; no Chrome Web Store dashboard access is needed.

To cancel version `1.0.1` currently under review:

```bash
gh workflow run extension-cancel-review.yml \
--repo thedesignproject/CRRT \
--ref trunk \
--field version=1.0.1
```

Dispatching only queues the operation. Find the run whose title includes the
requested version and initiating GitHub user, then watch its numeric ID:

```bash
gh run list \
--repo thedesignproject/CRRT \
--workflow extension-cancel-review.yml \
--event workflow_dispatch \
--limit 10 \
--json databaseId,displayTitle,status,conclusion,url

gh run watch RUN_ID --repo thedesignproject/CRRT --exit-status
gh run view RUN_ID --repo thedesignproject/CRRT
```

Replace `RUN_ID` with the selected run's `databaseId`. If it fails, inspect the
failed step with:

```bash
gh run view RUN_ID --repo thedesignproject/CRRT --log-failed
```

The cancellation runs only on `trunk` and uses the same concurrency group as
publishing, so those store jobs do not overlap. It checks that a submission is
still `PENDING_REVIEW` and that every submitted channel matches the supplied
version, calls Google's `cancelSubmission` endpoint, then fetches the resulting
status. A successful run records cancellation and the final status in its job
summary. Missing reviews, different versions, API errors, and unconfirmed
cancellations fail the run. Already published versions cannot be cancelled.
The action does not upload or submit a replacement.

After cancellation succeeds, merge the replacement changes with an incremented
extension version (for example `1.0.2`) and push `extension-v1.0.2` through the
normal release flow. Agents should check the cancellation run's conclusion
before proceeding with that replacement release.

The same action is available in GitHub's UI under **Actions → Chrome Web Store
cancel review → Run workflow**: select `trunk`, enter the pending version, and
run it.

## Local verification

```bash
python3 -m unittest discover -s scripts -p 'test_chrome_store.py' -v
node --test scripts/chrome-store-auth.node-test.mjs
```

References: [Chrome service accounts](https://developer.chrome.com/docs/webstore/service-accounts),
[Google service account OAuth](https://developers.google.com/identity/protocols/oauth2/service-account),
[Chrome API v2](https://developer.chrome.com/docs/webstore/using-api).
47 changes: 47 additions & 0 deletions scripts/chrome-store-auth.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createSign } from 'node:crypto'
import { appendFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'

const tokenUrl = 'https://oauth2.googleapis.com/token'
const scope = 'https://www.googleapis.com/auth/chromewebstore'

// Exchange a locally signed JWT directly with Google's OAuth endpoint. This
// needs no federation, impersonation, or extra Token Creator project/IAM roles.
export async function getAccessToken(credentials, expectedEmail, request = fetch, now = Date.now()) {
if (credentials.type !== 'service_account' || !expectedEmail || credentials.client_email !== expectedEmail) {
throw new Error('Secret must contain the configured service account JSON key')
}
const issuedAt = Math.floor(now / 1000)
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url')
const message = `${encode({ alg: 'RS256', typ: 'JWT', kid: credentials.private_key_id })}.${encode({
iss: credentials.client_email, scope, aud: tokenUrl, iat: issuedAt, exp: issuedAt + 3600,
})}`
const signature = createSign('RSA-SHA256').update(message).sign(credentials.private_key, 'base64url')
const response = await request(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${message}.${signature}`,
}),
signal: AbortSignal.timeout(30000),
})
if (!response.ok) throw new Error(`Google OAuth token exchange failed (HTTP ${response.status})`)
const result = await response.json()
if (typeof result.access_token !== 'string' || !/^[\x21-\x7e]+$/.test(result.access_token)) {
throw new Error('Google OAuth response did not contain a valid access token')
}
return result.access_token
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const token = await getAccessToken(JSON.parse(process.env.CWS_SERVICE_ACCOUNT_KEY), process.env.CWS_SERVICE_ACCOUNT)
console.log(`::add-mask::${token}`)
appendFileSync(process.env.GITHUB_OUTPUT, `access_token=${token}\n`)
} catch {
// Parser/crypto exceptions may include key material. Keep credentials out of logs.
console.error('Service account authentication failed. Check the JSON secret, configured account, key validity, and Chrome Web Store API enablement.')
process.exitCode = 1
}
}
Loading
Loading