feat: centralize candidate promotion pipelines
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

This commit is contained in:
2026-08-20 11:40:37 +02:00
parent 8f41d273d7
commit a4247d2697
7 changed files with 752 additions and 3 deletions
+175
View File
@@ -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;
}