feat: centralize candidate promotion pipelines
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
name: Immutable QuickStack OCI deployment
|
||||
description: Build an OCI image once, publish it to Gitea and deploy its exact digest through QuickStack.
|
||||
description: Build immutable candidates, reuse their digests across apps and promote releases without rebuilding.
|
||||
inputs:
|
||||
config-path:
|
||||
description: Path to the repository deployment manifest.
|
||||
|
||||
@@ -6,6 +6,13 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
ensureTag,
|
||||
loadRelease,
|
||||
publishRelease,
|
||||
resolveGiteaApiUrl,
|
||||
} from "./release.mjs";
|
||||
|
||||
const SUCCESS = new Set(["DEPLOYED"]);
|
||||
const FAILURE = new Set(["ERROR", "SHUTDOWN"]);
|
||||
|
||||
@@ -327,6 +334,124 @@ export function validateTarget(target) {
|
||||
};
|
||||
}
|
||||
|
||||
export function expandTokens(value, context) {
|
||||
return String(value)
|
||||
.replaceAll("$sha12", context.sha.slice(0, 12))
|
||||
.replaceAll("$sha", context.sha)
|
||||
.replaceAll("$version", context.version ?? "");
|
||||
}
|
||||
|
||||
function validateImagePath(value, name) {
|
||||
const image = requiredString(value, name).toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9._/-]*$/.test(image)) throw new Error(`Invalid OCI image path ${image}.`);
|
||||
return image;
|
||||
}
|
||||
|
||||
export function validateArtifact(artifact) {
|
||||
const name = requiredString(artifact.name, "Artifact name");
|
||||
const requiredFiles = (artifact.requiredFiles ?? []).map((file) => {
|
||||
const normalized = requiredString(file, `Required container file for ${name}`);
|
||||
if (!/^\/[A-Za-z0-9._/-]+$/.test(normalized) || normalized.includes("..")) {
|
||||
throw new Error(`Required container file for ${name} must be a safe absolute path.`);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
const buildArgs = Object.fromEntries(
|
||||
Object.entries(artifact.buildArgs ?? {}).map(([key, value]) => {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid build argument ${key}.`);
|
||||
return [key, String(value)];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...artifact,
|
||||
name,
|
||||
image: validateImagePath(artifact.image, `Image for ${name}`),
|
||||
dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
|
||||
context: safeRelative(artifact.context ?? ".", `Build context for ${name}`),
|
||||
buildArgs,
|
||||
requiredFiles,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateApplication(application, artifactNames) {
|
||||
const name = requiredString(application.name, "Application name");
|
||||
const artifact = requiredString(application.artifact, `Artifact for ${name}`);
|
||||
if (!artifactNames.has(artifact)) throw new Error(`Application ${name} references unknown artifact ${artifact}.`);
|
||||
const dependsOn = (application.dependsOn ?? []).map((dependency) => requiredString(dependency, `Dependency for ${name}`));
|
||||
const deploymentAttempts = application.deploymentAttempts === undefined
|
||||
? undefined
|
||||
: Number(application.deploymentAttempts);
|
||||
const deploymentRetrySeconds = application.deploymentRetrySeconds === undefined
|
||||
? undefined
|
||||
: Number(application.deploymentRetrySeconds);
|
||||
if (deploymentAttempts !== undefined && (!Number.isInteger(deploymentAttempts) || deploymentAttempts < 1)) {
|
||||
throw new Error(`Deployment attempts for ${name} must be a positive integer.`);
|
||||
}
|
||||
if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) {
|
||||
throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`);
|
||||
}
|
||||
return {
|
||||
...application,
|
||||
name,
|
||||
artifact,
|
||||
appId: requiredString(application.appId, `QuickStack app ID for ${name}`),
|
||||
dependsOn,
|
||||
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
|
||||
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
|
||||
};
|
||||
}
|
||||
|
||||
export function orderApplications(applications) {
|
||||
const byName = new Map(applications.map((application) => [application.name, application]));
|
||||
if (byName.size !== applications.length) throw new Error("Application names must be unique.");
|
||||
for (const application of applications) {
|
||||
for (const dependency of application.dependsOn) {
|
||||
if (!byName.has(dependency)) throw new Error(`Application ${application.name} depends on unknown application ${dependency}.`);
|
||||
if (dependency === application.name) throw new Error(`Application ${application.name} cannot depend on itself.`);
|
||||
}
|
||||
}
|
||||
const ordered = [];
|
||||
const complete = new Set();
|
||||
while (ordered.length < applications.length) {
|
||||
const ready = applications.find(
|
||||
(application) => !complete.has(application.name) && application.dependsOn.every((dependency) => complete.has(dependency)),
|
||||
);
|
||||
if (!ready) throw new Error("Application dependencies contain a cycle.");
|
||||
ordered.push(ready);
|
||||
complete.add(ready.name);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function selectPipeline(config, branch) {
|
||||
if (config.version !== 2 || !Array.isArray(config.artifacts) || !Array.isArray(config.pipelines)) {
|
||||
throw new Error("Deployment manifest version 2 must contain artifacts[] and pipelines[].");
|
||||
}
|
||||
return config.pipelines.find((entry) => entry.branch === branch) ?? null;
|
||||
}
|
||||
|
||||
export function validateVersion2Pipeline(config, branch) {
|
||||
const pipeline = selectPipeline(config, branch);
|
||||
if (!pipeline) return null;
|
||||
const strategy = requiredString(pipeline.strategy, `Strategy for ${branch}`);
|
||||
if (!new Set(["candidate", "promote"]).has(strategy)) {
|
||||
throw new Error(`Unsupported strategy ${strategy} for ${branch}.`);
|
||||
}
|
||||
const artifacts = config.artifacts.map(validateArtifact);
|
||||
const artifactNames = new Set(artifacts.map((artifact) => artifact.name));
|
||||
if (artifactNames.size !== artifacts.length) throw new Error("Artifact names must be unique.");
|
||||
const applications = orderApplications(
|
||||
(pipeline.applications ?? []).map((application) => validateApplication(application, artifactNames)),
|
||||
);
|
||||
if (!applications.length && strategy !== "candidate") {
|
||||
throw new Error(`Pipeline ${pipeline.name ?? branch} has no applications.`);
|
||||
}
|
||||
if (strategy === "promote" && !pipeline.release) {
|
||||
throw new Error(`Promotion pipeline ${pipeline.name ?? branch} requires release configuration.`);
|
||||
}
|
||||
return { ...pipeline, strategy, artifacts, applications };
|
||||
}
|
||||
|
||||
export function resolveDeploymentBranch({ eventName, environment = process.env, eventPayload } = {}) {
|
||||
if (eventName !== "pull_request") {
|
||||
return requiredString(environment.GITHUB_REF_NAME || environment.GITEA_REF_NAME, "Git branch");
|
||||
@@ -353,6 +478,200 @@ function appendSummary(text) {
|
||||
if (summary) fs.appendFileSync(summary, `${text}\n`);
|
||||
}
|
||||
|
||||
function dockerLogin(registry, dockerEnv) {
|
||||
run(
|
||||
"docker",
|
||||
["login", registry, "--username", requiredString(process.env.REGISTRY_USERNAME, "Registry username"), "--password-stdin"],
|
||||
{ env: dockerEnv, input: requiredString(process.env.REGISTRY_TOKEN, "Registry token") },
|
||||
);
|
||||
}
|
||||
|
||||
function exactDigestFromPush(pushOutput, taggedImage) {
|
||||
const digest = [...pushOutput.matchAll(/digest:\s*(sha256:[0-9a-f]{64})/g)].at(-1)?.[1];
|
||||
if (!digest) throw new Error(`Registry did not return a digest for ${taggedImage}.`);
|
||||
return digest;
|
||||
}
|
||||
|
||||
function verifyRequiredContainerFiles(taggedImage, requiredFiles, options) {
|
||||
for (const filename of requiredFiles) {
|
||||
run("docker", ["run", "--rm", "--entrypoint", "test", taggedImage, "-s", filename], options);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validationOnly }) {
|
||||
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`;
|
||||
const buildArgs = [
|
||||
"build", "--progress=plain", "--file", artifact.dockerfile,
|
||||
"--label", `org.opencontainers.image.source=${process.env.GITHUB_SERVER_URL ?? process.env.GITEA_SERVER_URL}/${process.env.GITHUB_REPOSITORY ?? process.env.GITEA_REPOSITORY}`,
|
||||
"--label", `org.opencontainers.image.revision=${sha}`,
|
||||
"--tag", taggedImage,
|
||||
];
|
||||
for (const [key, value] of Object.entries(artifact.buildArgs)) {
|
||||
buildArgs.push("--build-arg", `${key}=${expandTokens(value, { sha })}`);
|
||||
}
|
||||
buildArgs.push(artifact.context);
|
||||
console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`);
|
||||
run("docker", buildArgs, { cwd: workspace, env: dockerEnv });
|
||||
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
||||
if (validationOnly) return { artifact, taggedImage, exactImage: null, digest: null };
|
||||
const pushOutput = run("docker", ["push", taggedImage], { cwd: workspace, env: dockerEnv, capture: true });
|
||||
process.stdout.write(pushOutput);
|
||||
const digest = exactDigestFromPush(pushOutput, taggedImage);
|
||||
return { artifact, taggedImage, digest, exactImage: `${registry}/${artifact.image}@${digest}` };
|
||||
}
|
||||
|
||||
function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, dockerEnv }) {
|
||||
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
|
||||
run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
|
||||
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
||||
const repoDigests = run(
|
||||
"docker",
|
||||
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
|
||||
{ cwd: workspace, env: dockerEnv, capture: true },
|
||||
).split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
||||
const prefix = `${registry}/${artifact.image}@`;
|
||||
const exactImage = repoDigests.find((value) => value.startsWith(prefix));
|
||||
if (!exactImage || !/@sha256:[0-9a-f]{64}$/.test(exactImage)) {
|
||||
throw new Error(`Could not resolve an immutable candidate digest for ${taggedImage}.`);
|
||||
}
|
||||
return { artifact, taggedImage, exactImage, digest: exactImage.slice(exactImage.lastIndexOf("@") + 1) };
|
||||
}
|
||||
|
||||
function resolvePromotionSource(workspace, source = {}) {
|
||||
const mergeParent = Number(source.mergeParent ?? 2);
|
||||
if (!Number.isInteger(mergeParent) || mergeParent < 1) {
|
||||
throw new Error("Promotion mergeParent must be a positive integer.");
|
||||
}
|
||||
let sourceSha;
|
||||
try {
|
||||
sourceSha = run("git", ["rev-parse", `HEAD^${mergeParent}`], { cwd: workspace, capture: true }).trim();
|
||||
} catch {
|
||||
throw new Error(`Promotion requires a merge commit with parent ${mergeParent}.`);
|
||||
}
|
||||
if (source.requireTreeMatch !== false) {
|
||||
const releaseTree = run("git", ["rev-parse", "HEAD^{tree}"], { cwd: workspace, capture: true }).trim();
|
||||
const candidateTree = run("git", ["rev-parse", `${sourceSha}^{tree}`], { cwd: workspace, capture: true }).trim();
|
||||
if (releaseTree !== candidateTree) {
|
||||
throw new Error("The production merge tree differs from its tested candidate parent.");
|
||||
}
|
||||
}
|
||||
return sourceSha;
|
||||
}
|
||||
|
||||
function promoteArtifactAliases(artifacts, releaseTag, options) {
|
||||
for (const artifact of artifacts.values()) {
|
||||
const releaseImage = `${artifact.exactImage.slice(0, artifact.exactImage.lastIndexOf("@"))}:${releaseTag}`;
|
||||
run("docker", ["tag", artifact.taggedImage, releaseImage], options);
|
||||
const pushOutput = run("docker", ["push", releaseImage], { ...options, capture: true });
|
||||
process.stdout.write(pushOutput);
|
||||
const releaseDigest = exactDigestFromPush(pushOutput, releaseImage);
|
||||
if (releaseDigest !== artifact.digest) {
|
||||
throw new Error(`Release alias ${releaseImage} does not match the verified candidate digest.`);
|
||||
}
|
||||
console.log(`Promoted ${artifact.exactImage} as ${releaseImage}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deployApplications({ applications, artifacts, sha, client }) {
|
||||
for (const application of applications) {
|
||||
const artifact = artifacts.get(application.artifact);
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
|
||||
);
|
||||
const result = await deployExactImage({
|
||||
client,
|
||||
appId: application.appId,
|
||||
image: artifact.exactImage,
|
||||
registryUsername: process.env.REGISTRY_USERNAME,
|
||||
registryToken: process.env.REGISTRY_TOKEN,
|
||||
environment,
|
||||
healthCheckTcpPort: application.healthCheckTcpPort,
|
||||
postflight: application.postflight,
|
||||
deploymentAttempts: application.deploymentAttempts,
|
||||
deploymentRetrySeconds: application.deploymentRetrySeconds,
|
||||
sha,
|
||||
});
|
||||
console.log(`Deployed ${application.name}: ${result.image}`);
|
||||
appendSummary(`- ${application.name}: \`${result.image}\` (${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeVersion2({ config, pipeline, eventName, sha, workspace }) {
|
||||
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
|
||||
const validationOnly = eventName === "pull_request";
|
||||
const skipMarker = pipeline.strategy === "promote"
|
||||
? String(pipeline.release?.skipMarker ?? "[skip prod-release]")
|
||||
: "[skip quickstack-deploy]";
|
||||
if (!validationOnly && eventName === "push" && commitMessage.includes(skipMarker)) {
|
||||
console.log(`Deployment intentionally skipped by commit marker ${skipMarker}.`);
|
||||
return;
|
||||
}
|
||||
const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, "");
|
||||
const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-"));
|
||||
const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig };
|
||||
try {
|
||||
if (!validationOnly) dockerLogin(registry, dockerEnv);
|
||||
if (validationOnly || pipeline.strategy === "candidate") {
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
artifacts.set(artifact.name, buildArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
validationOnly,
|
||||
}));
|
||||
}
|
||||
if (validationOnly) {
|
||||
appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`);
|
||||
return;
|
||||
}
|
||||
const client = createQuickStackClient({
|
||||
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||
token: process.env.QUICKSTACK_API_TOKEN,
|
||||
});
|
||||
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceSha = resolvePromotionSource(workspace, pipeline.source);
|
||||
const release = loadRelease(workspace, pipeline.release);
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sourceSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
}));
|
||||
}
|
||||
if (eventName !== "push") {
|
||||
console.log(`Validated ${release.tag} against candidate ${sourceSha}; manual promotion does not mutate Production.`);
|
||||
appendSummary(`Validated release ${release.tag} against candidate \`${sourceSha}\` without deployment.`);
|
||||
return;
|
||||
}
|
||||
const gitea = {
|
||||
apiUrl: resolveGiteaApiUrl(),
|
||||
repository: requiredString(process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY, "Gitea repository"),
|
||||
token: requiredString(process.env.GITEA_TOKEN, "Gitea token"),
|
||||
};
|
||||
await ensureTag({ ...gitea, tag: release.tag, target: sha });
|
||||
promoteArtifactAliases(artifacts, release.tag, { cwd: workspace, env: dockerEnv });
|
||||
const client = createQuickStackClient({
|
||||
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||
token: process.env.QUICKSTACK_API_TOKEN,
|
||||
});
|
||||
await deployApplications({ applications: pipeline.applications, artifacts, sha: sourceSha, client });
|
||||
const published = await publishRelease({ ...gitea, release: release.release });
|
||||
console.log(`Published Gitea release ${release.tag}: ${published?.html_url ?? "success"}`);
|
||||
appendSummary(`- Release: ${release.tag}`);
|
||||
} finally {
|
||||
fs.rmSync(dockerConfig, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
|
||||
const configPath = path.resolve(workspace, process.env.QUICKSTACK_DEPLOY_CONFIG ?? ".quickstack/deploy.json");
|
||||
@@ -363,6 +682,17 @@ async function main() {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const eventName = process.env.GITHUB_EVENT_NAME ?? process.env.GITEA_EVENT_NAME ?? "";
|
||||
const branch = resolveDeploymentBranch({ eventName });
|
||||
if (config.version === 2) {
|
||||
const pipeline = validateVersion2Pipeline(config, branch);
|
||||
if (!pipeline) {
|
||||
console.log(`No deployment is declared for branch ${branch}.`);
|
||||
return;
|
||||
}
|
||||
const sha = requiredString(process.env.GITHUB_SHA ?? process.env.GITEA_SHA, "Git SHA").toLowerCase();
|
||||
if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new Error("Git SHA must be a full hexadecimal commit ID.");
|
||||
await executeVersion2({ config, pipeline, eventName, sha, workspace });
|
||||
return;
|
||||
}
|
||||
const deployment = selectDeployment(config, branch);
|
||||
if (!deployment) {
|
||||
console.log(`No deployment is declared for branch ${branch}.`);
|
||||
|
||||
@@ -3,11 +3,17 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
deployExactImage,
|
||||
expandTokens,
|
||||
mergeEnvironment,
|
||||
orderApplications,
|
||||
resolveDeploymentBranch,
|
||||
selectDeployment,
|
||||
selectPipeline,
|
||||
toSavePayload,
|
||||
validateArtifact,
|
||||
validateApplication,
|
||||
validateTarget,
|
||||
validateVersion2Pipeline,
|
||||
verifyEndpoint,
|
||||
} from "./deploy.mjs";
|
||||
|
||||
@@ -65,6 +71,63 @@ test("validates OCI paths and repository-local build paths", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("version 2 reuses one artifact across ordered applications", () => {
|
||||
const config = {
|
||||
version: 2,
|
||||
artifacts: [
|
||||
{ name: "webapp", image: "InnoMieter/Webapp", requiredFiles: ["/app/server.js"] },
|
||||
{ name: "thumbnail", image: "innomieter/thumbnail", dockerfile: "Dockerfile.thumbnail" },
|
||||
],
|
||||
pipelines: [
|
||||
{
|
||||
branch: "staging",
|
||||
strategy: "candidate",
|
||||
applications: [
|
||||
{ name: "worker", artifact: "webapp", appId: "app-worker", dependsOn: ["web"] },
|
||||
{ name: "web", artifact: "webapp", appId: "app-web" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(selectPipeline(config, "staging")?.strategy, "candidate");
|
||||
const pipeline = validateVersion2Pipeline(config, "staging");
|
||||
assert.deepEqual(pipeline.artifacts.map((artifact) => artifact.name), ["webapp", "thumbnail"]);
|
||||
assert.deepEqual(pipeline.applications.map((application) => application.name), ["web", "worker"]);
|
||||
assert.equal(pipeline.applications[1].artifact, "webapp");
|
||||
});
|
||||
|
||||
test("version 2 validates promotion contracts and token expansion", () => {
|
||||
const sha = "1234567890abcdef1234567890abcdef12345678";
|
||||
assert.equal(expandTokens("build-$sha12-$version", { sha, version: "v1.2.3" }), "build-1234567890ab-v1.2.3");
|
||||
assert.throws(
|
||||
() => validateVersion2Pipeline({
|
||||
version: 2,
|
||||
artifacts: [{ name: "web", image: "owner/web" }],
|
||||
pipelines: [{ branch: "prod", strategy: "promote", applications: [{ name: "web", artifact: "web", appId: "app" }] }],
|
||||
}, "prod"),
|
||||
/requires release configuration/,
|
||||
);
|
||||
assert.throws(() => validateArtifact({ name: "web", image: "owner/web", requiredFiles: ["../secret"] }), /safe absolute path/);
|
||||
assert.throws(
|
||||
() => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])),
|
||||
/unknown artifact/,
|
||||
);
|
||||
});
|
||||
|
||||
test("application dependencies reject missing nodes and cycles", () => {
|
||||
assert.throws(
|
||||
() => orderApplications([{ name: "web", dependsOn: ["missing"] }]),
|
||||
/unknown application/,
|
||||
);
|
||||
assert.throws(
|
||||
() => orderApplications([
|
||||
{ name: "web", dependsOn: ["worker"] },
|
||||
{ name: "worker", dependsOn: ["web"] },
|
||||
]),
|
||||
/cycle/,
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves response-only fields and existing environment secrets safely", () => {
|
||||
const payload = toSavePayload(app());
|
||||
assert.equal(payload.createdAt, undefined);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ensureTag,
|
||||
loadRelease,
|
||||
parseReleaseNotes,
|
||||
publishRelease,
|
||||
resolveGiteaApiUrl,
|
||||
} from "./release.mjs";
|
||||
|
||||
function response(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
test("loads canonical release metadata and notes", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "platform-ci-release-"));
|
||||
fs.mkdirSync(path.join(root, "content/releases"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "content/releases/latest.json"), '{"version":"v1.2.3"}');
|
||||
fs.writeFileSync(path.join(root, "content/releases/v1.2.3.md"), `---
|
||||
title: "Stable release"
|
||||
releasedAt: '2026-08-20'
|
||||
summary: "Release summary"
|
||||
---
|
||||
## Changes
|
||||
|
||||
Everything works.`);
|
||||
const loaded = loadRelease(root);
|
||||
assert.equal(loaded.tag, "v1.2.3");
|
||||
assert.equal(loaded.release.name, "v1.2.3 – Stable release");
|
||||
assert.match(loaded.release.body, /^## Changes/);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("parses canonical frontmatter and rejects missing fields", () => {
|
||||
assert.equal(parseReleaseNotes(`---
|
||||
title: Stable
|
||||
releasedAt: 2026-08-20
|
||||
summary: Done
|
||||
---
|
||||
Notes`).title, "Stable");
|
||||
assert.throws(() => parseReleaseNotes("No frontmatter"), /frontmatter/);
|
||||
});
|
||||
|
||||
test("resolves the Gitea API URL from compatible runner variables", () => {
|
||||
assert.equal(resolveGiteaApiUrl({ GITEA_API_URL: "https://gitea.example/api/v1/" }), "https://gitea.example/api/v1");
|
||||
assert.equal(resolveGiteaApiUrl({ GITHUB_SERVER_URL: "https://gitea.example" }), "https://gitea.example/api/v1");
|
||||
});
|
||||
|
||||
test("creates a missing tag and preserves an existing matching tag", async () => {
|
||||
const requests = [];
|
||||
const created = await ensureTag({
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
token: "secret",
|
||||
tag: "v1.2.3",
|
||||
target: "a".repeat(40),
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
if (requests.length === 1) return response(404, { message: "missing" });
|
||||
return response(201, { name: "v1.2.3", commit: { sha: "a".repeat(40) } });
|
||||
},
|
||||
});
|
||||
assert.equal(created.name, "v1.2.3");
|
||||
assert.equal(requests[1].options.method, "POST");
|
||||
|
||||
await ensureTag({
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
token: "secret",
|
||||
tag: "v1.2.3",
|
||||
target: "a".repeat(40),
|
||||
fetchImpl: async () => response(200, { commit: { sha: "a".repeat(40) } }),
|
||||
});
|
||||
});
|
||||
|
||||
test("refuses to reuse a tag that points at another commit", async () => {
|
||||
await assert.rejects(
|
||||
ensureTag({
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
token: "secret",
|
||||
tag: "v1.2.3",
|
||||
target: "a".repeat(40),
|
||||
fetchImpl: async () => response(200, { commit: { sha: "b".repeat(40) } }),
|
||||
}),
|
||||
/already points/,
|
||||
);
|
||||
});
|
||||
|
||||
test("creates and updates releases idempotently", async () => {
|
||||
const release = { tag_name: "v1.2.3", name: "Stable", body: "Notes", draft: false, prerelease: false };
|
||||
const createRequests = [];
|
||||
await publishRelease({
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
token: "secret",
|
||||
release,
|
||||
fetchImpl: async (url, options) => {
|
||||
createRequests.push({ url, options });
|
||||
return createRequests.length === 1 ? response(404, {}) : response(201, { id: 7 });
|
||||
},
|
||||
});
|
||||
assert.equal(createRequests[1].options.method, "POST");
|
||||
|
||||
const updateRequests = [];
|
||||
await publishRelease({
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
token: "secret",
|
||||
release,
|
||||
fetchImpl: async (url, options) => {
|
||||
updateRequests.push({ url, options });
|
||||
return updateRequests.length === 1 ? response(200, { id: 9 }) : response(200, { id: 9 });
|
||||
},
|
||||
});
|
||||
assert.equal(updateRequests[1].options.method, "PATCH");
|
||||
});
|
||||
@@ -2,6 +2,8 @@ name: Immutable QuickStack OCI deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -14,7 +16,8 @@ jobs:
|
||||
name: Build once and deploy exact digest
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
releases: write
|
||||
steps:
|
||||
- name: Checkout consuming repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -30,3 +33,4 @@ jobs:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
QUICKSTACK_API_TOKEN: ${{ secrets.QUICKSTACK_API_TOKEN }}
|
||||
QUICKSTACK_BASE_URL: https://server.nuvisphere.de
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
Reference in New Issue
Block a user