Files
Platform-CI/.gitea/actions/quickstack-oci/release.mjs
T
vadimenovikau a4247d2697
Platform CI tests / QuickStack deploy action tests (push) Successful in 27s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 25s
feat: centralize candidate promotion pipelines
2026-08-20 11:40:37 +02:00

176 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}