fix(extension): report store submission diagnostics
This commit is contained in:
1 parent
85606df666
commit
a7b311f4eb
4 files changed
+224
-47
No files matched your search
+114
-47
@@ -369,23 +369,91 @@ const runCommand = (command, args, options = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const errorDetail = async (response) => {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return `HTTP ${response.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 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 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) {
|
||||
@@ -445,7 +513,7 @@ const waitForChromeUpload = async (client) => {
|
||||
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}).`);
|
||||
throw new Error(`Chrome rejected the package upload (${state}).\n${redactDiagnostics(status)}`);
|
||||
}
|
||||
if (!state.includes("PROGRESS")) {
|
||||
return status;
|
||||
@@ -544,7 +612,7 @@ const waitForAmoUpload = async (client, 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."}`);
|
||||
throw new Error(`Firefox rejected the package. ${errors.join(" ") || "Validation failed."}\n${redactDiagnostics(upload)}`);
|
||||
}
|
||||
if (ready.done) {
|
||||
return upload;
|
||||
@@ -761,50 +829,50 @@ const chromeAccessToken = async (env, fetchImpl) => {
|
||||
};
|
||||
|
||||
export const submitChrome = async ({ version, chromiumZip, env, fetchImpl }) => {
|
||||
const client = createChromeClient({
|
||||
const client = stagedClient("chrome", createChromeClient({
|
||||
fetchImpl,
|
||||
accessToken: await chromeAccessToken(env, fetchImpl),
|
||||
});
|
||||
accessToken: await runSubmissionStage("chrome.authenticate", () => chromeAccessToken(env, fetchImpl)),
|
||||
}));
|
||||
const initial = chromeSubmissionPlan({
|
||||
packageVersion: version,
|
||||
status: await client.fetchStatus(),
|
||||
});
|
||||
if (initial.action !== "upload") {
|
||||
return initial;
|
||||
return initial.action === "blocked" ? { ...initial, stage: "chrome.submissionPlan" } : 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}).`);
|
||||
await runSubmissionStage("chrome.validateUpload", () => { throw new Error(`Chrome rejected the package upload (${immediate.state}).\n${redactDiagnostics(uploaded)}`); });
|
||||
}
|
||||
if (!immediate.done) {
|
||||
await waitForChromeUpload(client);
|
||||
await runSubmissionStage("chrome.validateUpload", () => waitForChromeUpload(client));
|
||||
}
|
||||
try {
|
||||
const published = await client.publish();
|
||||
return {
|
||||
action: "submitted",
|
||||
state: published.state ?? "PENDING_REVIEW",
|
||||
state: published.state ?? "UNKNOWN",
|
||||
warnings: published.warningInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
const after = chromeSubmissionPlan({
|
||||
packageVersion: version,
|
||||
status: await client.fetchStatus(),
|
||||
});
|
||||
if (after.action === "already-submitted") {
|
||||
return after;
|
||||
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 = createFirefoxClient({
|
||||
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,
|
||||
@@ -815,7 +883,7 @@ export const submitFirefox = async ({ version, firefoxZip, sourceZip, releaseNot
|
||||
versions: await client.listVersions(),
|
||||
});
|
||||
if (plan.action !== "upload" && plan.action !== "attach-source") {
|
||||
return plan;
|
||||
return plan.action === "blocked" ? { ...plan, stage: "firefox.submissionPlan" } : plan;
|
||||
}
|
||||
if (plan.action === "attach-source") {
|
||||
await client.attachSource({
|
||||
@@ -829,9 +897,9 @@ export const submitFirefox = async ({ version, firefoxZip, sourceZip, releaseNot
|
||||
|
||||
const upload = await client.uploadPackage(firefoxZip);
|
||||
if (!upload.uuid) {
|
||||
throw new Error("Firefox did not return an upload id.");
|
||||
await runSubmissionStage("firefox.uploadPackage", () => { throw new Error("Firefox did not return an upload id."); });
|
||||
}
|
||||
await waitForAmoUpload(client, upload.uuid);
|
||||
await runSubmissionStage("firefox.validateUpload", () => waitForAmoUpload(client, upload.uuid));
|
||||
const created = await client.createVersion({
|
||||
uploadUuid: upload.uuid,
|
||||
releaseNotes,
|
||||
@@ -866,7 +934,7 @@ const main = async () => {
|
||||
notesFile: options.notesFile,
|
||||
version,
|
||||
});
|
||||
const packages = buildPackages(version);
|
||||
const packages = await runSubmissionStage("build-packages-and-verify-source", () => buildPackages(version));
|
||||
writeGithubOutput(packages);
|
||||
|
||||
if (options.dryRun) {
|
||||
@@ -892,7 +960,7 @@ const main = async () => {
|
||||
for (const platform of selectedPlatforms(options.platform)) {
|
||||
const store = platform === "chrome" ? "Chrome" : "Firefox";
|
||||
try {
|
||||
assertCredentials(platform, process.env);
|
||||
await runSubmissionStage(`${platform}.credentials`, () => assertCredentials(platform, process.env));
|
||||
const result = platform === "chrome"
|
||||
? await submitChrome({ ...packages, env: process.env, fetchImpl: fetch })
|
||||
: await submitFirefox({
|
||||
@@ -910,28 +978,27 @@ const main = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
results.push([store, { action: "failed", reason }]);
|
||||
results.push([store, { action: "failed", stage: error.stage ?? "unknown", reason, recoveryError: error.recoveryError }]);
|
||||
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.",
|
||||
...(results.some(([, result]) => result.action === "waiting-review")
|
||||
? ["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"));
|
||||
writeStepSummary(formatSubmissionReport(version, results));
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join(" "));
|
||||
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) => {
|
||||
console.error(error instanceof Error ? error.message : 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;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHmac, createVerify, generateKeyPairSync } from "node:crypto";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -20,6 +21,9 @@ import {
|
||||
createGoogleServiceAccountAssertion,
|
||||
credentialPresence,
|
||||
firefoxSubmissionPlan,
|
||||
formatSubmissionReport,
|
||||
redactDiagnostics,
|
||||
runSubmissionStage,
|
||||
FIREFOX_LICENSE_SLUG,
|
||||
highestCrxVersion,
|
||||
OFFICIAL_REPOSITORY,
|
||||
@@ -39,6 +43,92 @@ const workflow = readFileSync(
|
||||
);
|
||||
|
||||
describe("web clipper store command", () => {
|
||||
test("reports HTTP field errors, nested details and status without leaking credentials", async () => {
|
||||
const client = createChromeClient({ accessToken: "private-access", fetchImpl: async () => ({
|
||||
ok: false, status: 400, text: async () => JSON.stringify({ error: {
|
||||
code: 400, status: "INVALID_ARGUMENT", message: "Requirements missing",
|
||||
details: [{ fieldViolations: [{ field: "privacy", description: "Justify declarativeNetRequestWithHostAccess" }] }],
|
||||
access_token: "private-access",
|
||||
} }),
|
||||
}) });
|
||||
let failure;
|
||||
try { await runSubmissionStage("chrome.publish", () => client.publish()); } catch (error) { failure = error; }
|
||||
const report = formatSubmissionReport("0.2.0", [["Chrome", { action: "failed", stage: failure.stage, reason: failure.message }]]);
|
||||
expect(report).toContain("HTTP 400");
|
||||
expect(report).toContain("INVALID_ARGUMENT");
|
||||
expect(report).toContain("Justify declarativeNetRequestWithHostAccess");
|
||||
expect(report).toContain("Failure stage: chrome.publish");
|
||||
expect(report).not.toContain("private-access");
|
||||
expect(report).toContain("publication unconfirmed");
|
||||
});
|
||||
|
||||
test("distinguishes pending, staged, published and blocked results in the shared report", () => {
|
||||
for (const [result, expected] of [
|
||||
[{ action: "submitted", state: "PENDING_REVIEW" }, "Submitted for review; publication unconfirmed"],
|
||||
[{ action: "already-submitted", submittedState: "STAGED" }, "Approved and staged; not yet published"],
|
||||
[{ action: "submitted", state: "PUBLISHED" }, "Published (confirmed by store API)"],
|
||||
[{ action: "already-published", published: "0.2.1" }, "Store version: 0.2.1"],
|
||||
[{ action: "waiting-review", reason: "Another version is pending" }, "Not submitted; publication unconfirmed"],
|
||||
]) expect(formatSubmissionReport("0.2.0", [["Chrome", result]])).toContain(expected);
|
||||
expect(formatSubmissionReport("0.2.0", [["Firefox", { action: "submitted", sourceAttached: true }]]))
|
||||
.toContain("Submitted for review; publication unconfirmed");
|
||||
});
|
||||
|
||||
test("redacts private approval notes and environment secrets and renders API markup literally", () => {
|
||||
expect(redactDiagnostics({ approval_notes: "private login", errors: ["bad field"] })).not.toContain("private login");
|
||||
const report = formatSubmissionReport("0.2.0", [["Firefox", { action: "failed", reason: "secret-value ``` <script>" }]], { AMO_JWT_SECRET: "secret-value" });
|
||||
expect(report).not.toContain("secret-value");
|
||||
expect(report).toStartWith("````text\n");
|
||||
});
|
||||
|
||||
test("preserves Chrome publish failure when the subsequent status check fails", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "edgeever-submit-diagnostics-"));
|
||||
const zip = join(directory, "addon.zip");
|
||||
writeFileSync(zip, "zip");
|
||||
let statusCalls = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
if (String(url).endsWith("/token")) return jsonResponse({ access_token: "token" });
|
||||
if (String(url).endsWith(":fetchStatus")) {
|
||||
if (++statusCalls > 1) throw new Error("Status unavailable");
|
||||
return jsonResponse({});
|
||||
}
|
||||
if (String(url).endsWith(":upload")) return jsonResponse({ uploadState: "SUCCEEDED" });
|
||||
return { ok: false, status: 400, text: async () => JSON.stringify({ error: { message: "Privacy requirement missing" } }) };
|
||||
};
|
||||
try {
|
||||
let failure;
|
||||
try { await submitChrome({ version: "0.2.0", chromiumZip: zip, env: {}, fetchImpl }); } catch (error) { failure = error; }
|
||||
expect(failure.stage).toBe("chrome.publish");
|
||||
expect(failure.message).toContain("Privacy requirement missing");
|
||||
expect(failure.recoveryError).toBe("Status unavailable");
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("preserves Firefox field errors and non-JSON service errors", async () => {
|
||||
const client = createFirefoxClient({ issuer: "test", secret: "test", fetchImpl: async () => ({
|
||||
ok: false, status: 400, text: async () => JSON.stringify({ upload: ["Invalid package"], license: ["Required"] }),
|
||||
}) });
|
||||
await expect(client.createVersion({})).rejects.toThrow("Invalid package");
|
||||
const chrome = createChromeClient({ accessToken: "test", fetchImpl: async () => ({
|
||||
ok: false, status: 503, text: async () => "Service unavailable",
|
||||
}) });
|
||||
await expect(chrome.publish()).rejects.toThrow("HTTP 503\nService unavailable");
|
||||
});
|
||||
|
||||
test("writes preparation failure details to Actions Summary and exits nonzero", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "edgeever-report-test-"));
|
||||
const summary = join(directory, "summary.md");
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [new URL("./publish-web-clipper.mjs", import.meta.url).pathname, "--invalid-option"], {
|
||||
env: { ...process.env, GITHUB_STEP_SUMMARY: summary }, encoding: "utf8", stdio: "pipe",
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
const markdown = readFileSync(summary, "utf8");
|
||||
expect(markdown).toContain("Failure stage: preparation");
|
||||
expect(markdown).toContain("Unknown option: --invalid-option");
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("defaults to both stores", () => {
|
||||
expect(parsePublishArgs([])).toMatchObject({
|
||||
platform: "both",
|
||||
|
||||
Reference in new issue
Block a user