feat: centralize candidate promotion pipelines
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user