Fix Factorio mod release uploads with native fetch (#1080)

Fix Factorio mod release uploads with native fetch
This commit is contained in:
Arvids Godjuks authored and GitHub committed 2026-10-14 22:50:55 +03:00
1 parent 46e6445973
commit 346e1acc97
2 files changed
+112 -9

No files matched your search

+9 -9
View File
@@ -2,8 +2,9 @@
* Publish any mods that need to be published
*/
import FormData from "form-data";
import { openAsBlob } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import yargs from "yargs";
let DRY = false;
@@ -34,7 +35,7 @@ async function requestModReleases(mod) {
/**
* Upload a mod to the mod portal
*/
async function uploadModRelease(file, name) {
export async function uploadModRelease(file, name) {
// eslint-disable-next-line no-console
console.log(`Uploading new release ${file}`);
if (DRY) {
@@ -61,7 +62,7 @@ async function uploadModRelease(file, name) {
// Upload the new version of the mod
const uploadUrl = (await initResponse.json()).upload_url;
const uploadFormData = new FormData();
uploadFormData.append("file", (await fs.open(file)).createReadStream(file));
uploadFormData.append("file", await openAsBlob(file), path.basename(file));
const uploadResponse = await fetch(uploadUrl, {
method: "POST",
@@ -77,25 +78,24 @@ const regexModZip = /(\w+)_\d+.\d+.\d+\.zip/;
/**
* Publishes all mods that require publishing
*/
async function publishMods(dir) {
export async function publishMods(dir) {
const files = await fs.readdir(dir);
const mods = files
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
.map(file => regexModZip.exec(file))
.filter(file => Boolean(file));
const uploading = [];
for (const mod of mods) {
const [file, name] = mod;
const releases = await requestModReleases(name);
if (!releases.some(release => release.file_name === file)) {
uploading.push(uploadModRelease(file, name));
// Upload in version order so the newest variant is published last.
await uploadModRelease(path.join(dir, file), name);
} else {
// eslint-disable-next-line no-console
console.log(`Skipped ${file}`);
}
}
await Promise.all(uploading);
}
/**
@@ -121,7 +121,7 @@ async function main() {
.demandOption("mods-dir", "A path to the mods dir must be specified");
}, async argv => {
DRY = argv.dry;
publishMods(argv.modsDir);
await publishMods(argv.modsDir);
})
.strict()
.parse();
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { publishMods, uploadModRelease } from "../publish.js";
const execFileAsync = promisify(execFile);
describe("mod publishing", function() {
let originalFetch;
let directory;
beforeEach(async function() {
originalFetch = global.fetch;
await fs.mkdir("temp", { recursive: true });
directory = await fs.mkdtemp("temp/publish-test-");
});
afterEach(async function() {
global.fetch = originalFetch;
await fs.rm(directory, { recursive: true, force: true });
});
it("sends multipart mod and file fields accepted by native fetch", async function() {
const name = "multipart_test";
const filename = `${name}_1.0.0.zip`;
const contents = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff]);
const file = path.join(directory, filename);
await fs.writeFile(file, contents);
let calls = 0;
global.fetch = async (url, options) => {
calls += 1;
const request = new Request(url, options);
assert.match(request.headers.get("content-type"), /^multipart\/form-data; boundary=/);
const form = await request.formData();
if (calls === 1) {
assert.equal(url, "https://mods.factorio.com/api/v2/mods/releases/init_upload");
assert.equal(form.get("mod"), name);
return Response.json({ upload_url: "https://upload.example.test/release" });
}
assert.equal(url, "https://upload.example.test/release");
const uploaded = form.get("file");
assert.equal(uploaded.name, filename);
assert.deepEqual(Buffer.from(await uploaded.arrayBuffer()), contents);
return Response.json({ success: true });
};
await uploadModRelease(file, name);
assert.equal(calls, 2);
});
it("reads archives from the supplied directory and uploads unpublished versions in order", async function() {
const name = "ordered_publish_test";
for (const version of ["1.0.9", "1.0.10", "1.0.2"]) {
await fs.writeFile(path.join(directory, `${name}_${version}.zip`), version);
}
const uploaded = [];
let uploading = false;
global.fetch = async (url, options) => {
if (url === `https://mods.factorio.com/api/mods/${name}`) {
return Response.json({ releases: [{ file_name: `${name}_1.0.2.zip` }] });
}
const form = await new Request(url, options).formData();
if (url.endsWith("/init_upload")) {
assert.equal(uploading, false, "previous upload must complete first");
uploading = true;
return Response.json({ upload_url: "https://upload.example.test/release" });
}
const file = form.get("file");
uploaded.push([file.name, await file.text()]);
await new Promise(resolve => setImmediate(resolve));
uploading = false;
return Response.json({ success: true });
};
await publishMods(directory);
assert.deepEqual(uploaded, [
[`${name}_1.0.9.zip`, "1.0.9"],
[`${name}_1.0.10.zip`, "1.0.10"],
]);
});
it("runs the CLI in dry-run mode without uploading unpublished archives", async function() {
const filename = "cli_publish_test_1.0.0.zip";
await fs.writeFile(path.join(directory, filename), "dry-run archive");
const mockFetch = `
import assert from "node:assert/strict";
global.fetch = async (url, options = {}) => {
assert.equal(url, "https://mods.factorio.com/api/mods/cli_publish_test");
assert.equal(options.method ?? "GET", "GET", "dry-run must not upload");
return Response.json({ releases: [] });
};
`;
const { stdout, stderr } = await execFileAsync(process.execPath, [
"--import", `data:text/javascript,${encodeURIComponent(mockFetch)}`,
"publish.js", "factorio", directory, "--dry-run",
]);
assert.equal(stderr, "");
assert(stdout.includes("Requesting current releases of cli_publish_test"));
assert(stdout.includes(path.join(directory, filename)));
});
});