176 lines
6.4 KiB
JavaScript
176 lines
6.4 KiB
JavaScript
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
function requiredString(value, name) {
|
||
const normalized = String(value ?? "").trim();
|
||
if (!normalized) throw new Error(`${name} is required.`);
|
||
return normalized;
|
||
}
|
||
|
||
function resolveRepositoryPath(rootDirectory, relativePath, name) {
|
||
const normalized = requiredString(relativePath, name).replace(/^\.\//, "");
|
||
if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) {
|
||
throw new Error(`${name} must stay inside the repository.`);
|
||
}
|
||
return path.join(rootDirectory, normalized);
|
||
}
|
||
|
||
export function parseReleaseNotes(source, filename = "release notes") {
|
||
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||
if (!match) throw new Error(`${filename} must start with YAML frontmatter.`);
|
||
const fields = {};
|
||
for (const line of match[1].split(/\r?\n/)) {
|
||
const separator = line.indexOf(":");
|
||
if (separator < 1) continue;
|
||
const key = line.slice(0, separator).trim();
|
||
const rawValue = line.slice(separator + 1).trim();
|
||
if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
|
||
try {
|
||
fields[key] = JSON.parse(rawValue);
|
||
} catch {
|
||
throw new Error(`${filename} frontmatter contains invalid quoted ${key}.`);
|
||
}
|
||
} else if (rawValue.startsWith("'") && rawValue.endsWith("'")) {
|
||
fields[key] = rawValue.slice(1, -1).replaceAll("''", "'");
|
||
} else {
|
||
fields[key] = rawValue;
|
||
}
|
||
}
|
||
for (const key of ["title", "releasedAt", "summary"]) {
|
||
if (!fields[key]) throw new Error(`${filename} frontmatter must include ${key}.`);
|
||
}
|
||
const body = match[2].trim();
|
||
if (!body) throw new Error(`${filename} must contain release notes.`);
|
||
return { ...fields, body };
|
||
}
|
||
|
||
export function loadRelease(rootDirectory, releaseConfig = {}) {
|
||
const versionFile = resolveRepositoryPath(
|
||
rootDirectory,
|
||
releaseConfig.versionFile ?? "content/releases/latest.json",
|
||
"Release version file",
|
||
);
|
||
const versionField = requiredString(releaseConfig.versionField ?? "version", "Release version field");
|
||
const metadata = JSON.parse(fs.readFileSync(versionFile, "utf8"));
|
||
const tag = requiredString(metadata[versionField], `Release ${versionField}`);
|
||
if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag)) {
|
||
throw new Error("Release version must be a SemVer tag such as v1.2.3.");
|
||
}
|
||
const notesPattern = requiredString(
|
||
releaseConfig.notesFile ?? "content/releases/$version.md",
|
||
"Release notes file",
|
||
);
|
||
const notesFile = resolveRepositoryPath(
|
||
rootDirectory,
|
||
notesPattern.replaceAll("$version", tag),
|
||
"Release notes file",
|
||
);
|
||
const notes = parseReleaseNotes(fs.readFileSync(notesFile, "utf8"), notesFile);
|
||
return {
|
||
tag,
|
||
release: {
|
||
tag_name: tag,
|
||
name: `${tag} – ${notes.title}`,
|
||
body: notes.body,
|
||
draft: false,
|
||
prerelease: tag.includes("-"),
|
||
},
|
||
};
|
||
}
|
||
|
||
export function resolveGiteaApiUrl(environment = process.env) {
|
||
const explicitApiUrl = String(
|
||
environment.GITEA_API_URL ?? environment.GITHUB_API_URL ?? "",
|
||
).trim();
|
||
if (explicitApiUrl) return explicitApiUrl.replace(/\/$/, "");
|
||
const serverUrl = String(
|
||
environment.GITEA_SERVER_URL ?? environment.GITHUB_SERVER_URL ?? "",
|
||
).trim();
|
||
if (!serverUrl) {
|
||
throw new Error("Gitea API URL is missing. Set an API URL or server URL.");
|
||
}
|
||
return `${serverUrl.replace(/\/$/, "")}/api/v1`;
|
||
}
|
||
|
||
async function readJson(response) {
|
||
const text = await response.text();
|
||
if (!text) return null;
|
||
try { return JSON.parse(text); } catch { return { message: text }; }
|
||
}
|
||
|
||
function repositoryParts(repository) {
|
||
const [owner, repo] = String(repository).split("/");
|
||
if (!owner || !repo) throw new Error("Gitea repository must use owner/name format.");
|
||
return { owner, repo };
|
||
}
|
||
|
||
function headers(token) {
|
||
return {
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${requiredString(token, "Gitea token")}`,
|
||
"Content-Type": "application/json",
|
||
};
|
||
}
|
||
|
||
export async function ensureTag({ apiUrl, repository, token, tag, target, fetchImpl = fetch }) {
|
||
const { owner, repo } = repositoryParts(repository);
|
||
const base = `${String(apiUrl).replace(/\/$/, "")}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tags`;
|
||
const requestHeaders = headers(token);
|
||
const existingResponse = await fetchImpl(`${base}/${encodeURIComponent(tag)}`, { headers: requestHeaders });
|
||
const existing = await readJson(existingResponse);
|
||
if (existingResponse.ok) {
|
||
const existingSha = existing?.commit?.sha ?? existing?.commit?.id;
|
||
if (existingSha !== target) {
|
||
throw new Error(`Release tag ${tag} already points at ${existingSha ?? "an unknown commit"}.`);
|
||
}
|
||
return existing;
|
||
}
|
||
if (existingResponse.status !== 404) {
|
||
throw new Error(`Reading Gitea tag failed with HTTP ${existingResponse.status}.`);
|
||
}
|
||
const response = await fetchImpl(base, {
|
||
method: "POST",
|
||
headers: requestHeaders,
|
||
body: JSON.stringify({ tag_name: tag, target, message: `Release ${tag}` }),
|
||
});
|
||
const created = await readJson(response);
|
||
if (!response.ok) {
|
||
throw new Error(`Creating Gitea tag failed with HTTP ${response.status}: ${created?.message ?? response.statusText}`);
|
||
}
|
||
return created;
|
||
}
|
||
|
||
export async function publishRelease({ apiUrl, repository, token, release, fetchImpl = fetch }) {
|
||
const { owner, repo } = repositoryParts(repository);
|
||
const base = `${String(apiUrl).replace(/\/$/, "")}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`;
|
||
const requestHeaders = headers(token);
|
||
const existingResponse = await fetchImpl(
|
||
`${base}/tags/${encodeURIComponent(release.tag_name)}`,
|
||
{ headers: requestHeaders },
|
||
);
|
||
const existing = await readJson(existingResponse);
|
||
let response;
|
||
if (existingResponse.status === 404) {
|
||
response = await fetchImpl(base, {
|
||
method: "POST",
|
||
headers: requestHeaders,
|
||
body: JSON.stringify(release),
|
||
});
|
||
} else if (existingResponse.ok) {
|
||
response = await fetchImpl(`${base}/${existing.id}`, {
|
||
method: "PATCH",
|
||
headers: requestHeaders,
|
||
body: JSON.stringify(release),
|
||
});
|
||
} else {
|
||
throw new Error(`Reading Gitea release failed with HTTP ${existingResponse.status}.`);
|
||
}
|
||
const published = await readJson(response);
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
`Publishing Gitea release failed with HTTP ${response.status}: ${published?.message ?? response.statusText}`,
|
||
);
|
||
}
|
||
return published;
|
||
}
|