scripts/prepare-release-assets.mjs

import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { lstat, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";

const checksumName = "SHA256SUMS.txt";

export function releaseAssetNames(version) {
  if (typeof version !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
    throw new Error("Expected an exact stable version (for example 1.2.3, without a v prefix)");
  }
  return [
    `Emc2-${version}-win-x64.exe`,
    `Emc2-${version}-mac-arm64.dmg`,
    `Emc2-${version}-mac-x64.dmg`,
    `Emc2-${version}-linux-x86_64.AppImage`,
    "latest.yml",
    "latest-linux.yml",
  ].sort();
}

// This checks the merged publication set. The per-platform updater check still
// owns manifest parsing and version, architecture, size and SHA512 verification.
export async function prepareReleaseAssets(directory, version) {
  const expected = releaseAssetNames(version);
  if (typeof directory !== "string" || !directory) throw new Error("Expected an asset directory");
  const entries = await readdir(directory);
  const missing = expected.filter((name) => !entries.includes(name));
  const extra = entries.filter((name) => !expected.includes(name) && name !== checksumName).sort();
  if (missing.length || extra.length) {
    throw new Error(`Invalid release asset set; missing: ${missing.join(", ") || "none"}; unexpected: ${extra.join(", ") || "none"}`);
  }
  for (const name of entries) {
    const stat = await lstat(path.join(directory, name));
    if (!stat.isFile() || stat.isSymbolicLink()) {
      throw new Error(`Release asset must be a regular non-symlink file: ${name}`);
    }
    if (name !== checksumName && stat.size === 0) {
      throw new Error(`Release asset must not be empty: ${name}`);
    }
  }

  const lines = [];
  for (const name of expected) {
    const hash = createHash("sha256");
    for await (const chunk of createReadStream(path.join(directory, name))) hash.update(chunk);
    lines.push(`${hash.digest("hex")}  ${name}\n`);
  }
  // Only replace an old checksum after the entire set validates and hashes.
  // A same-filesystem rename avoids leaving a partial checksum on failure.
  const temporary = await mkdtemp(path.join(directory, ".release-checksums-"));
  try {
    const staged = path.join(temporary, checksumName);
    await writeFile(staged, lines.join(""), { flag: "wx" });
    await rename(staged, path.join(directory, checksumName));
  } finally {
    await rm(temporary, { recursive: true, force: true });
  }
  return expected;
}

if (import.meta.main) {
  try {
    if (process.argv.length !== 4) {
      throw new Error("Usage: node scripts/prepare-release-assets.mjs <directory> <stable-version>");
    }
    const assets = await prepareReleaseAssets(process.argv[2], process.argv[3]);
    console.log(`PASS: validated ${assets.length} release assets and wrote ${checksumName}`);
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}