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
+330
View File
@@ -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}.`);