From 6bc3c293ee5d5ddea999a12286062b8a0461f29f Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 4 Jul 2026 19:08:32 +0530 Subject: [PATCH 1/4] ci(cws): monitor Chrome Web Store status --- .github/workflows/chrome-web-store-status.yml | 50 +++++ docs/PUBLICATION_READINESS.md | 2 + docs/RELEASE.md | 15 ++ package.json | 1 + scripts/check-chrome-web-store-status.d.mts | 35 ++++ scripts/check-chrome-web-store-status.mjs | 197 ++++++++++++++++++ scripts/publish-chrome-web-store.d.mts | 7 + scripts/publish-chrome-web-store.mjs | 19 ++ tests/extension/ci-workflow.test.ts | 16 ++ .../check-chrome-web-store-status.test.ts | 183 ++++++++++++++++ 10 files changed, 525 insertions(+) create mode 100644 .github/workflows/chrome-web-store-status.yml create mode 100644 scripts/check-chrome-web-store-status.d.mts create mode 100644 scripts/check-chrome-web-store-status.mjs create mode 100644 tests/scripts/check-chrome-web-store-status.test.ts diff --git a/.github/workflows/chrome-web-store-status.yml b/.github/workflows/chrome-web-store-status.yml new file mode 100644 index 0000000..fcc9457 --- /dev/null +++ b/.github/workflows/chrome-web-store-status.yml @@ -0,0 +1,50 @@ +name: Chrome Web Store Status + +on: + workflow_dispatch: + inputs: + expected_version: + description: Expected extension version. Defaults to package.json. + required: false + default: "" + require_published: + description: Fail unless the expected version is published. + required: true + default: "false" + type: choice + options: + - "false" + - "true" + schedule: + - cron: "17 */6 * * *" + +permissions: + contents: read + +jobs: + status: + name: Check Chrome Web Store status + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: chrome-web-store + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22.13.0 + + - name: Check Chrome Web Store status + run: node scripts/check-chrome-web-store-status.mjs + env: + CWS_EXTENSION_ID: nfnbhekccajjfgkppolomflaeledoccb + CWS_EXPECTED_VERSION: ${{ inputs.expected_version }} + CWS_REQUIRE_PUBLISHED: ${{ inputs.require_published || 'false' }} + CWS_PUBLISHER_ID: ${{ vars.CWS_PUBLISHER_ID }} + CWS_SERVICE_ACCOUNT_JSON: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }} + CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} + CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} + CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} diff --git a/docs/PUBLICATION_READINESS.md b/docs/PUBLICATION_READINESS.md index 4f1a802..dcc0b82 100644 --- a/docs/PUBLICATION_READINESS.md +++ b/docs/PUBLICATION_READINESS.md @@ -99,6 +99,8 @@ stable-release claims. - [x] Manifest icons are present in source and verified in the built package. - [x] Manifest homepage URL points to `https://pack.complyeaze.com/gst`. - [x] Protected Chrome Web Store workflow exists for future release updates. +- [x] Protected Chrome Web Store status monitor exists for post-submit + review/publication checks without upload or publish side effects. ### Must Complete Before Future Store Updates Or Broader Store Claims diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 83dccf4..18265c0 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -144,6 +144,13 @@ script runs. The publish script must receive the matching downloaded release version instead of the workflow checkout's `package.json` version. +Use `Chrome Web Store Status` after a submit run. It calls the Chrome Web Store +API `fetchStatus` endpoint with the same protected environment credentials, +prints a bounded status summary, and fails on rejected or failed states. By +default it succeeds while the expected version is submitted but still pending +review; dispatch it with `require_published=true` when final publication, not +just submission, is the release gate. + For local dry-runs against a generated release package: ```sh @@ -154,6 +161,14 @@ node scripts/publish-chrome-web-store.mjs \ --dry-run true ``` +For a local status check: + +```sh +node scripts/check-chrome-web-store-status.mjs \ + --publisher-id \ + --expected-version +``` + Do not move listing/support/homepage URLs in the Chrome dashboard without updating `src/extension/manifest-policy.ts`, this runbook, and the public Pack site. diff --git a/package.json b/package.json index a56ed4b..0ac74ac 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "release:provenance": "node scripts/write-release-provenance.mjs", "release:verify-assets": "node scripts/verify-github-release-assets.mjs", "release:chrome-web-store": "node scripts/publish-chrome-web-store.mjs", + "release:chrome-web-store-status": "node scripts/check-chrome-web-store-status.mjs", "store:assets": "node scripts/export-chrome-web-store-assets.mjs", "release:check-pr-title": "node scripts/check-conventional-pr-title.mjs", "verify:clean": "node scripts/assert-clean-worktree.mjs", diff --git a/scripts/check-chrome-web-store-status.d.mts b/scripts/check-chrome-web-store-status.d.mts new file mode 100644 index 0000000..d742005 --- /dev/null +++ b/scripts/check-chrome-web-store-status.d.mts @@ -0,0 +1,35 @@ +export interface CheckChromeWebStoreStatusOptions { + argv?: string[]; + cwd?: string; + env?: Record; + fetchImpl?: (url: string, init?: RequestInit) => Promise; + write?: (line: string) => void; +} + +export interface ChromeWebStoreStatusSummary { + extensionId: string; + publisherId: string | null; + expectedVersion: string | null; + submittedVersion: string | null; + publishedVersion: string | null; + latestObservedVersion: string | null; + states: string[]; + expectedSubmitted: boolean | null; + expectedPublished: boolean | null; + pendingReview: boolean; + published: boolean; + failed: boolean; +} + +export function checkChromeWebStoreStatus( + options?: CheckChromeWebStoreStatusOptions, +): Promise; + +export function summarizeChromeWebStoreStatus( + status: Record, + options?: { + extensionId?: string; + expectedVersion?: string; + publisherId?: string | null; + }, +): ChromeWebStoreStatusSummary; diff --git a/scripts/check-chrome-web-store-status.mjs b/scripts/check-chrome-web-store-status.mjs new file mode 100644 index 0000000..89922f4 --- /dev/null +++ b/scripts/check-chrome-web-store-status.mjs @@ -0,0 +1,197 @@ +/* global fetch */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { fetchChromeWebStoreStatus } from "./publish-chrome-web-store.mjs"; + +const DEFAULT_EXTENSION_ID = "nfnbhekccajjfgkppolomflaeledoccb"; +const FAILURE_STATES = new Set(["FAILED", "FAILURE", "REJECTED", "REJECTED_FOR_POLICY"]); +const PENDING_STATES = new Set([ + "IN_REVIEW", + "PENDING", + "PENDING_REVIEW", + "PENDING_REVIEW_PUBLISH", + "SUBMITTED", +]); +const PUBLISHED_STATES = new Set(["OK", "PUBLISHED", "PUBLIC", "LIVE"]); + +export async function checkChromeWebStoreStatus({ + argv = process.argv.slice(2), + cwd = process.cwd(), + env = process.env, + fetchImpl = fetch, + write = console.log, +} = {}) { + const args = parseArgs(argv); + const extensionId = args.extensionId ?? env.CWS_EXTENSION_ID ?? DEFAULT_EXTENSION_ID; + const expectedVersion = + nonEmptyString(args.expectedVersion) ?? + nonEmptyString(env.CWS_EXPECTED_VERSION) ?? + (await readPackageVersion(cwd)); + const requirePublished = + parseOptionalBoolean(args.requirePublished ?? env.CWS_REQUIRE_PUBLISHED, "requirePublished") ?? + false; + + const status = await fetchChromeWebStoreStatus({ + extensionId, + publisherId: args.publisherId ?? env.CWS_PUBLISHER_ID, + env, + fetchImpl, + }); + const summary = summarizeChromeWebStoreStatus(status, { + extensionId, + expectedVersion, + publisherId: args.publisherId ?? env.CWS_PUBLISHER_ID ?? null, + }); + + assertChromeWebStoreStatus(summary, { requirePublished }); + write(JSON.stringify(summary, null, 2)); + return summary; +} + +export function summarizeChromeWebStoreStatus( + status, + { extensionId = DEFAULT_EXTENSION_ID, expectedVersion, publisherId = null } = {}, +) { + const submittedVersion = firstString([ + ...distributionVersions(status.submittedItemRevisionStatus), + ...distributionVersions(status.itemRevisionStatus), + ]); + const publishedVersion = firstString([ + ...distributionVersions(status.publishedItemRevisionStatus), + ...distributionVersions(status.publicItemRevisionStatus), + ]); + const anyVersion = firstString([...collectValuesByKey(status, "crxVersion")]); + const states = uniqueStrings([ + status.lastAsyncUploadState, + status.itemState, + status.state, + status.reviewState, + status.publishState, + status.submittedItemRevisionStatus?.itemState, + status.submittedItemRevisionStatus?.state, + status.submittedItemRevisionStatus?.reviewState, + status.publishedItemRevisionStatus?.itemState, + status.publishedItemRevisionStatus?.state, + status.publishedItemRevisionStatus?.reviewState, + ]); + const normalizedStates = states.map((state) => state.toUpperCase()); + const hasFailureState = normalizedStates.some((state) => FAILURE_STATES.has(state)); + const hasPendingState = normalizedStates.some((state) => PENDING_STATES.has(state)); + const hasPublishedState = normalizedStates.some((state) => PUBLISHED_STATES.has(state)); + const expectedSubmitted = expectedVersion ? submittedVersion === expectedVersion : null; + const expectedPublished = expectedVersion ? publishedVersion === expectedVersion : null; + + return { + extensionId, + publisherId, + expectedVersion: expectedVersion ?? null, + submittedVersion: submittedVersion ?? null, + publishedVersion: publishedVersion ?? null, + latestObservedVersion: submittedVersion ?? publishedVersion ?? anyVersion ?? null, + states, + expectedSubmitted, + expectedPublished, + pendingReview: hasPendingState && !hasFailureState && !expectedPublished, + published: Boolean(expectedPublished || (!expectedVersion && hasPublishedState)), + failed: hasFailureState, + }; +} + +function assertChromeWebStoreStatus(summary, { requirePublished }) { + if (summary.failed) { + throw new Error( + `Chrome Web Store item ${summary.extensionId} has a failed/rejected state: ${summary.states.join(", ")}`, + ); + } + + if (summary.expectedVersion && !summary.expectedSubmitted && !summary.expectedPublished) { + throw new Error( + `Chrome Web Store status does not show expected version ${summary.expectedVersion}. Latest observed version: ${summary.latestObservedVersion ?? "unknown"}.`, + ); + } + + if (requirePublished && !summary.published) { + throw new Error( + `Chrome Web Store version ${summary.expectedVersion ?? "unknown"} is not published yet.`, + ); + } +} + +async function readPackageVersion(cwd) { + const packageJson = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8")); + if (!packageJson.version) throw new Error("package.json is missing version."); + return packageJson.version; +} + +function distributionVersions(revisionStatus) { + if (!revisionStatus) return []; + return [ + revisionStatus.crxVersion, + ...(revisionStatus.distributionChannels ?? []).map((channel) => channel?.crxVersion), + ].filter(Boolean); +} + +function collectValuesByKey(value, key, seen = new Set()) { + if (!value || typeof value !== "object" || seen.has(value)) return []; + seen.add(value); + const values = []; + + if (Object.prototype.hasOwnProperty.call(value, key)) { + values.push(value[key]); + } + + for (const child of Object.values(value)) { + if (Array.isArray(child)) { + for (const item of child) values.push(...collectValuesByKey(item, key, seen)); + } else if (child && typeof child === "object") { + values.push(...collectValuesByKey(child, key, seen)); + } + } + + return values; +} + +function firstString(values) { + return values.find((value) => typeof value === "string" && value.length > 0) ?? null; +} + +function uniqueStrings(values) { + return Array.from(new Set(values.filter((value) => typeof value === "string" && value))); +} + +function parseArgs(values) { + const parsed = {}; + for (let index = 0; index < values.length; index += 1) { + const key = values[index]; + if (!key?.startsWith("--")) throw new Error(`Unexpected argument: ${key}`); + const value = values[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${key}`); + parsed[toCamelCase(key.slice(2))] = value; + index += 1; + } + return parsed; +} + +function parseOptionalBoolean(value, name) { + if (value === undefined || value === null || value === "") return undefined; + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + throw new Error(`Expected ${name} to be true or false, got ${value}.`); +} + +function nonEmptyString(value) { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function toCamelCase(value) { + return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + checkChromeWebStoreStatus().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/scripts/publish-chrome-web-store.d.mts b/scripts/publish-chrome-web-store.d.mts index eb4008a..25441f9 100644 --- a/scripts/publish-chrome-web-store.d.mts +++ b/scripts/publish-chrome-web-store.d.mts @@ -20,3 +20,10 @@ export function buildPublishRequest(options?: { blockOnWarnings?: boolean; deployPercentage?: string | number | null; }): PublishRequest; + +export function fetchChromeWebStoreStatus(options?: { + extensionId?: string; + publisherId?: string; + env?: Record; + fetchImpl?: (url: string, init?: RequestInit) => Promise; +}): Promise>; diff --git a/scripts/publish-chrome-web-store.mjs b/scripts/publish-chrome-web-store.mjs index d2f2c24..4130234 100644 --- a/scripts/publish-chrome-web-store.mjs +++ b/scripts/publish-chrome-web-store.mjs @@ -16,6 +16,25 @@ const UPLOAD_SUCCESS_STATES = new Set([ ]); const UPLOAD_FAILURE_STATES = new Set(["FAILED", "NOT_FOUND", "UPLOAD_FAILED"]); +export async function fetchChromeWebStoreStatus({ + extensionId = DEFAULT_EXTENSION_ID, + publisherId, + env = process.env, + fetchImpl = fetch, +} = {}) { + const selectedPublisherId = publisherId ?? env.CWS_PUBLISHER_ID; + if (!selectedPublisherId) throw new Error("Missing CWS_PUBLISHER_ID or --publisher-id."); + + const accessToken = await getAccessToken(env, fetchImpl); + const name = `publishers/${selectedPublisherId}/items/${extensionId}`; + return getJson( + `https://chromewebstore.googleapis.com/v2/${name}:fetchStatus`, + accessToken, + fetchImpl, + "Chrome Web Store fetchStatus failed", + ); +} + export async function publishChromeWebStorePackage({ argv = process.argv.slice(2), cwd = process.cwd(), diff --git a/tests/extension/ci-workflow.test.ts b/tests/extension/ci-workflow.test.ts index 51dc413..5f0b112 100644 --- a/tests/extension/ci-workflow.test.ts +++ b/tests/extension/ci-workflow.test.ts @@ -131,4 +131,20 @@ describe("Pack CI workflow", () => { expect(releaseRunbook).toContain("CWS_SUBMIT_ENABLED"); expect(releaseRunbook).toContain("CWS_SUBMIT_ENABLED=true"); }); + + it("monitors Chrome Web Store review status without publishing side effects", async () => { + const statusWorkflow = await readFile( + path.join(rootDir, ".github", "workflows", "chrome-web-store-status.yml"), + "utf8", + ); + + expect(statusWorkflow).toContain("schedule:"); + expect(statusWorkflow).toContain("workflow_dispatch:"); + expect(statusWorkflow).toContain("environment: chrome-web-store"); + expect(statusWorkflow).toContain("node scripts/check-chrome-web-store-status.mjs"); + expect(statusWorkflow).toContain("CWS_REQUIRE_PUBLISHED"); + expect(statusWorkflow).not.toContain("scripts/publish-chrome-web-store.mjs"); + expect(statusWorkflow).not.toContain(":publish"); + expect(statusWorkflow).not.toContain(":upload"); + }); }); diff --git a/tests/scripts/check-chrome-web-store-status.test.ts b/tests/scripts/check-chrome-web-store-status.test.ts new file mode 100644 index 0000000..3782504 --- /dev/null +++ b/tests/scripts/check-chrome-web-store-status.test.ts @@ -0,0 +1,183 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { + checkChromeWebStoreStatus, + summarizeChromeWebStoreStatus, +} from "../../scripts/check-chrome-web-store-status.mjs"; + +describe("Chrome Web Store status monitor", () => { + it("summarizes the submitted package while review is pending", () => { + const summary = summarizeChromeWebStoreStatus( + { + lastAsyncUploadState: "SUCCEEDED", + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + publishedItemRevisionStatus: { + distributionChannels: [{ crxVersion: "0.2.1" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedPublished: false, + expectedSubmitted: true, + failed: false, + pendingReview: true, + published: false, + publishedVersion: "0.2.1", + submittedVersion: "0.3.2", + }); + }); + + it("marks the expected version published when the public revision matches", () => { + const summary = summarizeChromeWebStoreStatus( + { + itemState: "PUBLISHED", + publishedItemRevisionStatus: { + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedPublished: true, + pendingReview: false, + published: true, + publishedVersion: "0.3.2", + }); + }); + + it("fails on rejected review state", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url === "https://oauth2.googleapis.com/token") { + return jsonResponse({ access_token: "token-1" }); + } + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "REJECTED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + cwd, + env: { + CWS_CLIENT_ID: "client-1", + CWS_CLIENT_SECRET: "secret-1", + CWS_PUBLISHER_ID: "pub-1", + CWS_REFRESH_TOKEN: "refresh-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("failed/rejected state: REJECTED"); + }); + + it("uses fetchStatus and package.json version by default", async () => { + const cwd = await writePackageFixture("0.3.2"); + const calls: Array<{ method: string | undefined; url: string }> = []; + const output: string[] = []; + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ method: init?.method, url }); + + if (url === "https://oauth2.googleapis.com/token") { + return jsonResponse({ access_token: "token-1" }); + } + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + lastAsyncUploadState: "SUCCEEDED", + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + const summary = await checkChromeWebStoreStatus({ + cwd, + env: { + CWS_CLIENT_ID: "client-1", + CWS_CLIENT_SECRET: "secret-1", + CWS_EXPECTED_VERSION: "", + CWS_PUBLISHER_ID: "pub-1", + CWS_REFRESH_TOKEN: "refresh-1", + }, + fetchImpl, + write: (line: string) => output.push(line), + }); + + expect(calls.map((call) => call.method)).toEqual(["POST", "GET"]); + expect(calls[1]?.url).toBe( + "https://chromewebstore.googleapis.com/v2/publishers/pub-1/items/nfnbhekccajjfgkppolomflaeledoccb:fetchStatus", + ); + expect(summary).toMatchObject({ + expectedVersion: "0.3.2", + expectedSubmitted: true, + pendingReview: true, + }); + expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ + expectedVersion: "0.3.2", + submittedVersion: "0.3.2", + }); + }); + + it("can require the expected version to be published", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + argv: ["--require-published", "true"], + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("Chrome Web Store version 0.3.2 is not published yet."); + }); +}); + +async function writePackageFixture(version: string) { + const cwd = await mkdtemp(path.join(tmpdir(), "pack-cws-status-test-")); + await writeFile(path.join(cwd, "package.json"), JSON.stringify({ version })); + return cwd; +} + +function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify(body), + } as Response; +} From 491655e78ea3a428544048df6ad4577d87b12e15 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 4 Jul 2026 19:24:06 +0530 Subject: [PATCH 2/4] fix(cws): fail closed on terminal status --- .github/workflows/chrome-web-store-status.yml | 2 +- docs/PUBLICATION_READINESS.md | 4 ++ docs/RELEASE.md | 15 +++-- scripts/check-chrome-web-store-status.mjs | 21 ++++++- tests/extension/ci-workflow.test.ts | 2 +- .../check-chrome-web-store-status.test.ts | 59 +++++++++++++++++++ 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/.github/workflows/chrome-web-store-status.yml b/.github/workflows/chrome-web-store-status.yml index fcc9457..020fdcb 100644 --- a/.github/workflows/chrome-web-store-status.yml +++ b/.github/workflows/chrome-web-store-status.yml @@ -26,7 +26,7 @@ jobs: name: Check Chrome Web Store status runs-on: ubuntu-latest timeout-minutes: 5 - environment: chrome-web-store + environment: chrome-web-store-status steps: - name: Checkout diff --git a/docs/PUBLICATION_READINESS.md b/docs/PUBLICATION_READINESS.md index dcc0b82..a687f36 100644 --- a/docs/PUBLICATION_READINESS.md +++ b/docs/PUBLICATION_READINESS.md @@ -186,6 +186,10 @@ stable-release claims. - [x] Submit the `v0.3.2` package through the protected Chrome Web Store workflow. Run `28704776806` uploaded the package with Chrome Web Store upload state `SUCCEEDED`, publish state `PENDING_REVIEW`, and no warnings. +- [x] Add a read-only Chrome Web Store status monitor for submitted packages. + Scheduled runs use the dedicated `chrome-web-store-status` environment so + publication/rejection monitoring is not blocked by the protected publishing + approval gate. - [ ] Upload/review the `v0.3.2` GSTR-1 listing copy, screenshots, promotional images, privacy-practices declarations, and reviewer instructions in the Chrome Web Store dashboard, then record review/publication evidence for diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 18265c0..076d8b9 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -145,11 +145,16 @@ downloaded release version instead of the workflow checkout's `package.json` version. Use `Chrome Web Store Status` after a submit run. It calls the Chrome Web Store -API `fetchStatus` endpoint with the same protected environment credentials, -prints a bounded status summary, and fails on rejected or failed states. By -default it succeeds while the expected version is submitted but still pending -review; dispatch it with `require_published=true` when final publication, not -just submission, is the release gate. +API `fetchStatus` endpoint, prints a bounded status summary, and fails on +rejected, cancelled, failed, or taken-down states. By default it succeeds while +the expected version is submitted but still pending review; dispatch it with +`require_published=true` when final publication, not just submission, is the +release gate. + +Configure the status workflow with a dedicated `chrome-web-store-status` +environment that has no required reviewer protection. Give it only read-only +Chrome Web Store API credentials where possible, plus `CWS_PUBLISHER_ID`. Keep +the publishing workflow on the protected `chrome-web-store` environment. For local dry-runs against a generated release package: diff --git a/scripts/check-chrome-web-store-status.mjs b/scripts/check-chrome-web-store-status.mjs index 89922f4..6f62fef 100644 --- a/scripts/check-chrome-web-store-status.mjs +++ b/scripts/check-chrome-web-store-status.mjs @@ -6,7 +6,13 @@ import { pathToFileURL } from "node:url"; import { fetchChromeWebStoreStatus } from "./publish-chrome-web-store.mjs"; const DEFAULT_EXTENSION_ID = "nfnbhekccajjfgkppolomflaeledoccb"; -const FAILURE_STATES = new Set(["FAILED", "FAILURE", "REJECTED", "REJECTED_FOR_POLICY"]); +const FAILURE_STATES = new Set([ + "CANCELLED", + "FAILED", + "FAILURE", + "REJECTED", + "REJECTED_FOR_POLICY", +]); const PENDING_STATES = new Set([ "IN_REVIEW", "PENDING", @@ -77,7 +83,8 @@ export function summarizeChromeWebStoreStatus( status.publishedItemRevisionStatus?.reviewState, ]); const normalizedStates = states.map((state) => state.toUpperCase()); - const hasFailureState = normalizedStates.some((state) => FAILURE_STATES.has(state)); + const hasFailureState = + normalizedStates.some((state) => FAILURE_STATES.has(state)) || status.takenDown === true; const hasPendingState = normalizedStates.some((state) => PENDING_STATES.has(state)); const hasPublishedState = normalizedStates.some((state) => PUBLISHED_STATES.has(state)); const expectedSubmitted = expectedVersion ? submittedVersion === expectedVersion : null; @@ -91,16 +98,24 @@ export function summarizeChromeWebStoreStatus( publishedVersion: publishedVersion ?? null, latestObservedVersion: submittedVersion ?? publishedVersion ?? anyVersion ?? null, states, + takenDown: status.takenDown === true, expectedSubmitted, expectedPublished, pendingReview: hasPendingState && !hasFailureState && !expectedPublished, - published: Boolean(expectedPublished || (!expectedVersion && hasPublishedState)), + published: + !hasFailureState && Boolean(expectedPublished || (!expectedVersion && hasPublishedState)), failed: hasFailureState, }; } function assertChromeWebStoreStatus(summary, { requirePublished }) { if (summary.failed) { + if (summary.takenDown) { + throw new Error( + `Chrome Web Store item ${summary.extensionId} has been taken down for a policy violation.`, + ); + } + throw new Error( `Chrome Web Store item ${summary.extensionId} has a failed/rejected state: ${summary.states.join(", ")}`, ); diff --git a/tests/extension/ci-workflow.test.ts b/tests/extension/ci-workflow.test.ts index 5f0b112..425474d 100644 --- a/tests/extension/ci-workflow.test.ts +++ b/tests/extension/ci-workflow.test.ts @@ -140,7 +140,7 @@ describe("Pack CI workflow", () => { expect(statusWorkflow).toContain("schedule:"); expect(statusWorkflow).toContain("workflow_dispatch:"); - expect(statusWorkflow).toContain("environment: chrome-web-store"); + expect(statusWorkflow).toContain("environment: chrome-web-store-status"); expect(statusWorkflow).toContain("node scripts/check-chrome-web-store-status.mjs"); expect(statusWorkflow).toContain("CWS_REQUIRE_PUBLISHED"); expect(statusWorkflow).not.toContain("scripts/publish-chrome-web-store.mjs"); diff --git a/tests/scripts/check-chrome-web-store-status.test.ts b/tests/scripts/check-chrome-web-store-status.test.ts index 3782504..0b9b587 100644 --- a/tests/scripts/check-chrome-web-store-status.test.ts +++ b/tests/scripts/check-chrome-web-store-status.test.ts @@ -51,6 +51,7 @@ describe("Chrome Web Store status monitor", () => { pendingReview: false, published: true, publishedVersion: "0.3.2", + takenDown: false, }); }); @@ -87,6 +88,64 @@ describe("Chrome Web Store status monitor", () => { ).rejects.toThrow("failed/rejected state: REJECTED"); }); + it("fails on cancelled submissions for the expected version", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "CANCELLED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("failed/rejected state: CANCELLED"); + }); + + it("fails when fetchStatus reports the item has been taken down", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + takenDown: true, + publishedItemRevisionStatus: { + state: "PUBLISHED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + argv: ["--require-published", "true"], + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("has been taken down for a policy violation"); + }); + it("uses fetchStatus and package.json version by default", async () => { const cwd = await writePackageFixture("0.3.2"); const calls: Array<{ method: string | undefined; url: string }> = []; From 7841afccc427fed6ddba48b74c221fdbb61ba226 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 4 Jul 2026 19:36:17 +0530 Subject: [PATCH 3/4] fix(cws): harden status monitor gates --- docs/RELEASE.md | 8 +- scripts/check-chrome-web-store-status.mjs | 55 +++++--- scripts/publish-chrome-web-store.mjs | 12 +- .../check-chrome-web-store-status.test.ts | 118 ++++++++++++++++++ 4 files changed, 165 insertions(+), 28 deletions(-) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 076d8b9..9716471 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -146,10 +146,10 @@ version. Use `Chrome Web Store Status` after a submit run. It calls the Chrome Web Store API `fetchStatus` endpoint, prints a bounded status summary, and fails on -rejected, cancelled, failed, or taken-down states. By default it succeeds while -the expected version is submitted but still pending review; dispatch it with -`require_published=true` when final publication, not just submission, is the -release gate. +rejected, cancelled, failed, warned, or taken-down states. By default it +succeeds while the expected version is submitted but still pending review; +dispatch it with `require_published=true` when final publication, not just +submission, is the release gate. Configure the status workflow with a dedicated `chrome-web-store-status` environment that has no required reviewer protection. Give it only read-only diff --git a/scripts/check-chrome-web-store-status.mjs b/scripts/check-chrome-web-store-status.mjs index 6f62fef..00e532c 100644 --- a/scripts/check-chrome-web-store-status.mjs +++ b/scripts/check-chrome-web-store-status.mjs @@ -60,35 +60,41 @@ export function summarizeChromeWebStoreStatus( status, { extensionId = DEFAULT_EXTENSION_ID, expectedVersion, publisherId = null } = {}, ) { - const submittedVersion = firstString([ + const submittedVersions = uniqueStrings([ ...distributionVersions(status.submittedItemRevisionStatus), ...distributionVersions(status.itemRevisionStatus), ]); - const publishedVersion = firstString([ + const publishedVersions = uniqueStrings([ ...distributionVersions(status.publishedItemRevisionStatus), ...distributionVersions(status.publicItemRevisionStatus), ]); + const submittedVersion = firstString(submittedVersions); + const publishedVersion = firstString(publishedVersions); const anyVersion = firstString([...collectValuesByKey(status, "crxVersion")]); + const topLevelStates = [status.itemState, status.state, status.reviewState, status.publishState]; + const submittedRevisionStates = revisionStates(status.submittedItemRevisionStatus); + const publishedRevisionStates = revisionStates(status.publishedItemRevisionStatus); const states = uniqueStrings([ status.lastAsyncUploadState, - status.itemState, - status.state, - status.reviewState, - status.publishState, - status.submittedItemRevisionStatus?.itemState, - status.submittedItemRevisionStatus?.state, - status.submittedItemRevisionStatus?.reviewState, - status.publishedItemRevisionStatus?.itemState, - status.publishedItemRevisionStatus?.state, - status.publishedItemRevisionStatus?.reviewState, + ...topLevelStates, + ...submittedRevisionStates, + ...publishedRevisionStates, ]); const normalizedStates = states.map((state) => state.toUpperCase()); + const normalizedPublishedStates = uniqueStrings([ + ...topLevelStates, + ...publishedRevisionStates, + ]).map((state) => state.toUpperCase()); const hasFailureState = - normalizedStates.some((state) => FAILURE_STATES.has(state)) || status.takenDown === true; + normalizedStates.some((state) => FAILURE_STATES.has(state)) || + status.takenDown === true || + status.warned === true; const hasPendingState = normalizedStates.some((state) => PENDING_STATES.has(state)); - const hasPublishedState = normalizedStates.some((state) => PUBLISHED_STATES.has(state)); - const expectedSubmitted = expectedVersion ? submittedVersion === expectedVersion : null; - const expectedPublished = expectedVersion ? publishedVersion === expectedVersion : null; + const hasPublishedState = normalizedPublishedStates.some((state) => PUBLISHED_STATES.has(state)); + const expectedSubmitted = expectedVersion ? submittedVersions.includes(expectedVersion) : null; + const expectedPublished = expectedVersion ? publishedVersions.includes(expectedVersion) : null; + const published = + !hasFailureState && hasPublishedState && Boolean(expectedVersion ? expectedPublished : true); return { extensionId, @@ -99,11 +105,11 @@ export function summarizeChromeWebStoreStatus( latestObservedVersion: submittedVersion ?? publishedVersion ?? anyVersion ?? null, states, takenDown: status.takenDown === true, + warned: status.warned === true, expectedSubmitted, expectedPublished, - pendingReview: hasPendingState && !hasFailureState && !expectedPublished, - published: - !hasFailureState && Boolean(expectedPublished || (!expectedVersion && hasPublishedState)), + pendingReview: hasPendingState && !hasFailureState && !published, + published, failed: hasFailureState, }; } @@ -116,6 +122,12 @@ function assertChromeWebStoreStatus(summary, { requirePublished }) { ); } + if (summary.warned) { + throw new Error( + `Chrome Web Store item ${summary.extensionId} has a policy warning that must be resolved.`, + ); + } + throw new Error( `Chrome Web Store item ${summary.extensionId} has a failed/rejected state: ${summary.states.join(", ")}`, ); @@ -148,6 +160,11 @@ function distributionVersions(revisionStatus) { ].filter(Boolean); } +function revisionStates(revisionStatus) { + if (!revisionStatus) return []; + return [revisionStatus.itemState, revisionStatus.state, revisionStatus.reviewState]; +} + function collectValuesByKey(value, key, seen = new Set()) { if (!value || typeof value !== "object" || seen.has(value)) return []; seen.add(value); diff --git a/scripts/publish-chrome-web-store.mjs b/scripts/publish-chrome-web-store.mjs index 4130234..13f8f49 100644 --- a/scripts/publish-chrome-web-store.mjs +++ b/scripts/publish-chrome-web-store.mjs @@ -7,6 +7,8 @@ import { URLSearchParams, pathToFileURL } from "node:url"; const DEFAULT_EXTENSION_ID = "nfnbhekccajjfgkppolomflaeledoccb"; const DEFAULT_UPLOAD_POLL_ATTEMPTS = 30; const DEFAULT_UPLOAD_POLL_INTERVAL_MS = 10_000; +const CWS_WRITE_SCOPE = "https://www.googleapis.com/auth/chromewebstore"; +const CWS_READONLY_SCOPE = "https://www.googleapis.com/auth/chromewebstore.readonly"; const UPLOAD_IN_PROGRESS_STATES = new Set(["IN_PROGRESS", "UPLOAD_IN_PROGRESS"]); const UPLOAD_SUCCESS_STATES = new Set([ "SUCCEEDED", @@ -25,7 +27,7 @@ export async function fetchChromeWebStoreStatus({ const selectedPublisherId = publisherId ?? env.CWS_PUBLISHER_ID; if (!selectedPublisherId) throw new Error("Missing CWS_PUBLISHER_ID or --publisher-id."); - const accessToken = await getAccessToken(env, fetchImpl); + const accessToken = await getAccessToken(env, fetchImpl, { scope: CWS_READONLY_SCOPE }); const name = `publishers/${selectedPublisherId}/items/${extensionId}`; return getJson( `https://chromewebstore.googleapis.com/v2/${name}:fetchStatus`, @@ -304,10 +306,10 @@ export async function waitForUploadCompletion({ ); } -async function getAccessToken(env, fetchImpl) { +async function getAccessToken(env, fetchImpl, { scope = CWS_WRITE_SCOPE } = {}) { if (env.CWS_ACCESS_TOKEN) return env.CWS_ACCESS_TOKEN; if (env.CWS_SERVICE_ACCOUNT_JSON) { - return serviceAccountAccessToken(JSON.parse(env.CWS_SERVICE_ACCOUNT_JSON), fetchImpl); + return serviceAccountAccessToken(JSON.parse(env.CWS_SERVICE_ACCOUNT_JSON), fetchImpl, scope); } if (env.CWS_CLIENT_ID && env.CWS_CLIENT_SECRET && env.CWS_REFRESH_TOKEN) { return refreshTokenAccessToken({ @@ -322,14 +324,14 @@ async function getAccessToken(env, fetchImpl) { ); } -async function serviceAccountAccessToken(serviceAccount, fetchImpl) { +async function serviceAccountAccessToken(serviceAccount, fetchImpl, scope) { const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token"; const iat = Math.floor(Date.now() / 1000); const assertion = [ base64UrlJson({ alg: "RS256", typ: "JWT" }), base64UrlJson({ iss: serviceAccount.client_email, - scope: "https://www.googleapis.com/auth/chromewebstore", + scope, aud: tokenUri, exp: iat + 3600, iat, diff --git a/tests/scripts/check-chrome-web-store-status.test.ts b/tests/scripts/check-chrome-web-store-status.test.ts index 0b9b587..5b24edd 100644 --- a/tests/scripts/check-chrome-web-store-status.test.ts +++ b/tests/scripts/check-chrome-web-store-status.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync } from "node:crypto"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -52,6 +53,47 @@ describe("Chrome Web Store status monitor", () => { published: true, publishedVersion: "0.3.2", takenDown: false, + warned: false, + }); + }); + + it("matches the expected version across every distribution channel", () => { + const summary = summarizeChromeWebStoreStatus( + { + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.1" }, { crxVersion: "0.3.2" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedSubmitted: true, + failed: false, + pendingReview: true, + published: false, + submittedVersion: "0.3.1", + }); + }); + + it("does not treat tester-only availability as public publication", () => { + const summary = summarizeChromeWebStoreStatus( + { + publishedItemRevisionStatus: { + state: "PUBLISHED_TO_TESTERS", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedPublished: true, + failed: false, + pendingReview: false, + published: false, + publishedVersion: "0.3.2", }); }); @@ -146,6 +188,36 @@ describe("Chrome Web Store status monitor", () => { ).rejects.toThrow("has been taken down for a policy violation"); }); + it("fails when fetchStatus reports a policy warning", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + warned: true, + publishedItemRevisionStatus: { + state: "PUBLISHED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + argv: ["--require-published", "true"], + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("has a policy warning that must be resolved"); + }); + it("uses fetchStatus and package.json version by default", async () => { const cwd = await writePackageFixture("0.3.2"); const calls: Array<{ method: string | undefined; url: string }> = []; @@ -197,6 +269,47 @@ describe("Chrome Web Store status monitor", () => { }); }); + it("requests read-only service-account tokens for fetchStatus", async () => { + const cwd = await writePackageFixture("0.3.2"); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwtPayloads: Array<{ scope?: string }> = []; + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + if (url === "https://oauth2.googleapis.com/token") { + const body = init?.body as URLSearchParams; + const assertion = body.get("assertion") ?? ""; + jwtPayloads.push(decodeJwtPayload(assertion)); + return jsonResponse({ access_token: "token-1" }); + } + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await checkChromeWebStoreStatus({ + cwd, + env: { + CWS_EXPECTED_VERSION: "0.3.2", + CWS_PUBLISHER_ID: "pub-1", + CWS_SERVICE_ACCOUNT_JSON: JSON.stringify({ + client_email: "pack-status@example.iam.gserviceaccount.com", + private_key: privateKey.export({ format: "pem", type: "pkcs8" }), + token_uri: "https://oauth2.googleapis.com/token", + }), + }, + fetchImpl, + write: vi.fn(), + }); + + expect(jwtPayloads[0]?.scope).toBe("https://www.googleapis.com/auth/chromewebstore.readonly"); + }); + it("can require the expected version to be published", async () => { const cwd = await writePackageFixture("0.3.2"); const fetchImpl = vi.fn(async (url: string) => { @@ -240,3 +353,8 @@ function jsonResponse(body: unknown) { text: async () => JSON.stringify(body), } as Response; } + +function decodeJwtPayload(assertion: string) { + const payload = assertion.split(".")[1] ?? ""; + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { scope?: string }; +} From 4c0241ff9878eacab9e65e37370e60f77bbf72af Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 4 Jul 2026 23:50:45 +0530 Subject: [PATCH 4/4] fix(cws): keep status workflow read only --- .github/workflows/chrome-web-store-status.yml | 3 --- docs/RELEASE.md | 8 +++++--- tests/extension/ci-workflow.test.ts | 3 +++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/chrome-web-store-status.yml b/.github/workflows/chrome-web-store-status.yml index 020fdcb..c6a9bc1 100644 --- a/.github/workflows/chrome-web-store-status.yml +++ b/.github/workflows/chrome-web-store-status.yml @@ -45,6 +45,3 @@ jobs: CWS_REQUIRE_PUBLISHED: ${{ inputs.require_published || 'false' }} CWS_PUBLISHER_ID: ${{ vars.CWS_PUBLISHER_ID }} CWS_SERVICE_ACCOUNT_JSON: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }} - CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} - CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} - CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 9716471..6118c78 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -152,9 +152,11 @@ dispatch it with `require_published=true` when final publication, not just submission, is the release gate. Configure the status workflow with a dedicated `chrome-web-store-status` -environment that has no required reviewer protection. Give it only read-only -Chrome Web Store API credentials where possible, plus `CWS_PUBLISHER_ID`. Keep -the publishing workflow on the protected `chrome-web-store` environment. +environment that has no required reviewer protection. Give it a read-only +service-account `CWS_SERVICE_ACCOUNT_JSON` secret plus `CWS_PUBLISHER_ID`; do +not copy the publish workflow's OAuth client secret or refresh token into this +environment. Keep the publishing workflow on the protected `chrome-web-store` +environment. For local dry-runs against a generated release package: diff --git a/tests/extension/ci-workflow.test.ts b/tests/extension/ci-workflow.test.ts index 425474d..7568603 100644 --- a/tests/extension/ci-workflow.test.ts +++ b/tests/extension/ci-workflow.test.ts @@ -143,6 +143,9 @@ describe("Pack CI workflow", () => { expect(statusWorkflow).toContain("environment: chrome-web-store-status"); expect(statusWorkflow).toContain("node scripts/check-chrome-web-store-status.mjs"); expect(statusWorkflow).toContain("CWS_REQUIRE_PUBLISHED"); + expect(statusWorkflow).toContain("CWS_SERVICE_ACCOUNT_JSON"); + expect(statusWorkflow).not.toContain("CWS_REFRESH_TOKEN"); + expect(statusWorkflow).not.toContain("CWS_CLIENT_SECRET"); expect(statusWorkflow).not.toContain("scripts/publish-chrome-web-store.mjs"); expect(statusWorkflow).not.toContain(":publish"); expect(statusWorkflow).not.toContain(":upload");