web-ext-deploy
Version:
A tool for deploying WebExtensions to multiple stores.
250 lines (249 loc) • 8.56 kB
JavaScript
import Axios from "axios";
import chalk from "chalk";
import dedent from "dedent";
import { backOff } from "exponential-backoff";
import FormData from "form-data";
import status from "http-status";
import jwt from "jsonwebtoken";
import { getErrorMessage, getExtJson, getVerboseMessage, logSuccessfullyPublished } from "../../utils.js";
import fs from "fs";
const STORE = "Firefox";
let axios;
const SECONDS_TO_TOKEN_EXPIRY = 60 * 3;
async function handleRequestWithBackOff({ sendRequest, errorActionOnFailure, zip, extId }) {
while (true) {
try {
const { data } = await sendRequest();
return [undefined, data];
}
catch (e) {
const isServerError = e.response.status >= 500;
if (isServerError) {
await backOff(Promise.resolve, { maxDelay: 30_000, delayFirstAttempt: true, jitter: "full" });
continue;
}
if (e.response.status === status.TOO_MANY_REQUESTS) {
const secondsToWait = Number(e.response.data.detail.match(/\d+/)[0]);
if (secondsToWait <= 60) {
if (secondsToWait < SECONDS_TO_TOKEN_EXPIRY) {
const newTime = new Date(Date.now() + secondsToWait * 1000).toLocaleTimeString();
console.log(chalk.yellow(getVerboseMessage({
store: STORE,
message: dedent(`
Too many requests. A retry will automatically be at ${newTime}
Or, you can deploy manually: https://addons.mozilla.org/developers/addon/${extId}/versions/submit/
`),
prefix: "Warning"
})));
}
await new Promise(resolve => setTimeout(resolve, secondsToWait * 1000));
continue;
}
// If the wait time is greater than SECONDS_TO_TOKEN_EXPIRY, do not retry due to the token expiry
return [
getErrorMessage({
store: STORE,
error: `Too many API requests. Deploy manually at https://addons.mozilla.org/developers/addons/${extId}/versions/submit/`,
actionName: errorActionOnFailure,
zip
})
];
}
// Some sort of client error
let errorMessage = getErrorMessage({
store: STORE,
error: JSON.stringify(e.response.data),
actionName: errorActionOnFailure,
zip
});
if (errorMessage.match(/release_notes.+The language code.+is invalid/)) {
errorMessage += " Supported language codes: https://github.com/mozilla/addons-server/blob/master/src/olympia/core/languages.py";
}
return [
errorMessage
];
}
}
}
function getJwtBlob({ jwtIssuer, jwtSecret }) {
const issuedAt = Math.floor(Date.now() / 1000);
const payload = {
iss: jwtIssuer,
jti: Math.random().toString(),
iat: issuedAt,
exp: issuedAt + SECONDS_TO_TOKEN_EXPIRY
};
return jwt.sign(payload, jwtSecret, { algorithm: "HS256" });
}
async function uploadZip({ zip, extId }) {
// https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#upload-create
const formData = new FormData();
formData.append("upload", fs.createReadStream(zip));
formData.append("channel", "listed");
const sendRequest = () => axios.post("upload/", formData, {
headers: {
"Content-Type": "multipart/form-data"
}
});
const [error, data] = await handleRequestWithBackOff({
zip,
sendRequest,
errorActionOnFailure: "upload zip for",
extId
});
if (error) {
return [error];
}
return [undefined, data];
}
async function createNewVersion({ slug, uuid, changelog, changelogLang, devChangelog, isVerbose, zip }) {
// https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#version-create
const { default_locale = changelogLang } = getExtJson(zip);
const sendRequest = async () => axios.post(`addon/${slug}/versions/`, {
upload: uuid,
...(changelog && {
release_notes: {
[default_locale.replaceAll("_", "-")]: changelog
}
}),
...(devChangelog && {
approval_notes: devChangelog
})
});
if (isVerbose) {
if (changelog) {
console.log(getVerboseMessage({
store: STORE,
message: `Adding changelog: ${changelog}`
}));
}
if (devChangelog) {
console.log(getVerboseMessage({
store: STORE,
message: `Adding changelog for reviewers: ${devChangelog}`
}));
}
}
const [error, data] = await handleRequestWithBackOff({
zip,
sendRequest,
errorActionOnFailure: "create new version of",
extId: slug
});
if (error) {
return [error];
}
return [undefined, data];
}
async function validateUpload({ zip, extId, uuid }) {
// https://mozilla.github.io/addons-server/topics/api/addons.html#upload-detail
const sendRequest = () => axios(`upload/${uuid}/`);
let data;
let error;
do {
[error, data] = await handleRequestWithBackOff({
zip,
sendRequest,
errorActionOnFailure: "verify upload of",
extId
});
if (error) {
return [error];
}
} while (!data.processed);
const errors = [];
for (const error of data.validation.messages || []) {
if (error.type === "error") {
errors.push(error.message);
}
}
if (errors.length > 0) {
return [errors.join("\n")];
}
return [undefined, data];
}
async function uploadSourceCodeIfNeeded({ slug, zipSource, version, zip }) {
// https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#version-sources
const formData = new FormData();
formData.append("source", fs.createReadStream(zipSource));
const sendRequest = async () => axios.patch(`addon/${slug}/versions/${version}/`, formData, {
headers: {
"Content-Type": "multipart/form-data"
}
});
const [error, data] = await handleRequestWithBackOff({
zip,
sendRequest,
errorActionOnFailure: "upload source code of",
extId: slug
});
if (error) {
return [error];
}
return [undefined, data];
}
export default async function deployToFirefox({ extId, jwtIssuer, jwtSecret, zip, zipSource = "", changelog = "", changelogLang = "en-US", devChangelog = "", verbose: isVerbose }) {
axios = Axios.create({
baseURL: `https://addons.mozilla.org/api/v5/addons/`,
headers: {
Authorization: `JWT ${getJwtBlob({ jwtIssuer, jwtSecret })}`
}
});
const { name } = getExtJson(zip);
if (isVerbose) {
console.log(getVerboseMessage({
store: STORE,
message: `Uploading zip of ${name} with extension ID ${extId}`
}));
}
// eslint-disable-next-line prefer-const
let [error, { uuid, version }] = await uploadZip({ zip, extId });
if (error) {
throw error;
}
if (isVerbose) {
console.log(getVerboseMessage({
store: STORE,
message: "Verifying upload"
}));
}
[error] = await validateUpload({ zip, extId, uuid });
if (error) {
throw error;
}
if (isVerbose) {
console.log(getVerboseMessage({
store: STORE,
message: `Creating a new version: ${version}`
}));
}
[error] = await createNewVersion({
slug: extId,
uuid,
changelog,
changelogLang,
devChangelog,
isVerbose,
zip
});
if (error) {
throw error;
}
if (isVerbose) {
console.log(getVerboseMessage({
store: STORE,
message: `Uploading source ZIP: ${zipSource}`
}));
}
[error] = await uploadSourceCodeIfNeeded({
slug: extId,
zipSource,
version,
zip
});
if (error) {
throw error;
}
logSuccessfullyPublished({ extId, store: STORE, zip });
return true;
}