Two release-pipeline fixes that previously forced manual workarounds. - scripts/release_gitea.mjs no longer unconditionally runs npm run dist:win. New --skip-build flag, plus auto-skip when all 3 required artifacts (Setup-<v>.exe, Setup-<v>.exe.blockmap, latest.yml) already exist for the requested version. The previous behaviour re-ran the entire test suite + electron-builder on every release attempt — unusable when the test path was broken. - playwright ^1.59.1 added to devDependencies. test:e2e / test:e2e:guide / test:e2e:full now invoke node scripts/smoke-test*.js directly instead of "npm exec --yes --package=playwright -- node ...", which failed with MODULE_NOT_FOUND when npm exec could not resolve playwright on the fly. No browser binaries needed — the smoke tests drive Electron via _electron, not a browser. All test paths verified after the change: test:e2e, test:e2e:guide, test:e2e:full, test:merge-split, test:e2e:update-logic — all pass with the simplified scripts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
7.1 KiB
JavaScript
188 lines
7.1 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
const NPM_EXECUTABLE = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
const BASE_URL = String(process.env.GITEA_BASE_URL || "https://git.24-music.de").replace(/\/+$/, "");
|
|
const OWNER = String(process.env.GITEA_REPO_OWNER || "Administrator").trim();
|
|
const REPO = String(process.env.GITEA_REPO_NAME || "Twitch-VOD-Manager").trim();
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
input: options.input,
|
|
stdio: options.capture ? ["pipe", "pipe", "pipe"] : "inherit"
|
|
});
|
|
if (result.status !== 0) {
|
|
const stderr = String(result.stderr || "").trim();
|
|
const stdout = String(result.stdout || "").trim();
|
|
const details = [stderr, stdout].filter(Boolean).join("\n");
|
|
throw new Error(`Command failed: ${command} ${args.join(" ")}${details ? `\n${details}` : ""}`);
|
|
}
|
|
return String(result.stdout || "");
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = argv.slice(2);
|
|
if (args.includes("--help") || args.includes("-h")) {
|
|
return { help: true };
|
|
}
|
|
const FLAGS = new Set(["--dry-run", "--skip-build"]);
|
|
const dryRun = args.includes("--dry-run");
|
|
const skipBuild = args.includes("--skip-build");
|
|
const positional = args.filter((arg) => !FLAGS.has(arg));
|
|
const version = positional[0] || "";
|
|
const notes = positional.slice(1).join(" ").trim();
|
|
return { help: false, dryRun, skipBuild, version, notes };
|
|
}
|
|
|
|
function ensureVersion(version) {
|
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version || "").trim())) {
|
|
throw new Error("Invalid version format. Expected e.g. 4.2.0");
|
|
}
|
|
return String(version).trim();
|
|
}
|
|
|
|
function getAuthHeader() {
|
|
const token = String(process.env.GITEA_TOKEN || process.env.FORGEJO_TOKEN || "").trim();
|
|
if (token) {
|
|
return `token ${token}`;
|
|
}
|
|
|
|
const output = run("git", ["credential", "fill"], {
|
|
capture: true,
|
|
input: `protocol=https\nhost=${new URL(BASE_URL).host}\n\n`
|
|
});
|
|
const map = new Map();
|
|
for (const line of output.split(/\r?\n/)) {
|
|
if (!line.includes("=")) continue;
|
|
const [key, value] = line.split("=", 2);
|
|
map.set(key, value);
|
|
}
|
|
const username = map.get("username");
|
|
const password = map.get("password");
|
|
if (!username || !password) {
|
|
throw new Error("Missing Gitea credentials. Set GITEA_TOKEN or configure git credential helper.");
|
|
}
|
|
return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
|
|
}
|
|
|
|
async function apiRequest(method, url, authHeader, body, contentType = "application/json") {
|
|
const headers = { Accept: "application/json", Authorization: authHeader };
|
|
if (body !== undefined) headers["Content-Type"] = contentType;
|
|
const response = await fetch(url, { method, headers, body });
|
|
const text = await response.text();
|
|
let parsed = null;
|
|
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
|
|
return { ok: response.ok, status: response.status, body: parsed };
|
|
}
|
|
|
|
function ensureAssets(version) {
|
|
const releaseDir = path.join(process.cwd(), "release");
|
|
const files = [
|
|
`Twitch-VOD-Manager-Setup-${version}.exe`,
|
|
`Twitch-VOD-Manager-Setup-${version}.exe.blockmap`,
|
|
"latest.yml"
|
|
];
|
|
for (const file of files) {
|
|
const fullPath = path.join(releaseDir, file);
|
|
if (!fs.existsSync(fullPath)) {
|
|
throw new Error(`Missing release artifact: ${fullPath}`);
|
|
}
|
|
}
|
|
return { releaseDir, files };
|
|
}
|
|
|
|
async function createOrGetRelease(baseApi, tag, authHeader, notes) {
|
|
const existing = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
|
|
if (existing.ok) return existing.body;
|
|
const payload = {
|
|
tag_name: tag,
|
|
target_commitish: "main",
|
|
name: tag,
|
|
body: notes || `Release ${tag}`,
|
|
draft: false,
|
|
prerelease: false
|
|
};
|
|
const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload));
|
|
if (!created.ok) {
|
|
throw new Error(`Failed to create release (${created.status}): ${JSON.stringify(created.body)}`);
|
|
}
|
|
return created.body;
|
|
}
|
|
|
|
async function uploadAssets(baseApi, releaseId, authHeader, releaseDir, files) {
|
|
for (const fileName of files) {
|
|
const filePath = path.join(releaseDir, fileName);
|
|
const fileData = fs.readFileSync(filePath);
|
|
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
|
|
const response = await apiRequest("POST", uploadUrl, authHeader, fileData, "application/octet-stream");
|
|
if (response.ok || response.status === 409 || response.status === 422) {
|
|
continue;
|
|
}
|
|
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(response.body)}`);
|
|
}
|
|
}
|
|
|
|
function hasAllArtifactsForVersion(version) {
|
|
const releaseDir = path.join(process.cwd(), "release");
|
|
const files = [
|
|
`Twitch-VOD-Manager-Setup-${version}.exe`,
|
|
`Twitch-VOD-Manager-Setup-${version}.exe.blockmap`,
|
|
"latest.yml"
|
|
];
|
|
return files.every((f) => fs.existsSync(path.join(releaseDir, f)));
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv);
|
|
if (args.help) {
|
|
process.stdout.write("Usage: npm run release:gitea -- <version> [release notes] [--skip-build] [--dry-run]\n");
|
|
process.stdout.write(" --skip-build skip dist:win when release/ already has the 3 required artifacts\n");
|
|
process.stdout.write(" (auto-skipped when artifacts already exist for this version)\n");
|
|
process.stdout.write("Env: GITEA_BASE_URL, GITEA_REPO_OWNER, GITEA_REPO_NAME, GITEA_TOKEN\n");
|
|
return;
|
|
}
|
|
|
|
const version = ensureVersion(args.version);
|
|
const tag = `v${version}`;
|
|
const authHeader = getAuthHeader();
|
|
const baseApi = `${BASE_URL}/api/v1/repos/${OWNER}/${REPO}`;
|
|
|
|
run("git", ["fetch", "--tags"]);
|
|
if (!args.dryRun) {
|
|
run("git", ["push", "origin", "main"]);
|
|
run("git", ["push", "origin", tag]);
|
|
}
|
|
|
|
// Skip the rebuild when the user passed --skip-build OR when all artifacts
|
|
// for this version are already on disk. The original unconditional dist:win
|
|
// re-ran the full test suite + electron-builder even when the .exe already
|
|
// existed, which made the script unusable when test:e2e was broken.
|
|
const artifactsExist = hasAllArtifactsForVersion(version);
|
|
const shouldBuild = !args.skipBuild && !artifactsExist;
|
|
if (shouldBuild) {
|
|
run(NPM_EXECUTABLE, ["run", "dist:win"]);
|
|
} else if (artifactsExist) {
|
|
process.stdout.write(`Skipping dist:win — artifacts for ${tag} already exist in release/\n`);
|
|
} else {
|
|
process.stdout.write(`Skipping dist:win (--skip-build)\n`);
|
|
}
|
|
|
|
const assets = ensureAssets(version);
|
|
if (args.dryRun) {
|
|
process.stdout.write(`Dry run complete for ${tag}\n`);
|
|
return;
|
|
}
|
|
|
|
const release = await createOrGetRelease(baseApi, tag, authHeader, args.notes);
|
|
await uploadAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
|
|
process.stdout.write(`Release published: ${release.html_url || `${BASE_URL}/${OWNER}/${REPO}/releases/tag/${tag}`}\n`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${String(error?.message || error)}\n`);
|
|
process.exit(1);
|
|
});
|