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; }