936 lines
32 KiB
JavaScript
936 lines
32 KiB
JavaScript
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 <target> chrome, firefox, or both (default: both)
|
|
--notes <text> Firefox release notes
|
|
--notes-file <path> 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}.`);
|
|
}
|
|
};
|
|
|
|
const errorDetail = async (response) => {
|
|
const text = await response.text();
|
|
if (!text) {
|
|
return `HTTP ${response.status}`;
|
|
}
|
|
try {
|
|
const payload = JSON.parse(text);
|
|
const message = payload.error?.message || payload.detail || payload.message;
|
|
if (message) {
|
|
return `HTTP ${response.status}: ${message}`;
|
|
}
|
|
return `HTTP ${response.status}: ${JSON.stringify(payload).slice(0, 2000)}`;
|
|
} catch {
|
|
return `HTTP ${response.status}: ${text.slice(0, 2000)}`;
|
|
}
|
|
};
|
|
|
|
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}).`);
|
|
}
|
|
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."}`);
|
|
}
|
|
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 = createChromeClient({
|
|
fetchImpl,
|
|
accessToken: await chromeAccessToken(env, fetchImpl),
|
|
});
|
|
const initial = chromeSubmissionPlan({
|
|
packageVersion: version,
|
|
status: await client.fetchStatus(),
|
|
});
|
|
if (initial.action !== "upload") {
|
|
return initial;
|
|
}
|
|
|
|
const uploaded = await client.upload(chromiumZip);
|
|
const immediate = chromeUploadFinished(uploaded);
|
|
if (immediate.done && !immediate.ok) {
|
|
throw new Error(`Chrome rejected the package upload (${immediate.state}).`);
|
|
}
|
|
if (!immediate.done) {
|
|
await waitForChromeUpload(client);
|
|
}
|
|
try {
|
|
const published = await client.publish();
|
|
return {
|
|
action: "submitted",
|
|
state: published.state ?? "PENDING_REVIEW",
|
|
};
|
|
} catch (error) {
|
|
const after = chromeSubmissionPlan({
|
|
packageVersion: version,
|
|
status: await client.fetchStatus(),
|
|
});
|
|
if (after.action === "already-submitted") {
|
|
return after;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const submitFirefox = async ({ version, firefoxZip, sourceZip, releaseNotes, env, fetchImpl }) => {
|
|
const client = 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;
|
|
}
|
|
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) {
|
|
throw new Error("Firefox did not return an upload id.");
|
|
}
|
|
await 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 = 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 {
|
|
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", reason }]);
|
|
failures.push(reason);
|
|
}
|
|
}
|
|
|
|
writeStepSummary([
|
|
`Web Clipper ${version}`,
|
|
...results.map(([store, result]) => `${store}: ${result.action}${result.reason ? ` — ${result.reason}` : ""}`),
|
|
"Chrome and Edge use the same Chrome Web Store submission. Review still has to approve it.",
|
|
"Firefox review still has to approve and sign the package.",
|
|
"waiting-review: no package was submitted to that store. After review completes, manually rerun Submit Web Clipper with the original release_tag and that platform.",
|
|
].join("\n"));
|
|
if (failures.length > 0) {
|
|
throw new Error(failures.join(" "));
|
|
}
|
|
};
|
|
|
|
if (import.meta.main) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|