ci(docs): edge store listing info and auto publishing

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-27 13:35:25 +02:00
1 parent 427b08db59
commit caeb394fdb
13 files changed
+353 -74

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
// Publishes a built Edge zip to the existing Microsoft Edge Add-ons listing, through the Edge
// Add-ons Update REST API v1.1 (https://learn.microsoft.com/microsoft-edge/extensions/update/api/using-addons-api):
// upload the package to the listing's draft submission, wait for it to be processed, submit the
// draft for certification, and wait for that to be accepted. Certification itself then takes
// Microsoft anywhere from hours to days -- this only gets the update into the queue.
//
// EDGE_PRODUCT_ID=... EDGE_CLIENT_ID=... EDGE_API_KEY=... \
// tsx scripts/publish-edge.ts dist/zips/thingport-grab-edge.zip [--notes "..."]
//
// Run by .github/workflows/extension-release.yml when package.json's version hasn't been
// published to Edge yet (see extension/CONTRIBUTING.md for setting up the credentials).
// EDGE_API_BASE overrides the API root, for testing against a mock.
import { readFile } from "node:fs/promises";
const API_BASE = process.env.EDGE_API_BASE || "https://api.addons.microsoftedge.microsoft.com";
const POLL_INTERVAL_MS = 5000;
const POLL_TIMEOUT_MS = 10 * 60 * 1000;
type Operation = {
id?: string;
status?: "InProgress" | "Succeeded" | "Failed" | string;
message?: string | null;
errorCode?: string | null;
errors?: unknown[] | null;
};
// What the documented failure codes mean for whoever reads the CI log.
const ERROR_HINTS: Record<string, string> = {
InProgressSubmission:
"A previous submission is still in certification. Wait for Microsoft to finish reviewing it, then re-run this workflow.",
NoModulesUpdated: "The uploaded package is identical to the published one -- bump the version in package.json.",
UnpublishInProgress: "The listing is being unpublished in Partner Center.",
CreateNotAllowed: "The product ID doesn't belong to an existing listing -- check the EDGE_PRODUCT_ID variable.",
};
function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} isn't set`);
return value;
}
function argValue(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** A 202 whose Location header carries the operation to poll (documented as the bare ID; a
* full URL is tolerated too). */
async function startOperation(url: string, init: RequestInit, what: string): Promise<string> {
const res = await fetch(url, init);
if (res.status !== 202) {
const body = await res.text().catch(() => "");
const hint = res.status === 401 ? " -- the API key or client ID is wrong, or the key has expired (Partner Center > Publish API)." : "";
throw new Error(`${what} failed: HTTP ${res.status} ${body}${hint}`);
}
const location = res.headers.get("location");
if (!location) throw new Error(`${what}: no operation ID in the response`);
return location.replace(/\/+$/, "").split("/").pop()!;
}
async function waitFor(url: string, auth: Record<string, string>, what: string): Promise<Operation> {
const deadline = Date.now() + POLL_TIMEOUT_MS;
for (;;) {
const res = await fetch(url, { headers: auth });
if (!res.ok) throw new Error(`${what}: status check failed with HTTP ${res.status} ${await res.text().catch(() => "")}`);
const operation = (await res.json()) as Operation;
if (operation.status !== "InProgress") {
if (operation.status === "Succeeded") return operation;
const hint = operation.errorCode ? ERROR_HINTS[operation.errorCode] : undefined;
const details = operation.errors?.length ? `\n ${JSON.stringify(operation.errors)}` : "";
throw new Error(`${what} failed (${operation.errorCode || "no error code"}): ${operation.message}${details}${hint ? `\n ${hint}` : ""}`);
}
if (Date.now() > deadline) throw new Error(`${what} still in progress after ${POLL_TIMEOUT_MS / 60000} minutes`);
await sleep(POLL_INTERVAL_MS);
}
}
async function main(): Promise<void> {
const zipPath = process.argv.slice(2).find((arg, i, all) => !arg.startsWith("--") && all[i - 1] !== "--notes");
if (!zipPath) throw new Error("Usage: tsx scripts/publish-edge.ts <package.zip> [--notes <text>]");
const productId = requireEnv("EDGE_PRODUCT_ID");
const auth = { Authorization: `ApiKey ${requireEnv("EDGE_API_KEY")}`, "X-ClientID": requireEnv("EDGE_CLIENT_ID") };
const productUrl = `${API_BASE}/v1/products/${productId}`;
const zip = await readFile(zipPath);
console.log(`Uploading ${zipPath} (${(zip.length / 1024).toFixed(1)} KB) to Edge Add-ons product ${productId}…`);
const uploadId = await startOperation(
`${productUrl}/submissions/draft/package`,
{ method: "POST", headers: { ...auth, "Content-Type": "application/zip" }, body: zip },
"Upload",
);
const uploaded = await waitFor(`${productUrl}/submissions/draft/package/operations/${uploadId}`, auth, "Upload");
console.log(`Upload processed: ${uploaded.message ?? "ok"}`);
const notes = argValue("--notes") ?? "Automated update. Testing instructions are unchanged from the previous submission.";
const publishId = await startOperation(
`${productUrl}/submissions`,
{ method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ notes }) },
"Submission",
);
const submitted = await waitFor(`${productUrl}/submissions/operations/${publishId}`, auth, "Submission");
console.log(`Submitted for certification: ${submitted.message ?? "ok"}`);
}
try {
await main();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
+3 -1
View File
@@ -3,6 +3,7 @@
// npm run store-assets
//
// logo.png 300x300 store logo (Edge Add-ons "Extension logo")
// store-icon.png 128x128 Chrome Web Store icon (transparent, 96px artwork + 16px padding)
// tile-small.png 440x280 small promotional tile
// tile-large.png 1400x560 large promotional tile (the extension's real panel on a placeholder page)
// screenshots/*.png 1280x800 the README's page screenshots (docs/screenshots/*.jpg) as PNG, at the
@@ -32,6 +33,7 @@ const CONTENT_CSS =
const ASSETS = [
{ template: "logo.html", out: "logo.png", width: 300, height: 300 },
{ template: "store-icon.html", out: "store-icon.png", width: 128, height: 128, transparent: true },
{ template: "tile-small.html", out: "tile-small.png", width: 440, height: 280 },
{ template: "tile-large.html", out: "tile-large.png", width: 1400, height: 560 },
];
@@ -45,7 +47,7 @@ try {
await page.goto(pathToFileURL(path.join(TEMPLATES, asset.template)).href, { waitUntil: "load" });
await page.addStyleTag({ content: CONTENT_CSS });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: path.join(OUT_DIR, asset.out) });
await page.screenshot({ path: path.join(OUT_DIR, asset.out), omitBackground: "transparent" in asset });
await page.close();
console.log(`docs/store/${asset.out} ${asset.width}x${asset.height}`);
}