import { spawnSync } from "node:child_process"; import { createHash, createHmac, createSign, randomUUID } from "node:crypto"; import { appendFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); export const OFFICIAL_REPOSITORY = "tianma-if/edgeever"; export const CHROME_PUBLISHER_ID = "8c414fb4-4243-4b51-9e6b-fe05e49307e3"; export const CHROME_EXTENSION_ID = "gjadpfmanienmlofajibkfkkpfdkclgo"; export const FIREFOX_ADDON_SLUG = "edgeever-web-clipper"; export const FIREFOX_ADDON_ID = "web-clipper@edgeever.org"; export const FIREFOX_LICENSE_SLUG = "AGPL-3.0-only"; const CHROME_SCOPE = "https://www.googleapis.com/auth/chromewebstore"; const SOURCE_SKIP_DIRECTORIES = new Set([ "node_modules", "dist", "dist-firefox", "web-ext-artifacts", ".git", ]); const ROOT_SOURCE_FILES = [ "LICENSE", "package.json", "bun.lock", "bunfig.toml", "tsconfig.json", ]; export const SHARED_EXTENSION_SOURCE_FILES = [ "apps/web/src/components/ui/button.tsx", "apps/web/src/components/ui/input.tsx", "apps/web/src/components/ui/button-tooltip.tsx", "apps/web/src/components/ui/tooltip.tsx", "apps/web/src/lib/utils.ts", ]; const usage = `Usage: bun run publish:extension -- [options] Options: --platform chrome, firefox, or both (default: both) --notes Firefox release notes --notes-file Firefox release notes file --dry-run Build the packages without calling the stores --help Show this help Chrome and Edge share the Chrome Web Store item ${CHROME_EXTENSION_ID}. Firefox is submitted separately to ${FIREFOX_ADDON_SLUG}. `; export const parsePublishArgs = (argv) => { const options = { platform: "both", notes: "", notesFile: "", dryRun: false, help: false, }; const valueOptions = new Map([ ["--platform", "platform"], ["--notes", "notes"], ["--notes-file", "notesFile"], ]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--dry-run") { options.dryRun = true; continue; } if (argument === "--help") { options.help = true; continue; } const key = valueOptions.get(argument); if (!key) { throw new Error(`Unknown option: ${argument}`); } const value = argv[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`${argument} requires a value.`); } options[key] = value; index += 1; } if (!options.help && !["chrome", "firefox", "both"].includes(options.platform)) { throw new Error("--platform must be chrome, firefox, or both."); } return options; }; export const compareVersions = (left, right) => { const parse = (value) => { if (!/^\d+\.\d+\.\d+$/.test(value)) { throw new Error(`Extension version must use X.Y.Z, received ${value}.`); } return value.split(".").map((part) => Number(part)); }; const leftParts = parse(left); const rightParts = parse(right); for (let index = 0; index < 3; index += 1) { if (leftParts[index] !== rightParts[index]) { return leftParts[index] - rightParts[index]; } } return 0; }; export const highestCrxVersion = (revision) => { const versions = (revision?.distributionChannels ?? []) .map((channel) => channel.crxVersion) .filter((version) => typeof version === "string" && version.length > 0); return versions.sort(compareVersions).at(-1) ?? ""; }; export const chromeSubmissionPlan = ({ packageVersion, status }) => { const published = highestCrxVersion(status?.publishedItemRevisionStatus); const submitted = highestCrxVersion(status?.submittedItemRevisionStatus); const submittedState = status?.submittedItemRevisionStatus?.state ?? ""; const pending = submittedState === "PENDING_REVIEW" || submittedState === "STAGED"; if (published && compareVersions(packageVersion, published) <= 0) { return { action: "already-published", published }; } if (submitted && compareVersions(packageVersion, submitted) === 0 && pending) { return { action: "already-submitted", published, submitted, submittedState }; } if (submitted && pending) { return { action: "waiting-review", reason: `Chrome ${submitted} is ${submittedState}. Wait for that review, or cancel it, before submitting ${packageVersion}.`, }; } return { action: "upload", published, submitted, submittedState }; }; export const firefoxSubmissionPlan = ({ packageVersion, versions }) => { const listed = versions.filter((version) => version?.version); const same = listed.find((version) => version.version === packageVersion); if (same) { const status = same.file?.status ?? ""; if (status === "public") { return { action: "already-published", version: same.version, status }; } if (status === "unreviewed" && !same.source) { return { action: "attach-source", version: same.version, status }; } if (status === "unreviewed") { return { action: "already-submitted", version: same.version, status }; } return { action: "blocked", reason: `Firefox version ${packageVersion} is ${status || "already used"}. Increase apps/extension/package.json before submitting again.`, }; } const newerPublic = listed.find((version) => version.file?.status === "public" && compareVersions(version.version, packageVersion) > 0); if (newerPublic) { return { action: "already-published", version: newerPublic.version, status: "public" }; } const awaiting = listed.find((version) => version.file?.status === "unreviewed"); if (awaiting) { return { action: "waiting-review", reason: `Firefox ${awaiting.version} is still awaiting review.`, }; } const newer = listed.find((version) => compareVersions(version.version, packageVersion) > 0); if (newer) { return { action: "blocked", reason: `Firefox already has ${newer.version}. Increase apps/extension/package.json before submitting ${packageVersion}.`, }; } return { action: "upload" }; }; export const chromeUploadFinished = (payload) => { const state = payload?.uploadState || payload?.lastAsyncUploadState || ""; if (state.includes("FAIL") || state === "NOT_FOUND") { return { done: true, ok: false, state }; } if (state.includes("PROGRESS")) { return { done: false, ok: false, state }; } if (state.includes("SUCCEED") || state === "SUCCESS") { return { done: true, ok: true, state }; } if (payload?.itemId && !state) { return { done: true, ok: true, state: "SUCCEEDED" }; } return { done: false, ok: false, state }; }; export const amoUploadReady = (upload) => { if (!upload?.processed) { return { done: false, ok: false }; } if (upload.valid === false) { return { done: true, ok: false }; } return { done: true, ok: true }; }; export const amoValidationErrors = (upload) => { const messages = upload?.validation?.messages ?? upload?.validation?.errors ?? []; return messages .filter((message) => !message.type || message.type === "error") .map((message) => message.message || JSON.stringify(message)); }; export const credentialPresence = (env) => ({ chromeServiceAccount: Boolean(env.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON), chromeRefreshToken: Boolean( env.CHROME_WEB_STORE_CLIENT_ID && env.CHROME_WEB_STORE_CLIENT_SECRET && env.CHROME_WEB_STORE_REFRESH_TOKEN, ), amo: Boolean(env.AMO_JWT_ISSUER && env.AMO_JWT_SECRET), reviewInstance: Boolean( env.EDGE_EVER_REVIEW_INSTANCE_URL && env.EDGE_EVER_REVIEW_API_TOKEN, ), }); export const assertCredentials = (platform, env) => { const present = credentialPresence(env); const missing = []; if ((platform === "chrome" || platform === "both") && !present.chromeServiceAccount && !present.chromeRefreshToken) { missing.push("CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON or CHROME_WEB_STORE_CLIENT_ID, CHROME_WEB_STORE_CLIENT_SECRET, and CHROME_WEB_STORE_REFRESH_TOKEN"); } if ((platform === "firefox" || platform === "both") && !present.amo) { missing.push("AMO_JWT_ISSUER and AMO_JWT_SECRET"); } if (missing.length > 0) { throw new Error(`Missing Web Clipper store credentials: ${missing.join("; ")}.`); } }; export const assertOfficialRepository = (repository) => { if (repository && repository !== OFFICIAL_REPOSITORY) { throw new Error(`Web Clipper store submission runs only in ${OFFICIAL_REPOSITORY}.`); } }; export const resolveReleaseNotes = ({ notes, notesFile, version }) => { const inline = notes?.trim() ?? ""; if (inline) { return limitNotes(inline); } if (notesFile) { const fromFile = readFileSync(notesFile, "utf8").trim(); if (fromFile) { return limitNotes(fromFile); } } return `EdgeEver Web Clipper ${version}.`; }; const limitNotes = (notes) => { if (notes.length > 4000) { throw new Error("Release notes must be 4000 characters or fewer."); } return notes; }; export const buildApprovalNotes = ({ version, reviewInstanceUrl, reviewApiToken }) => { const lines = [ "Source archive root: edgeever-source.", `Reproduce version ${version} with Bun 1.3.14 or later and Node.js 20 or later:`, "bun install --frozen-lockfile", "bun run test:extension", "bun run build:extension:firefox", "bun run lint:extension:firefox", "bun run package:extension:firefox", "See SOURCE_BUILD.md.", "The extension reads a page only after a clip action and sends it to the EdgeEver instance configured in the extension settings.", "Review steps are in apps/extension/FIREFOX_STORE_LISTING.md.", ]; if (reviewInstanceUrl && reviewApiToken) { lines.push(`Review instance: ${reviewInstanceUrl}`, `Review API token: ${reviewApiToken}`); } return lines.join("\n"); }; export const shouldCopyExtensionSource = (relativePath) => { const parts = relativePath.split(/[\\/]/u).filter(Boolean); if (parts.some((part) => SOURCE_SKIP_DIRECTORIES.has(part) || part === ".DS_Store")) { return false; } return !(parts[0] === "store-assets" && relativePath.endsWith(".zip")); }; export const workspacePackageManifests = (repoRoot) => { const manifests = []; for (const group of ["apps", "packages"]) { const groupDirectory = join(repoRoot, group); if (!existsSync(groupDirectory)) { continue; } for (const entry of readdirSync(groupDirectory, { withFileTypes: true })) { if (!entry.isDirectory() || (group === "apps" && entry.name === "extension")) { continue; } const manifest = join(group, entry.name, "package.json"); if (existsSync(join(repoRoot, manifest))) { manifests.push(manifest); } } } return manifests.sort(); }; export const encodeJwtPart = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); export const createAmoJwt = ({ issuer, secret, now = Date.now(), jti = randomUUID() }) => { const issuedAt = Math.floor(now / 1000); const header = encodeJwtPart({ alg: "HS256", typ: "JWT" }); const payload = encodeJwtPart({ iss: issuer, jti, iat: issuedAt, exp: issuedAt + 240, }); const body = `${header}.${payload}`; const signature = createHmac("sha256", secret).update(body).digest("base64url"); return `${body}.${signature}`; }; export const createGoogleServiceAccountAssertion = ({ clientEmail, privateKey, now = Date.now(), }) => { const issuedAt = Math.floor(now / 1000); const header = encodeJwtPart({ alg: "RS256", typ: "JWT" }); const payload = encodeJwtPart({ iss: clientEmail, scope: CHROME_SCOPE, aud: "https://oauth2.googleapis.com/token", iat: issuedAt, exp: issuedAt + 3600, }); const body = `${header}.${payload}`; const signature = createSign("RSA-SHA256").update(body).sign(privateKey).toString("base64url"); return `${body}.${signature}`; }; const runCommand = (command, args, options = {}) => { const result = spawnSync(command, args, { stdio: "inherit", ...options }); if (result.error) { throw new Error(`${command} failed to start: ${result.error.message}`); } if (result.status !== 0) { throw new Error(`${command} ${args.join(" ")} failed with status ${result.status}.`); } }; // Never log request bodies: Firefox approval notes contain private review credentials. export const redactDiagnostics = (value, env = process.env) => { const sensitiveKey = /token|secret|password|authorization|private[_-]?key|approval[_-]?notes|assertion/i; const clean = (item) => { if (Array.isArray(item)) return item.map(clean); if (item && typeof item === "object") return Object.fromEntries( Object.entries(item).map(([key, entry]) => [key, sensitiveKey.test(key) ? "[REDACTED]" : clean(entry)]), ); return item; }; let text = typeof value === "string" ? value : JSON.stringify(clean(value), null, 2); const secrets = Object.entries(env) .filter(([key, entry]) => sensitiveKey.test(key) && typeof entry === "string" && entry) .map(([, entry]) => entry); try { const account = JSON.parse(env.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON || "{}"); if (account.private_key) secrets.push(account.private_key); } catch { /* Invalid credentials are reported by the authentication stage. */ } for (const secret of secrets.sort((a, b) => b.length - a.length)) text = text.split(secret).join("[REDACTED]"); return text .replace(/(?:Bearer|JWT)\s+[A-Za-z0-9._~+/=-]+/gi, "[REDACTED authorization]") .replace(/-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/g, "[REDACTED private key]"); }; const errorDetail = async (response) => { const body = await response.text(); if (!body) return `HTTP ${response.status}`; let detail; try { detail = redactDiagnostics(JSON.parse(body)); } catch { detail = redactDiagnostics(body); } const limit = 20000; return `HTTP ${response.status}\n${detail.slice(0, limit)}${detail.length > limit ? "\n[API response truncated after 20000 characters]" : ""}`; }; export const runSubmissionStage = async (stage, operation) => { try { return await operation(); } catch (error) { const failure = new Error(error instanceof Error ? error.message : String(error), { cause: error }); failure.stage = stage; throw failure; } }; const stagedClient = (store, client) => Object.fromEntries( Object.entries(client).map(([method, operation]) => [method, (...args) => runSubmissionStage(`${store}.${method}`, () => operation(...args)), ]), ); export const formatSubmissionReport = (version, results, env = process.env) => { const lines = [`Web Clipper ${version}`, ""]; for (const [store, result] of results) { let status = result.action === "failed" ? "Submission incomplete or failed; publication unconfirmed (earlier steps may have succeeded)" : "Not submitted; publication unconfirmed"; if (result.action === "already-published" || (store === "Chrome" && result.state === "PUBLISHED")) { status = "Published (confirmed by store API)"; } else if (["submitted", "already-submitted"].includes(result.action)) { status = result.state === "STAGED" || result.submittedState === "STAGED" ? "Approved and staged; not yet published" : "Submitted for review; publication unconfirmed"; } lines.push(`${store}: ${result.action} — ${status}`); if (result.published || result.version) lines.push(`Store version: ${result.published || result.version}`); if (result.state || result.submittedState || result.status) lines.push(`API state: ${result.state || result.submittedState || result.status}`); if (result.stage) lines.push(`Failure stage: ${result.stage}`); if (result.reason) lines.push(`Reason / API response:\n${result.reason}`); if (result.warnings) lines.push(`API warnings:\n${redactDiagnostics(result.warnings, env)}`); if (result.recoveryError) lines.push(`Status recheck also failed:\n${result.recoveryError}`); if (["Chrome", "Firefox"].includes(store) && (result.action === "failed" || result.action === "blocked")) { lines.push(store === "Chrome" ? `Dashboard: https://chrome.google.com/webstore/devconsole/${CHROME_PUBLISHER_ID}/${CHROME_EXTENSION_ID}/edit` : `Dashboard: https://addons.mozilla.org/developers/addon/${FIREFOX_ADDON_SLUG}/versions`); lines.push("Only API-returned diagnostics are available here; dashboard-only requirements must be checked in the developer dashboard."); } lines.push(""); } lines.push("Submission success is not review approval or publication. This run does not wait for later store review.", "Chrome and Edge share the Chrome Web Store item. waiting-review means no new package was submitted; retry that store after review completes."); // A code block keeps untrusted API text literal in the Actions Summary. const report = redactDiagnostics(lines.join("\n"), env); const fence = "`".repeat(Math.max(3, ...Array.from(report.matchAll(/`+/g), (match) => match[0].length + 1))); return `${fence}text\n${report}\n${fence}`; }; const requestJson = async (fetchImpl, url, options) => { const response = await fetchImpl(url, options); if (!response.ok) { throw new Error(await errorDetail(response)); } const text = await response.text(); return text ? JSON.parse(text) : {}; }; const sleep = (delayMs) => new Promise((resolveDelay) => { setTimeout(resolveDelay, delayMs); }); export const createChromeClient = ({ fetchImpl = fetch, accessToken, publisherId = CHROME_PUBLISHER_ID, extensionId = CHROME_EXTENSION_ID, }) => { const item = `publishers/${publisherId}/items/${extensionId}`; const headers = { Authorization: `Bearer ${accessToken}` }; return { fetchStatus: () => requestJson( fetchImpl, `https://chromewebstore.googleapis.com/v2/${item}:fetchStatus`, { headers }, ), upload: async (zipPath) => { const response = await fetchImpl( `https://chromewebstore.googleapis.com/upload/v2/${item}:upload`, { method: "POST", headers: { ...headers, "Content-Type": "application/zip" }, body: readFileSync(zipPath), }, ); if (!response.ok) { throw new Error(await errorDetail(response)); } const text = await response.text(); return text ? JSON.parse(text) : {}; }, publish: () => requestJson( fetchImpl, `https://chromewebstore.googleapis.com/v2/${item}:publish`, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ publishType: "DEFAULT_PUBLISH" }), }, ), }; }; const waitForChromeUpload = async (client) => { for (let attempt = 0; attempt < 30; attempt += 1) { const status = await client.fetchStatus(); const state = status.lastAsyncUploadState || ""; if (state.includes("FAIL") || state === "NOT_FOUND") { throw new Error(`Chrome rejected the package upload (${state}).\n${redactDiagnostics(status)}`); } if (!state.includes("PROGRESS")) { return status; } await sleep(2000); } throw new Error("Timed out waiting for the Chrome Web Store upload."); }; export const createFirefoxClient = ({ fetchImpl = fetch, issuer, secret, addonSlug = FIREFOX_ADDON_SLUG, }) => { const authorization = () => `JWT ${createAmoJwt({ issuer, secret })}`; const addonUrl = `https://addons.mozilla.org/api/v5/addons/addon/${addonSlug}`; return { listVersions: async () => { const versions = []; let url = `${addonUrl}/versions/?page_size=50`; for (let page = 0; page < 5 && url; page += 1) { const payload = await requestJson(fetchImpl, url, { headers: { Authorization: authorization() }, }); versions.push(...(payload.results ?? [])); url = payload.next ?? ""; } return versions; }, uploadPackage: async (zipPath) => { const form = new FormData(); form.append("upload", new Blob([readFileSync(zipPath)]), "edgeever-web-clipper.zip"); form.append("channel", "listed"); const response = await fetchImpl("https://addons.mozilla.org/api/v5/addons/upload/", { method: "POST", headers: { Authorization: authorization() }, body: form, }); if (!response.ok) { throw new Error(await errorDetail(response)); } return response.json(); }, fetchUpload: (uuid) => requestJson( fetchImpl, `https://addons.mozilla.org/api/v5/addons/upload/${uuid}/`, { headers: { Authorization: authorization() } }, ), createVersion: ({ uploadUuid, releaseNotes, approvalNotes }) => requestJson( fetchImpl, `${addonUrl}/versions/`, { method: "POST", headers: { Authorization: authorization(), "Content-Type": "application/json", }, body: JSON.stringify({ upload: uploadUuid, license: FIREFOX_LICENSE_SLUG, release_notes: { "en-US": releaseNotes }, approval_notes: approvalNotes, }), }, ), attachSource: async ({ version, sourcePath, releaseNotes, approvalNotes }) => { const form = new FormData(); form.append("source", new Blob([readFileSync(sourcePath)]), "edgeever-web-clipper-source.zip"); const sourceResponse = await fetchImpl(`${addonUrl}/versions/${version}/`, { method: "PATCH", headers: { Authorization: authorization() }, body: form, }); if (!sourceResponse.ok) { throw new Error(await errorDetail(sourceResponse)); } return requestJson(fetchImpl, `${addonUrl}/versions/${version}/`, { method: "PATCH", headers: { Authorization: authorization(), "Content-Type": "application/json", }, body: JSON.stringify({ release_notes: { "en-US": releaseNotes }, approval_notes: approvalNotes, }), }); }, }; }; const waitForAmoUpload = async (client, uuid) => { for (let attempt = 0; attempt < 60; attempt += 1) { const upload = await client.fetchUpload(uuid); const ready = amoUploadReady(upload); if (ready.done && !ready.ok) { const errors = amoValidationErrors(upload); throw new Error(`Firefox rejected the package. ${errors.join(" ") || "Validation failed."}\n${redactDiagnostics(upload)}`); } if (ready.done) { return upload; } await sleep(5000); } throw new Error("Timed out waiting for Firefox Add-ons validation."); }; export const copySourceArchive = (repoRoot, destinationRoot) => { mkdirSync(destinationRoot, { recursive: true }); for (const file of ROOT_SOURCE_FILES) { cpSync(join(repoRoot, file), join(destinationRoot, file)); } cpSync(join(repoRoot, "patches"), join(destinationRoot, "patches"), { recursive: true }); cpSync( join(repoRoot, "apps/extension/SOURCE_BUILD.md"), join(destinationRoot, "SOURCE_BUILD.md"), ); const extensionRoot = join(repoRoot, "apps/extension"); cpSync(extensionRoot, join(destinationRoot, "apps/extension"), { recursive: true, filter: (source) => { const extensionRelative = relative(extensionRoot, source); return extensionRelative === "" || shouldCopyExtensionSource(extensionRelative); }, }); for (const manifest of workspacePackageManifests(repoRoot)) { const target = join(destinationRoot, manifest); mkdirSync(dirname(target), { recursive: true }); cpSync(join(repoRoot, manifest), target); } for (const file of SHARED_EXTENSION_SOURCE_FILES) { const target = join(destinationRoot, file); mkdirSync(dirname(target), { recursive: true }); cpSync(join(repoRoot, file), target); } }; export const createSourceArchive = (repoRoot, sourceZip) => { const staging = mkdtempSync(join(tmpdir(), "edgeever-web-clipper-source-")); try { copySourceArchive(repoRoot, join(staging, "edgeever-source")); rmSync(sourceZip, { force: true }); runCommand("zip", ["-q", "-r", "-X", sourceZip, "edgeever-source", "-x", "*.DS_Store"], { cwd: staging }); } finally { rmSync(staging, { recursive: true, force: true }); } }; const buildFileHashes = (directory, prefix = "") => { const files = new Map(); for (const entry of readdirSync(join(directory, prefix), { withFileTypes: true })) { const path = join(prefix, entry.name); if (entry.isDirectory()) { for (const [file, hash] of buildFileHashes(directory, path)) files.set(file, hash); } else { files.set(path, createHash("sha256").update(readFileSync(join(directory, path))).digest("hex")); } } return files; }; export const assertIdenticalBuildDirectories = (referenceDirectory, rebuiltDirectory) => { const expected = buildFileHashes(referenceDirectory); const rebuilt = buildFileHashes(rebuiltDirectory); const different = [...new Set([...expected.keys(), ...rebuilt.keys()])] .filter((file) => expected.get(file) !== rebuilt.get(file)).sort(); if (different.length) throw new Error(`Source archive rebuild differs: ${different.join(", ")}`); if (!expected.size) throw new Error("Cannot verify an empty extension build"); return expected.size; }; export const sourceRebuildEnvironment = (environment = process.env) => ({ ...Object.fromEntries(Object.entries(environment).filter(([key]) => !/^(CHROME_WEB_STORE_|AMO_|EDGE_EVER_REVIEW_)/.test(key) && !["GITHUB_TOKEN", "GH_TOKEN"].includes(key))), NODE_PATH: "", NODE_OPTIONS: "", }); export const verifySourceArchive = ({ sourceZip, referenceDirectory }) => { const staging = mkdtempSync(join(tmpdir(), "edgeever-web-clipper-rebuild-")); try { runCommand("unzip", ["-q", sourceZip, "-d", staging]); const sourceRoot = join(staging, "edgeever-source"); if (!existsSync(join(sourceRoot, "package.json"))) throw new Error("Source archive is missing its root package.json"); if (existsSync(join(sourceRoot, "node_modules")) || existsSync(join(sourceRoot, "apps/extension/dist-firefox"))) { throw new Error("Source rebuild must start without dependencies or built assets"); } const options = { cwd: sourceRoot, env: sourceRebuildEnvironment() }; runCommand("bun", ["install", "--frozen-lockfile"], options); runCommand("bun", ["run", "build:extension:firefox"], options); const count = assertIdenticalBuildDirectories(referenceDirectory, join(sourceRoot, "apps/extension/dist-firefox")); console.log(`Firefox source archive independently rebuilt: ${count} files match SHA-256.`); return count; } finally { rmSync(staging, { recursive: true, force: true }); } }; const zipDirectoryContents = (directory, archivePath) => { rmSync(archivePath, { force: true }); runCommand("zip", ["-r", "-X", archivePath, ".", "-x", "*.DS_Store", "-x", "*__MACOSX*"], { cwd: directory, }); }; const readExtensionVersion = () => { const version = JSON.parse(readFileSync(join(REPO_ROOT, "apps/extension/package.json"), "utf8")).version; compareVersions(version, version); return version; }; const assertBuiltManifests = (version) => { const chromium = JSON.parse(readFileSync(join(REPO_ROOT, "apps/extension/dist/manifest.json"), "utf8")); const firefox = JSON.parse(readFileSync(join(REPO_ROOT, "apps/extension/dist-firefox/manifest.json"), "utf8")); if (chromium.version !== version || firefox.version !== version) { throw new Error(`Built manifests are ${chromium.version} and ${firefox.version}; expected ${version}.`); } if (chromium.browser_specific_settings) { throw new Error("The Chromium package must not include Firefox manifest settings."); } if (firefox.browser_specific_settings?.gecko?.id !== FIREFOX_ADDON_ID) { throw new Error(`The Firefox package id must be ${FIREFOX_ADDON_ID}.`); } }; const findFirefoxPackage = (version) => { const directory = join(REPO_ROOT, "apps/extension/web-ext-artifacts"); const names = readdirSync(directory).filter((name) => name.endsWith(`-${version}.zip`) && !name.includes("source")); if (names.length !== 1) { throw new Error(`Expected one Firefox package for ${version}, found ${names.join(", ") || "none"}.`); } return join(directory, names[0]); }; const buildPackages = (version) => { runCommand("zip", ["-v"], { stdio: "ignore" }); runCommand("bun", ["run", "test:extension"], { cwd: REPO_ROOT }); runCommand("bun", ["run", "build:extension"], { cwd: REPO_ROOT }); runCommand("bun", ["run", "package:extension:firefox"], { cwd: REPO_ROOT }); assertBuiltManifests(version); const chromiumZip = join( REPO_ROOT, "apps/extension/store-assets", `edgeever-web-clipper-v${version}.zip`, ); mkdirSync(dirname(chromiumZip), { recursive: true }); zipDirectoryContents(join(REPO_ROOT, "apps/extension/dist"), chromiumZip); const firefoxZip = findFirefoxPackage(version); const sourceZip = join( REPO_ROOT, "apps/extension/web-ext-artifacts", `edgeever_web_clipper-${version}-source.zip`, ); createSourceArchive(REPO_ROOT, sourceZip); // This runs before either store is contacted, including in --dry-run mode. verifySourceArchive({ sourceZip, referenceDirectory: join(REPO_ROOT, "apps/extension/dist-firefox") }); return { version, chromiumZip, firefoxZip, sourceZip }; }; const writeGithubOutput = (packages) => { if (!process.env.GITHUB_OUTPUT) { return; } appendFileSync( process.env.GITHUB_OUTPUT, `version=${packages.version}\nchromium_zip=${packages.chromiumZip}\nfirefox_zip=${packages.firefoxZip}\nsource_zip=${packages.sourceZip}\n`, ); }; const writeStepSummary = (markdown) => { console.log(markdown); if (process.env.GITHUB_STEP_SUMMARY) { appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${markdown}\n`); } }; const chromeAccessToken = async (env, fetchImpl) => { if (env.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON) { const account = JSON.parse(env.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON); if (!account.client_email || !account.private_key) { throw new Error("CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON must include client_email and private_key."); } const assertion = createGoogleServiceAccountAssertion({ clientEmail: account.client_email, privateKey: account.private_key, }); const payload = await requestJson(fetchImpl, "https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion, }), }); return payload.access_token; } const payload = await requestJson(fetchImpl, "https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: env.CHROME_WEB_STORE_CLIENT_ID, client_secret: env.CHROME_WEB_STORE_CLIENT_SECRET, refresh_token: env.CHROME_WEB_STORE_REFRESH_TOKEN, grant_type: "refresh_token", }), }); return payload.access_token; }; export const submitChrome = async ({ version, chromiumZip, env, fetchImpl }) => { const client = stagedClient("chrome", createChromeClient({ fetchImpl, accessToken: await runSubmissionStage("chrome.authenticate", () => chromeAccessToken(env, fetchImpl)), })); const initial = chromeSubmissionPlan({ packageVersion: version, status: await client.fetchStatus(), }); if (initial.action !== "upload") { return initial.action === "blocked" ? { ...initial, stage: "chrome.submissionPlan" } : initial; } const uploaded = await client.upload(chromiumZip); const immediate = chromeUploadFinished(uploaded); if (immediate.done && !immediate.ok) { await runSubmissionStage("chrome.validateUpload", () => { throw new Error(`Chrome rejected the package upload (${immediate.state}).\n${redactDiagnostics(uploaded)}`); }); } if (!immediate.done) { await runSubmissionStage("chrome.validateUpload", () => waitForChromeUpload(client)); } try { const published = await client.publish(); return { action: "submitted", state: published.state ?? "UNKNOWN", warnings: published.warningInfo, }; } catch (error) { try { const after = chromeSubmissionPlan({ packageVersion: version, status: await client.fetchStatus() }); if (after.action === "already-submitted" || after.action === "already-published") return after; } catch (recoveryError) { error.recoveryError = recoveryError.message; } throw error; } }; export const submitFirefox = async ({ version, firefoxZip, sourceZip, releaseNotes, env, fetchImpl }) => { const client = stagedClient("firefox", createFirefoxClient({ fetchImpl, issuer: env.AMO_JWT_ISSUER, secret: env.AMO_JWT_SECRET, })); const approvalNotes = buildApprovalNotes({ version, reviewInstanceUrl: env.EDGE_EVER_REVIEW_INSTANCE_URL, reviewApiToken: env.EDGE_EVER_REVIEW_API_TOKEN, }); const plan = firefoxSubmissionPlan({ packageVersion: version, versions: await client.listVersions(), }); if (plan.action !== "upload" && plan.action !== "attach-source") { return plan.action === "blocked" ? { ...plan, stage: "firefox.submissionPlan" } : plan; } if (plan.action === "attach-source") { await client.attachSource({ version, sourcePath: sourceZip, releaseNotes, approvalNotes, }); return { action: "submitted", version, sourceAttached: true }; } const upload = await client.uploadPackage(firefoxZip); if (!upload.uuid) { await runSubmissionStage("firefox.uploadPackage", () => { throw new Error("Firefox did not return an upload id."); }); } await runSubmissionStage("firefox.validateUpload", () => waitForAmoUpload(client, upload.uuid)); const created = await client.createVersion({ uploadUuid: upload.uuid, releaseNotes, approvalNotes, }); const createdVersion = created.version?.version ?? version; await client.attachSource({ version: createdVersion, sourcePath: sourceZip, releaseNotes, approvalNotes, }); return { action: "submitted", version: createdVersion, sourceAttached: true }; }; const selectedPlatforms = (platform) => platform === "both" ? ["chrome", "firefox"] : [platform]; const main = async () => { const options = parsePublishArgs(process.argv.slice(2)); if (options.help) { console.log(usage); return; } if (!options.dryRun) { assertOfficialRepository(process.env.GITHUB_REPOSITORY); } const version = readExtensionVersion(); const releaseNotes = resolveReleaseNotes({ notes: options.notes, notesFile: options.notesFile, version, }); const packages = await runSubmissionStage("build-packages-and-verify-source", () => buildPackages(version)); writeGithubOutput(packages); if (options.dryRun) { const present = credentialPresence(process.env); writeStepSummary([ `Web Clipper ${version} packages are ready.`, `Chromium: ${packages.chromiumZip}`, `Firefox: ${packages.firefoxZip}`, `Source: ${packages.sourceZip}`, "Dry run: stores were not called.", `Chrome credentials: ${present.chromeServiceAccount || present.chromeRefreshToken ? "present" : "missing"}`, `Firefox credentials: ${present.amo ? "present" : "missing"}`, ].join("\n")); return; } if (!process.env.EDGE_EVER_REVIEW_INSTANCE_URL || !process.env.EDGE_EVER_REVIEW_API_TOKEN) { console.warn("Review instance credentials are unset. Firefox approval notes omit the review login."); } const results = []; const failures = []; for (const platform of selectedPlatforms(options.platform)) { const store = platform === "chrome" ? "Chrome" : "Firefox"; try { await runSubmissionStage(`${platform}.credentials`, () => assertCredentials(platform, process.env)); const result = platform === "chrome" ? await submitChrome({ ...packages, env: process.env, fetchImpl: fetch }) : await submitFirefox({ ...packages, releaseNotes, env: process.env, fetchImpl: fetch, }); results.push([store, result]); if (result.action === "blocked") { failures.push(result.reason); } if (result.action === "waiting-review") { console.warn(`${store}: ${result.reason} Retry Submit Web Clipper with the same release_tag after review completes.`); } } catch (error) { const reason = error instanceof Error ? error.message : String(error); results.push([store, { action: "failed", stage: error.stage ?? "unknown", reason, recoveryError: error.recoveryError }]); failures.push(reason); } } writeStepSummary(formatSubmissionReport(version, results)); if (failures.length > 0) { const error = new Error("One or more store submissions failed; see the report above."); error.reported = true; throw error; } }; if (import.meta.main) { main().catch((error) => { if (!error.reported) { writeStepSummary(formatSubmissionReport("(preparation)", [["Preparation", { action: "failed", stage: error.stage ?? "preparation", reason: error.message ?? String(error), }]])); } console.error(redactDiagnostics(error instanceof Error ? error.message : String(error))); process.exitCode = 1; }); }