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
47 changes: 47 additions & 0 deletions .github/workflows/chrome-web-store-status.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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-status

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 }}
6 changes: 6 additions & 0 deletions docs/PUBLICATION_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -184,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
Expand Down
22 changes: 22 additions & 0 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@ 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, prints a bounded status summary, and fails on
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 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:

```sh
Expand All @@ -154,6 +168,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 <publisher-id> \
--expected-version <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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions scripts/check-chrome-web-store-status.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface CheckChromeWebStoreStatusOptions {
argv?: string[];
cwd?: string;
env?: Record<string, string | undefined>;
fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
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<ChromeWebStoreStatusSummary>;

export function summarizeChromeWebStoreStatus(
status: Record<string, unknown>,
options?: {
extensionId?: string;
expectedVersion?: string;
publisherId?: string | null;
},
): ChromeWebStoreStatusSummary;
229 changes: 229 additions & 0 deletions scripts/check-chrome-web-store-status.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/* 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([
"CANCELLED",
"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 submittedVersions = uniqueStrings([
...distributionVersions(status.submittedItemRevisionStatus),
...distributionVersions(status.itemRevisionStatus),
]);
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,
...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 ||
status.warned === true;
const hasPendingState = normalizedStates.some((state) => PENDING_STATES.has(state));
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,
publisherId,
expectedVersion: expectedVersion ?? null,
submittedVersion: submittedVersion ?? null,
publishedVersion: publishedVersion ?? null,
latestObservedVersion: submittedVersion ?? publishedVersion ?? anyVersion ?? null,
states,
takenDown: status.takenDown === true,
warned: status.warned === true,
expectedSubmitted,
expectedPublished,
pendingReview: hasPendingState && !hasFailureState && !published,
published,
failed: hasFailureState,
Comment thread
lamemustafa marked this conversation as resolved.
};
}

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.`,
);
}

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(", ")}`,
);
}

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 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);
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);
});
}
7 changes: 7 additions & 0 deletions scripts/publish-chrome-web-store.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>;
fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
}): Promise<Record<string, unknown>>;
Loading