diff --git a/.github/workflows/extension-cancel-review.yml b/.github/workflows/extension-cancel-review.yml new file mode 100644 index 00000000..bac805a4 --- /dev/null +++ b/.github/workflows/extension-cancel-review.yml @@ -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 diff --git a/.github/workflows/extension-package.yml b/.github/workflows/extension-package.yml index bf22f9a7..33ed2cd4 100644 --- a/.github/workflows/extension-package.yml +++ b/.github/workflows/extension-package.yml @@ -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*'] @@ -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 @@ -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 @@ -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 diff --git a/apps/extension/package.json b/apps/extension/package.json index 4736be4a..77753f41 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,5 +1,5 @@ { "name": "@thedesignproject/crrt-extension", "private": true, - "version": "1.0.0" + "version": "1.0.1" } diff --git a/apps/extension/release.test.ts b/apps/extension/release.test.ts index a56754cb..afcab198 100644 --- a/apps/extension/release.test.ts +++ b/apps/extension/release.test.ts @@ -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' })) diff --git a/apps/extension/tests/config.test.ts b/apps/extension/tests/config.test.ts index ea51dc08..790f76cf 100644 --- a/apps/extension/tests/config.test.ts +++ b/apps/extension/tests/config.test.ts @@ -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' @@ -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, diff --git a/docs/extension-publishing.md b/docs/extension-publishing.md new file mode 100644 index 00000000..c4ba21dc --- /dev/null +++ b/docs/extension-publishing.md @@ -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). diff --git a/scripts/chrome-store-auth.mjs b/scripts/chrome-store-auth.mjs new file mode 100644 index 00000000..0f75d248 --- /dev/null +++ b/scripts/chrome-store-auth.mjs @@ -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 + } +} diff --git a/scripts/chrome-store-auth.node-test.mjs b/scripts/chrome-store-auth.node-test.mjs new file mode 100644 index 00000000..5abfd3fc --- /dev/null +++ b/scripts/chrome-store-auth.node-test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { generateKeyPairSync, verify } from 'node:crypto' +import { test } from 'node:test' +import { getAccessToken } from './chrome-store-auth.mjs' + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const email = 'test@example.iam.gserviceaccount.com' +const credentials = { + type: 'service_account', client_email: email, private_key_id: 'test-key', + private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }), +} + +test('signs and exchanges a Chrome-scoped JWT without trusting JSON endpoint URLs', async () => { + const token = await getAccessToken({ ...credentials, token_uri: 'https://untrusted.invalid' }, email, async (url, options) => { + assert.equal(url, 'https://oauth2.googleapis.com/token') + assert.equal(options.method, 'POST') + assert.equal(options.body.get('grant_type'), 'urn:ietf:params:oauth:grant-type:jwt-bearer') + const [header, payload, signature] = options.body.get('assertion').split('.') + assert.deepEqual(JSON.parse(Buffer.from(header, 'base64url')), { alg: 'RS256', typ: 'JWT', kid: 'test-key' }) + assert.deepEqual(JSON.parse(Buffer.from(payload, 'base64url')), { + iss: email, scope: 'https://www.googleapis.com/auth/chromewebstore', + aud: url, iat: 1000, exp: 4600, + }) + assert.equal(verify('RSA-SHA256', Buffer.from(`${header}.${payload}`), publicKey, Buffer.from(signature, 'base64url')), true) + return { ok: true, json: async () => ({ access_token: 'test-token' }) } + }, 1000000) + assert.equal(token, 'test-token') +}) + +test('rejects incorrect credentials before sending a request', async () => { + for (const candidate of [{ ...credentials, type: 'authorized_user' }, { ...credentials, client_email: 'other' }]) { + await assert.rejects(getAccessToken(candidate, email, () => assert.fail('must not call Google')), /configured service account/) + } + await assert.rejects(getAccessToken(credentials, ''), /configured service account/) +}) + +test('propagates token exchange failures without returning a token', async () => { + await assert.rejects(getAccessToken(credentials, email, async () => ({ ok: false, status: 401 })), /HTTP 401/) + await assert.rejects(getAccessToken(credentials, email, async () => { throw new Error('network failure') }), /network failure/) + for (const access_token of [undefined, '', 'bad\ninjection']) { + await assert.rejects(getAccessToken(credentials, email, async () => ({ ok: true, json: async () => ({ access_token }) })), /valid access token/) + } +}) diff --git a/scripts/chrome-store.py b/scripts/chrome-store.py new file mode 100644 index 00000000..00d36320 --- /dev/null +++ b/scripts/chrome-store.py @@ -0,0 +1,175 @@ +"""Chrome Web Store v2 releases. Uses only Python's standard library.""" +import hashlib +import json +import os +from pathlib import Path +import re +import sys +import time +import urllib.error +import urllib.request +import zipfile + + +def version_tuple(value): + # Chrome accepts 1–4 numeric components, each 0–65535, without leading zeros. + if not isinstance(value, str) or not re.fullmatch(r'(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)){0,3}', value): + raise ValueError(f'Invalid Chrome version: {value!r}') + parts = tuple(map(int, value.split('.'))) + if max(parts) > 65535 or not any(parts): + raise ValueError(f'Invalid Chrome version: {value!r}') + return parts + (0,) * (4 - len(parts)) + + +def validate_tag(version, ref): + version_tuple(version) + if ref != f'refs/tags/extension-v{version}': + raise ValueError(f'Release tag must be extension-v{version}, matching apps/extension/package.json') + + +def require_new_version(version, status): + for field in ('publishedItemRevisionStatus', 'submittedItemRevisionStatus'): + revision = status.get(field) + if revision: + channels = revision.get('distributionChannels', []) + if not channels: + raise ValueError(f'Store returned {field} without versions; refusing upload') + for channel in channels: + previous = channel.get('crxVersion') + if not previous: + raise ValueError(f'Store returned {field} without a version; refusing upload') + if version_tuple(version) <= version_tuple(previous): + raise ValueError(f'Extension {version} must be newer than store version {previous} ({field})') + + +def read_artifact(root, version, commit): + path = root / f'crrt-extension-{version}-chrome.zip' + metadata = json.loads((root / f'crrt-extension-{version}-release.json').read_text()) + payload = path.read_bytes() + if metadata['version'] != version or metadata['commit'] != commit: + raise ValueError('Release metadata does not match the tagged version and commit') + if hashlib.sha256(payload).hexdigest() != metadata['zipSha256']: + raise ValueError('Release ZIP checksum does not match validated build') + with zipfile.ZipFile(path) as archive: + manifest = archive.read('manifest.json') + if json.loads(manifest)['version'] != version: + raise ValueError('ZIP manifest version does not match release tag') + if hashlib.sha256(manifest).hexdigest() != metadata['manifestSha256']: + raise ValueError('ZIP manifest checksum does not match validated build') + return payload + + +class Store: + def __init__(self): + publisher = os.environ['CWS_PUBLISHER_ID'] + extension = os.environ['CWS_EXTENSION_ID'] + if not re.fullmatch(r'[a-zA-Z0-9-]+', publisher) or not re.fullmatch(r'[a-p]{32}', extension): + raise ValueError('Invalid publisher or extension ID') + self.item = f'publishers/{publisher}/items/{extension}' + self.token = os.environ['CWS_ACCESS_TOKEN'] + + def request(self, operation, payload=None, upload=False): + prefix = 'upload/v2' if upload else 'v2' + url = f'https://chromewebstore.googleapis.com/{prefix}/{self.item}:{operation}' + headers = {'Authorization': f'Bearer {self.token}'} + if payload is not None: + headers['Content-Type'] = 'application/zip' if upload else 'application/json' + method = 'GET' if operation == 'fetchStatus' else 'POST' + request = urllib.request.Request(url, data=payload, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=120) as response: + body = response.read() + if operation == 'cancelSubmission' and not body.strip(): + return {} + return json.loads(body) + except urllib.error.HTTPError as error: + # Google's structured message explains disabled APIs vs publisher + # access failures. Never log request headers or the access token. + try: + detail = json.loads(error.read()).get('error', {}).get('message', '') + except (ValueError, AttributeError): + detail = '' + if not isinstance(detail, str): + detail = '' + detail = detail.replace(self.token, '[redacted]') if self.token else detail + raise ValueError(f'Chrome Web Store HTTP {error.code}: {detail[:2000] or error.reason}') from None + + +def cancel_review(store, expected_version): + expected = version_tuple(expected_version) + status = store.request('fetchStatus') + submitted = status.get('submittedItemRevisionStatus') or {} + if submitted.get('state') != 'PENDING_REVIEW': + raise ValueError('There is no pending review to cancel; fetch store status before retrying') + channels = submitted.get('distributionChannels', []) + if not channels or any(version_tuple(channel.get('crxVersion')) != expected for channel in channels): + raise ValueError(f'Pending review does not match requested version {expected_version}; refusing cancellation') + store.request('cancelSubmission') + status = store.request('fetchStatus') + submitted = status.get('submittedItemRevisionStatus') or {} + if submitted and submitted.get('state') != 'CANCELLED': + raise ValueError('Cancellation requested but not confirmed; check store status before retrying') + published = status.get('publishedItemRevisionStatus') or {} + if any(version_tuple(channel.get('crxVersion')) == expected + for channel in published.get('distributionChannels', [])): + raise ValueError('Requested version is already published; cancellation cannot undo publication') + return status + + +def publish(store, payload, version, sleep=time.sleep): + # Recheck inside the serialized publishing job, immediately before upload. + require_new_version(version, store.request('fetchStatus')) + result = store.request('upload', payload, upload=True) + state = result.get('uploadState') + for _ in range(30): + if state == 'SUCCEEDED': + break + if state != 'IN_PROGRESS': + raise ValueError(f'Upload failed or returned an unknown state: {state}') + sleep(10) + state = store.request('fetchStatus').get('lastAsyncUploadState') + if state != 'SUCCEEDED': + raise ValueError('Upload processing timed out; inspect the store before retrying') + result = store.request('publish', json.dumps({ + 'publishType': 'DEFAULT_PUBLISH', 'blockOnWarnings': True, + }).encode()) + if not result.get('name') or not result.get('state'): + raise ValueError(f'Unexpected publish response; inspect the store: {json.dumps(result)}') + return result + + +def main(): + mode = sys.argv[1] + if mode == 'status': + print(json.dumps(Store().request('fetchStatus'), indent=2)) + return + if mode == 'cancel': + version = os.environ['CWS_CANCEL_VERSION'] + status = cancel_review(Store(), version) + message = f'Cancelled pending Chrome Web Store review for {version}.' + print(message) + print(json.dumps(status, indent=2)) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: + summary.write(f'{message}\n\nCurrent store status:\n\n```json\n{json.dumps(status, indent=2)}\n```\n') + return + if mode not in ('validate-tag', 'publish'): + raise ValueError('Expected validate-tag, status, publish, or cancel') + version = json.loads(Path('apps/extension/package.json').read_text())['version'] + validate_tag(version, os.environ.get('GITHUB_REF', '')) + if mode == 'validate-tag': + print(f'Validated independent extension release: {version}') + return + payload = read_artifact(Path('artifacts'), version, os.environ['GITHUB_SHA']) + result = publish(Store(), payload, version) + print(json.dumps(result, indent=2)) + print('Submission accepted. Chrome Web Store review and approval control when it goes live.') + + +if __name__ == '__main__': + try: + main() + except (ValueError, KeyError, OSError, zipfile.BadZipFile) as error: + # Do not dump request headers or credentials into Actions logs. + print(f'Chrome Web Store release failed: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/test_chrome_store.py b/scripts/test_chrome_store.py new file mode 100644 index 00000000..09ebf829 --- /dev/null +++ b/scripts/test_chrome_store.py @@ -0,0 +1,234 @@ +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch +import zipfile + +spec = importlib.util.spec_from_file_location('chrome_store', Path(__file__).with_name('chrome-store.py')) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +def revision(*versions): + return {'distributionChannels': [{'crxVersion': value} for value in versions]} + + +class ReleaseTests(unittest.TestCase): + def test_google_api_error_details_are_reported_without_credentials(self): + with patch.dict(os.environ, {'CWS_PUBLISHER_ID': 'publisher', 'CWS_EXTENSION_ID': 'a' * 32, + 'CWS_ACCESS_TOKEN': 'test-token'}): + store = release.Store() + for body, expected in ( + (b'{"error":{"message":"API disabled test-token"}}', 'API disabled [redacted]'), + (b'not-json', 'Forbidden'), + (b'{"error":{"message":null}}', 'Forbidden'), + ): + error = release.urllib.error.HTTPError('https://example.invalid', 403, 'Forbidden', {}, io.BytesIO(body)) + with self.subTest(body=body), patch.object(release.urllib.request, 'urlopen', side_effect=error): + with self.assertRaises(ValueError) as raised: + store.request('fetchStatus') + self.assertIn(expected, str(raised.exception)) + self.assertNotIn('test-token', str(raised.exception)) + + def test_cancel_matching_pending_review_and_fetch_final_status(self): + store = Mock() + final_status = {'submittedItemRevisionStatus': {'state': 'CANCELLED'}} + store.request.side_effect = [ + {'submittedItemRevisionStatus': {**revision('1.0.1'), 'state': 'PENDING_REVIEW'}}, + {}, final_status, + ] + self.assertEqual(release.cancel_review(store, '1.0.1'), final_status) + self.assertEqual([call.args for call in store.request.call_args_list], + [('fetchStatus',), ('cancelSubmission',), ('fetchStatus',)]) + + def test_cancel_refuses_absent_review_or_other_states(self): + for state in (None, 'PUBLISHED', 'STAGED', 'REJECTED', 'CANCELLED'): + store = Mock() + store.request.return_value = {'submittedItemRevisionStatus': {**revision('1.0.1'), 'state': state}} + with self.subTest(state=state), self.assertRaisesRegex(ValueError, 'no pending review'): + release.cancel_review(store, '1.0.1') + store.request.assert_called_once_with('fetchStatus') + store = Mock() + store.request.return_value = {} + with self.assertRaises(ValueError): + release.cancel_review(store, '1.0.1') + store.request.assert_called_once_with('fetchStatus') + + def test_cancel_refuses_different_or_missing_version(self): + for channels in ([], [{'crxVersion': '1.0.2'}], [{}], + [{'crxVersion': '1.0.1'}, {'crxVersion': '1.0.2'}]): + store = Mock() + store.request.return_value = {'submittedItemRevisionStatus': { + 'state': 'PENDING_REVIEW', 'distributionChannels': channels, + }} + with self.subTest(channels=channels), self.assertRaises(ValueError): + release.cancel_review(store, '1.0.1') + store.request.assert_called_once_with('fetchStatus') + + def test_cancel_validates_input_before_api_access(self): + store = Mock() + with self.assertRaises(ValueError): + release.cancel_review(store, 'not-a-version') + store.request.assert_not_called() + + def test_cancel_propagates_api_errors_and_does_not_report_pending_as_success(self): + pending = {'submittedItemRevisionStatus': {**revision('1.0.1'), 'state': 'PENDING_REVIEW'}} + for responses in ([pending, OSError('API unavailable')], [pending, {}, pending]): + store = Mock() + store.request.side_effect = responses + with self.subTest(responses=responses), self.assertRaises((OSError, ValueError)): + release.cancel_review(store, '1.0.1') + + def test_cancel_posts_empty_body_and_accepts_empty_response(self): + with patch.dict(os.environ, {'CWS_PUBLISHER_ID': 'publisher', 'CWS_EXTENSION_ID': 'a' * 32, + 'CWS_ACCESS_TOKEN': 'test-token'}): + store = release.Store() + for body in (b'', b'{}'): + with self.subTest(body=body), patch.object(release.urllib.request, 'urlopen') as send: + send.return_value.__enter__.return_value.read.return_value = body + self.assertEqual(store.request('cancelSubmission'), {}) + request = send.call_args.args[0] + self.assertEqual(request.get_method(), 'POST') + self.assertIsNone(request.data) + self.assertTrue(request.full_url.endswith(':cancelSubmission')) + + def test_cancel_does_not_claim_success_when_approval_wins_the_race(self): + store = Mock() + store.request.side_effect = [ + {'submittedItemRevisionStatus': {**revision('1.0.1'), 'state': 'PENDING_REVIEW'}}, + {}, {'publishedItemRevisionStatus': revision('1.0.1')}, + ] + with self.assertRaisesRegex(ValueError, 'already published'): + release.cancel_review(store, '1.0.1') + + def test_cancel_accepts_removed_submission_after_success(self): + store = Mock() + final_status = {'publishedItemRevisionStatus': revision('1.0.0')} + store.request.side_effect = [ + {'submittedItemRevisionStatus': {**revision('1.0.1'), 'state': 'PENDING_REVIEW'}}, + {}, final_status, + ] + self.assertEqual(release.cancel_review(store, '1.0.1'), final_status) + + def test_store_uses_v2_endpoints_and_expected_request_methods(self): + env = {'CWS_PUBLISHER_ID': 'publisher-id', 'CWS_EXTENSION_ID': 'a' * 32, + 'CWS_ACCESS_TOKEN': 'test-token'} + with patch.dict(os.environ, env): + store = release.Store() + for operation, payload, upload, method, prefix in ( + ('fetchStatus', None, False, 'GET', 'v2'), + ('upload', b'zip', True, 'POST', 'upload/v2'), + ('publish', b'{}', False, 'POST', 'v2'), + ): + with self.subTest(operation=operation), patch.object(release.urllib.request, 'urlopen') as send: + send.return_value.__enter__.return_value.read.return_value = b'{"ok":true}' + self.assertEqual(store.request(operation, payload, upload), {'ok': True}) + request = send.call_args.args[0] + self.assertEqual(request.full_url, f'https://chromewebstore.googleapis.com/{prefix}/{store.item}:{operation}') + self.assertEqual(request.get_method(), method) + self.assertEqual(request.get_header('Authorization'), 'Bearer test-token') + self.assertEqual(request.data, payload) + + def test_unexpected_submission_response_fails(self): + store = Mock() + store.request.side_effect = [{}, {'uploadState': 'SUCCEEDED'}, {}] + with self.assertRaisesRegex(ValueError, 'Unexpected publish response'): + release.publish(store, b'zip', '1.0.0') + + def test_independent_extension_tag(self): + release.validate_tag('1.2.3', 'refs/tags/extension-v1.2.3') + for ref in ('refs/tags/extension-v1.2.4', 'refs/tags/v1.2.3', + 'refs/tags/crrt-storybook-v1.2.3', 'refs/heads/trunk'): + with self.subTest(ref=ref), self.assertRaises(ValueError): + release.validate_tag('1.2.3', ref) + + def test_chrome_numeric_version_rules(self): + self.assertEqual(release.version_tuple('1.2.3'), release.version_tuple('1.2.3.0')) + self.assertGreater(release.version_tuple('1.10.0'), release.version_tuple('1.9.9')) + for value in ('0', '0.0.0', '01.2.3', '1.2.65536', '1.2.3-beta', '1.2.3.4.5', None): + with self.subTest(value=value), self.assertRaises(ValueError): + release.version_tuple(value) + + def test_checks_all_store_channels_and_pending_submission(self): + release.require_new_version('1.10.0', {'publishedItemRevisionStatus': revision('1.9.9')}) + release.require_new_version('1.0.0', {}) + for status in ( + {'publishedItemRevisionStatus': revision('1.0.0')}, + {'publishedItemRevisionStatus': revision('1.0.0.0')}, + {'publishedItemRevisionStatus': revision('0.9', '1.1')}, + {'submittedItemRevisionStatus': revision('1.1')}, + {'publishedItemRevisionStatus': {'state': 'PUBLISHED'}}, + {'publishedItemRevisionStatus': {'distributionChannels': [{}]}}, + ): + with self.subTest(status=status), self.assertRaises(ValueError): + release.require_new_version('1.0.0', status) + + def test_duplicate_version_never_uploads(self): + store = Mock() + store.request.return_value = {'publishedItemRevisionStatus': revision('1.0.0')} + with self.assertRaises(ValueError): + release.publish(store, b'zip', '1.0.0') + store.request.assert_called_once_with('fetchStatus') + + def test_waits_for_upload_before_submitting(self): + store = Mock() + store.request.side_effect = [{}, {'uploadState': 'IN_PROGRESS'}, + {'lastAsyncUploadState': 'SUCCEEDED'}, + {'name': 'publishers/p/items/i', 'state': 'PENDING_REVIEW'}] + sleep = Mock() + result = release.publish(store, b'zip', '1.0.0', sleep) + self.assertEqual(result['state'], 'PENDING_REVIEW') + sleep.assert_called_once_with(10) + calls = store.request.call_args_list + self.assertEqual([call.args[0] for call in calls], ['fetchStatus', 'upload', 'fetchStatus', 'publish']) + self.assertEqual(json.loads(calls[-1].args[1]), {'publishType': 'DEFAULT_PUBLISH', 'blockOnWarnings': True}) + + def test_failed_or_unknown_upload_never_publishes(self): + for state in ('FAILED', None, 'NOT_FOUND'): + store = Mock() + store.request.side_effect = [{}, {'uploadState': state}] + with self.subTest(state=state), self.assertRaises(ValueError): + release.publish(store, b'zip', '1.0.0') + self.assertEqual(store.request.call_count, 2) + + def test_upload_timeout_never_publishes(self): + store = Mock() + store.request.side_effect = [{}, {'uploadState': 'IN_PROGRESS'}] + [ + {'lastAsyncUploadState': 'IN_PROGRESS'}] * 30 + with self.assertRaisesRegex(ValueError, 'timed out'): + release.publish(store, b'zip', '1.0.0', Mock()) + self.assertNotIn('publish', [call.args[0] for call in store.request.call_args_list]) + + def test_api_failure_stops_release(self): + store = Mock() + store.request.side_effect = OSError('API unavailable') + with self.assertRaises(OSError): + release.publish(store, b'zip', '1.0.0') + self.assertEqual(store.request.call_count, 1) + + def test_artifact_integrity_and_version(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / 'crrt-extension-1.0.0-chrome.zip' + manifest = b'{"version":"1.0.0"}' + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('manifest.json', manifest) + metadata = {'version': '1.0.0', 'commit': 'commit', + 'zipSha256': hashlib.sha256(path.read_bytes()).hexdigest(), + 'manifestSha256': hashlib.sha256(manifest).hexdigest()} + metadata_path = root / 'crrt-extension-1.0.0-release.json' + metadata_path.write_text(json.dumps(metadata)) + self.assertEqual(release.read_artifact(root, '1.0.0', 'commit'), path.read_bytes()) + for field in ('version', 'commit', 'zipSha256', 'manifestSha256'): + metadata_path.write_text(json.dumps({**metadata, field: 'wrong'})) + with self.subTest(field=field), self.assertRaises(ValueError): + release.read_artifact(root, '1.0.0', 'commit') + + +if __name__ == '__main__': + unittest.main()