Author SHA1 Message Date
vadimenovikau 8f8110fb67 ci: wait for concurrent candidate publication
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 23s
2026-08-24 09:40:35 +02:00
vadimenovikau b6a550e9a3 Expose SMTP secrets to QuickStack deployments
Platform CI tests / QuickStack deploy action tests (push) Successful in 29s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 32s
2026-08-22 19:52:58 +00:00
vadimenovikau 3a9bd879e6 fix(ci): forward Web Push secrets to deployments
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 26s
2026-08-22 11:46:46 +00:00
vadimenovikau efc0421ac1 fix(ci): forward Redis URL to QuickStack deployments
Platform CI tests / QuickStack deploy action tests (push) Successful in 26s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 27s
2026-08-22 11:40:57 +00:00
vadimenovikau 70b3375617 feat(ci): support explicit runtime secret mapping
Platform CI tests / QuickStack deploy action tests (push) Successful in 28s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 27s
Adds validated opt-in mapping from Gitea Actions secrets to QuickStack runtime environment variables.
2026-08-21 13:32:10 +00:00
vadimenovikau f4ffb28018 ci: reuse candidates for production pull requests
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 28s
2026-08-20 16:30:23 +02:00
vadimenovikau a4247d2697 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
2026-08-20 11:40:37 +02:00
vadimenovikau 8f41d273d7 docs: document centralized organization workflows
Platform CI tests / QuickStack deploy action tests (push) Successful in 23s
2026-08-20 08:48:07 +00:00
vadimenovikau d3c0b76329 ci: centralize QuickStack action source
Platform CI tests / QuickStack deploy action tests (push) Successful in 28s
2026-08-20 08:48:06 +00:00
vadimenovikau 1cebf34ea7 fix: wait for QuickStack deployment visibility
Platform CI tests / QuickStack deploy action tests (push) Successful in 23s
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 22s
2026-08-18 17:44:54 +00:00
vadimenovikau d2a611aee6 fix: wait for QuickStack deployment visibility
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 30s
Platform CI tests / QuickStack deploy action tests (pull_request) Successful in 22s
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (pull_request) Successful in 21s
2026-08-18 19:39:59 +02:00
vadimenovikau c27dce00b3 ci: retry transient QuickStack rollouts
Platform CI tests / QuickStack deploy action tests (push) Successful in 23s
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 23s
2026-08-18 17:35:45 +00:00
vadimenovikau 2d3c7500b1 ci: retry transient QuickStack rollouts
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 24s
Platform CI tests / QuickStack deploy action tests (pull_request) Successful in 20s
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (pull_request) Successful in 19s
2026-08-18 19:31:28 +02:00
8 changed files with 1059 additions and 19 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: Immutable QuickStack OCI deployment 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: inputs:
config-path: config-path:
description: Path to the repository deployment manifest. description: Path to the repository deployment manifest.
+461 -2
View File
@@ -6,6 +6,13 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
ensureTag,
loadRelease,
publishRelease,
resolveGiteaApiUrl,
} from "./release.mjs";
const SUCCESS = new Set(["DEPLOYED"]); const SUCCESS = new Set(["DEPLOYED"]);
const FAILURE = new Set(["ERROR", "SHUTDOWN"]); const FAILURE = new Set(["ERROR", "SHUTDOWN"]);
@@ -80,6 +87,33 @@ export function mergeEnvironment(source, overrides = {}) {
return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n"); return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n");
} }
export function resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) {
if (
secretEnvironment === null ||
typeof secretEnvironment !== "object" ||
Array.isArray(secretEnvironment)
) {
throw new Error("secretEnvironment must be an object.");
}
const resolved = {};
for (const [runtimeKey, rawSecretName] of Object.entries(secretEnvironment)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(runtimeKey)) {
throw new Error(`Invalid secret environment key ${runtimeKey}.`);
}
const secretName = String(rawSecretName);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretName)) {
throw new Error(`Invalid Actions secret name for ${runtimeKey}.`);
}
const value = environment[secretName];
if (value === undefined || String(value).length === 0) {
throw new Error(`Missing Actions secret ${secretName} for runtime environment ${runtimeKey}.`);
}
resolved[runtimeKey] = String(value);
}
return resolved;
}
async function readBody(response) { async function readBody(response) {
const text = await response.text(); const text = await response.text();
if (!text) return null; if (!text) return null;
@@ -101,7 +135,10 @@ export function createQuickStackClient({ baseUrl, token, fetchImpl = fetch }) {
}); });
const parsed = await readBody(response); const parsed = await readBody(response);
if (!response.ok) { if (!response.ok) {
throw new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${parsed?.detail ?? response.statusText}`); throw Object.assign(
new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${parsed?.detail ?? response.statusText}`),
{ statusCode: response.status },
);
} }
return parsed; return parsed;
} }
@@ -118,7 +155,17 @@ export function createQuickStackClient({ baseUrl, token, fetchImpl = fetch }) {
async function waitForDeployment(client, appId, deploymentId, sleep) { async function waitForDeployment(client, appId, deploymentId, sleep) {
for (let attempt = 1; attempt <= 180; attempt += 1) { for (let attempt = 1; attempt <= 180; attempt += 1) {
const deployment = await client.getDeployment(appId, deploymentId); let deployment;
try {
deployment = await client.getDeployment(appId, deploymentId);
} catch (error) {
if (error?.statusCode === 404) {
console.log(`QuickStack deployment ${deploymentId}: NOT_VISIBLE_YET (${attempt}/180)`);
await sleep(5_000);
continue;
}
throw error;
}
const status = String(deployment?.status ?? "UNKNOWN").toUpperCase(); const status = String(deployment?.status ?? "UNKNOWN").toUpperCase();
console.log(`QuickStack deployment ${deploymentId}: ${status} (${attempt}/180)`); console.log(`QuickStack deployment ${deploymentId}: ${status} (${attempt}/180)`);
if (SUCCESS.has(status)) return deployment; if (SUCCESS.has(status)) return deployment;
@@ -314,6 +361,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 } = {}) { export function resolveDeploymentBranch({ eventName, environment = process.env, eventPayload } = {}) {
if (eventName !== "pull_request") { if (eventName !== "pull_request") {
return requiredString(environment.GITHUB_REF_NAME || environment.GITEA_REF_NAME, "Git branch"); return requiredString(environment.GITHUB_REF_NAME || environment.GITEA_REF_NAME, "Git branch");
@@ -335,11 +500,294 @@ export function resolveDeploymentBranch({ eventName, environment = process.env,
); );
} }
export function resolvePullRequestHeadBranch({ environment = process.env, eventPayload } = {}) {
const direct = String(environment.GITHUB_HEAD_REF || environment.GITEA_HEAD_REF || "").trim();
if (direct) return direct;
let payload = eventPayload;
if (payload === undefined) {
const eventPath = String(environment.GITHUB_EVENT_PATH || environment.GITEA_EVENT_PATH || "").trim();
if (eventPath) {
payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
}
}
return requiredString(payload?.pull_request?.head?.ref, "Pull request head branch");
}
export function classifyVersion2Execution(pipeline, eventName) {
if (eventName === "pull_request") {
return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation";
}
return pipeline.strategy === "candidate" ? "build-deploy" : "promote";
}
export function validatePromotionPullRequestSource(pipeline, headBranch) {
const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch");
const actualHeadBranch = requiredString(headBranch, "Pull request head branch");
if (actualHeadBranch !== sourceBranch) {
throw new Error(`Production promotion pull requests must originate from ${sourceBranch}, not ${actualHeadBranch}.`);
}
return sourceBranch;
}
function appendSummary(text) { function appendSummary(text) {
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY; const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
if (summary) fs.appendFileSync(summary, `${text}\n`); 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}` };
}
const PROMOTION_PR_CANDIDATE_PULL_ATTEMPTS = 40;
const PROMOTION_PR_CANDIDATE_PULL_RETRY_MS = 10_000;
export async function pullCandidateArtifact({
artifact,
registry,
sourceSha,
workspace,
dockerEnv,
pullAttempts = 1,
pullRetryMs = 0,
runCommand = run,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
}) {
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
if (!Number.isInteger(pullAttempts) || pullAttempts < 1) {
throw new Error("Candidate pull attempts must be a positive integer.");
}
if (!Number.isFinite(pullRetryMs) || pullRetryMs < 0) {
throw new Error("Candidate pull retry delay must be a non-negative number.");
}
for (let attempt = 1; attempt <= pullAttempts; attempt += 1) {
try {
runCommand("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
break;
} catch (error) {
if (attempt === pullAttempts) {
throw new Error(
`Candidate ${taggedImage} did not become available after ${pullAttempts} attempt(s).`,
{ cause: error },
);
}
console.log(
`Candidate ${taggedImage} is not available yet (${attempt}/${pullAttempts}); retrying in ${pullRetryMs}ms.`,
);
await sleep(pullRetryMs);
}
}
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
const repoDigests = runCommand(
"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 })]),
),
...resolveSecretEnvironment(application.secretEnvironment),
};
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 };
const execution = classifyVersion2Execution(pipeline, eventName);
try {
if (execution !== "build-validation") dockerLogin(registry, dockerEnv);
if (execution === "build-validation" || execution === "build-deploy") {
const artifacts = new Map();
for (const artifact of pipeline.artifacts) {
artifacts.set(artifact.name, buildArtifact({
artifact,
registry,
sha,
workspace,
dockerEnv,
validationOnly: execution === "build-validation",
}));
}
if (execution === "build-validation") {
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;
}
if (execution === "validate-candidate") {
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
const release = loadRelease(workspace, pipeline.release);
const artifacts = new Map();
for (const artifact of pipeline.artifacts) {
artifacts.set(artifact.name, await pullCandidateArtifact({
artifact,
registry,
sourceSha: sha,
workspace,
dockerEnv,
pullAttempts: PROMOTION_PR_CANDIDATE_PULL_ATTEMPTS,
pullRetryMs: PROMOTION_PR_CANDIDATE_PULL_RETRY_MS,
}));
}
console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sha}; no rebuild or deployment performed.`);
appendSummary(`Validated release ${release.tag} against tested candidate \`${sha}\` from \`${sourceBranch}\` without rebuilding.`);
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, await 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() { async function main() {
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(); const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
const configPath = path.resolve(workspace, process.env.QUICKSTACK_DEPLOY_CONFIG ?? ".quickstack/deploy.json"); const configPath = path.resolve(workspace, process.env.QUICKSTACK_DEPLOY_CONFIG ?? ".quickstack/deploy.json");
@@ -350,6 +798,17 @@ async function main() {
const config = JSON.parse(fs.readFileSync(configPath, "utf8")); const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const eventName = process.env.GITHUB_EVENT_NAME ?? process.env.GITEA_EVENT_NAME ?? ""; const eventName = process.env.GITHUB_EVENT_NAME ?? process.env.GITEA_EVENT_NAME ?? "";
const branch = resolveDeploymentBranch({ eventName }); 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); const deployment = selectDeployment(config, branch);
if (!deployment) { if (!deployment) {
console.log(`No deployment is declared for branch ${branch}.`); console.log(`No deployment is declared for branch ${branch}.`);
@@ -2,12 +2,23 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
classifyVersion2Execution,
deployExactImage, deployExactImage,
expandTokens,
mergeEnvironment, mergeEnvironment,
pullCandidateArtifact,
resolveSecretEnvironment,
orderApplications,
resolveDeploymentBranch, resolveDeploymentBranch,
resolvePullRequestHeadBranch,
selectDeployment, selectDeployment,
selectPipeline,
toSavePayload, toSavePayload,
validateArtifact,
validateApplication,
validatePromotionPullRequestSource,
validateTarget, validateTarget,
validateVersion2Pipeline,
verifyEndpoint, verifyEndpoint,
} from "./deploy.mjs"; } from "./deploy.mjs";
@@ -56,6 +67,79 @@ test("resolves push and pull request deployment branches without accepting empty
); );
}); });
test("resolves the pull request head branch from direct and payload context", () => {
assert.equal(
resolvePullRequestHeadBranch({ environment: { GITHUB_HEAD_REF: "staging" } }),
"staging",
);
assert.equal(
resolvePullRequestHeadBranch({
environment: {},
eventPayload: { pull_request: { head: { ref: "release-candidate" } } },
}),
"release-candidate",
);
assert.throws(
() => resolvePullRequestHeadBranch({ environment: {}, eventPayload: {} }),
/Pull request head branch/,
);
});
test("production pull requests validate tested candidates without rebuilding", () => {
assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "pull_request"), "build-validation");
assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "push"), "build-deploy");
assert.equal(classifyVersion2Execution({ strategy: "promote" }, "pull_request"), "validate-candidate");
assert.equal(classifyVersion2Execution({ strategy: "promote" }, "push"), "promote");
assert.equal(
validatePromotionPullRequestSource({ source: { branch: "staging" } }, "staging"),
"staging",
);
assert.throws(
() => validatePromotionPullRequestSource({ source: { branch: "staging" } }, "feature"),
/must originate from staging, not feature/,
);
});
test("production pull requests wait for a concurrently published candidate", async () => {
const registry = "gitea.nuvisphere.de";
const artifact = {
name: "webapp",
image: "innomieter/webapp",
requiredFiles: [],
};
const sourceSha = "1234567890abcdef1234567890abcdef12345678";
const digest = `sha256:${"a".repeat(64)}`;
const sleeps = [];
let pullCalls = 0;
const runCommand = (_command, args) => {
if (args[0] === "pull") {
pullCalls += 1;
if (pullCalls < 3) throw new Error("manifest unknown");
return "";
}
if (args[0] === "image") {
return `${registry}/${artifact.image}@${digest}\n`;
}
throw new Error(`Unexpected Docker command: ${args.join(" ")}`);
};
const result = await pullCandidateArtifact({
artifact,
registry,
sourceSha,
workspace: "/workspace",
dockerEnv: {},
pullAttempts: 3,
pullRetryMs: 25,
runCommand,
sleep: async (ms) => sleeps.push(ms),
});
assert.equal(pullCalls, 3);
assert.deepEqual(sleeps, [25, 25]);
assert.equal(result.exactImage, `${registry}/${artifact.image}@${digest}`);
});
test("validates OCI paths and repository-local build paths", () => { test("validates OCI paths and repository-local build paths", () => {
assert.equal(validateTarget({ name: "Web", image: "Owner/Web", appId: "app-1" }).image, "owner/web"); assert.equal(validateTarget({ name: "Web", image: "Owner/Web", appId: "app-1" }).image, "owner/web");
assert.throws(() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", context: "../secret" }), /inside/); assert.throws(() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", context: "../secret" }), /inside/);
@@ -65,12 +149,87 @@ 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", () => { test("preserves response-only fields and existing environment secrets safely", () => {
const payload = toSavePayload(app()); const payload = toSavePayload(app());
assert.equal(payload.createdAt, undefined); assert.equal(payload.createdAt, undefined);
assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new"); assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new");
}); });
test("maps only explicitly declared Actions secrets into runtime environment", () => {
assert.deepEqual(
resolveSecretEnvironment(
{ MINIO_ENDPOINT: "MINIO_ENDPOINT", MINIO_REGION: "MINIO_REGION" },
{ MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" },
),
{ MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" },
);
assert.throws(
() => resolveSecretEnvironment({ MINIO_ENDPOINT: "MINIO_ENDPOINT" }, {}),
/Missing Actions secret MINIO_ENDPOINT/,
);
assert.throws(
() => resolveSecretEnvironment({ "INVALID-KEY": "MINIO_ENDPOINT" }, { MINIO_ENDPOINT: "value" }),
/Invalid secret environment key/,
);
});
test("postflight waits for the exact expected identity", async () => { test("postflight waits for the exact expected identity", async () => {
let attempt = 0; let attempt = 0;
const result = await verifyEndpoint( const result = await verifyEndpoint(
@@ -147,3 +306,35 @@ test("transient QuickStack deployment failures retry the desired immutable image
assert.equal(current.sourceType, "CONTAINER"); assert.equal(current.sourceType, "CONTAINER");
assert.equal(current.containerImageSource, image); assert.equal(current.containerImageSource, image);
}); });
test("a newly created deployment may be briefly unavailable without triggering rollback", async () => {
let current = app();
let deployments = 0;
let lookups = 0;
const image = `gitea.example/owner/app@sha256:${"c".repeat(64)}`;
const client = {
getApp: async () => current,
saveApp: async (payload) => { current = { ...current, ...payload }; },
deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }),
getDeployment: async (_appId, deploymentId) => {
lookups += 1;
if (lookups === 1) throw Object.assign(new Error("not visible"), { statusCode: 404 });
return { deploymentId, status: "DEPLOYED" };
},
};
const result = await deployExactImage({
client,
appId: "app-one",
image,
registryUsername: "deploy",
registryToken: "token",
sha: "3".repeat(40),
sleep: async () => {},
});
assert.equal(deployments, 1);
assert.equal(lookups, 2);
assert.equal(result.image, image);
assert.equal(current.sourceType, "CONTAINER");
});
+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;
}
@@ -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");
});
+23 -3
View File
@@ -2,6 +2,8 @@ name: Immutable QuickStack OCI deployment
on: on:
push: push:
branches:
- "**"
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -11,10 +13,11 @@ concurrency:
jobs: jobs:
build_and_deploy: build_and_deploy:
name: Build once and deploy exact digest name: ${{ gitea.event_name == 'pull_request' && 'Validate existing candidate without deployment' || gitea.event_name == 'push' && 'Build, publish and deploy exact candidate' || 'Validate or redeploy declared pipeline' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: write
releases: write
steps: steps:
- name: Checkout consuming repository - name: Checkout consuming repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -22,7 +25,7 @@ jobs:
fetch-depth: 2 fetch-depth: 2
- name: Build and deploy declared QuickStack targets - name: Build and deploy declared QuickStack targets
uses: https://gitea.nuvisphere.de/vadimenovikau/Platform-CI/.gitea/actions/quickstack-oci@main uses: https://gitea.nuvisphere.de/Nuvisphere/Platform-CI/.gitea/actions/quickstack-oci@main
with: with:
config-path: .quickstack/deploy.json config-path: .quickstack/deploy.json
env: env:
@@ -30,3 +33,20 @@ jobs:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
QUICKSTACK_API_TOKEN: ${{ secrets.QUICKSTACK_API_TOKEN }} QUICKSTACK_API_TOKEN: ${{ secrets.QUICKSTACK_API_TOKEN }}
QUICKSTACK_BASE_URL: https://server.nuvisphere.de QUICKSTACK_BASE_URL: https://server.nuvisphere.de
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
NEXT_PUBLIC_MINIO_ENDPOINT: ${{ secrets.NEXT_PUBLIC_MINIO_ENDPOINT }}
MINIO_REGION: ${{ secrets.MINIO_REGION }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
MINIO_TLS_REJECT_UNAUTHORIZED: ${{ secrets.MINIO_TLS_REJECT_UNAUTHORIZED }}
MINIO_AVATAR_BUCKET: ${{ secrets.MINIO_AVATAR_BUCKET }}
REDIS_URL: ${{ secrets.REDIS_URL }}
WEB_PUSH_PUBLIC_KEY: ${{ secrets.WEB_PUSH_PUBLIC_KEY }}
WEB_PUSH_PRIVATE_KEY: ${{ secrets.WEB_PUSH_PRIVATE_KEY }}
WEB_PUSH_SUBJECT: ${{ secrets.WEB_PUSH_SUBJECT }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_SECURE: ${{ secrets.SMTP_SECURE }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
+20
View File
@@ -0,0 +1,20 @@
name: Platform CI tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
name: QuickStack deploy action tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run action tests
run: node --test .gitea/actions/quickstack-oci/deploy.test.mjs
+64 -13
View File
@@ -1,15 +1,10 @@
# Platform CI # Platform CI
This repository is the central, public source for Nuvisphere's Gitea scoped This repository is the central, public source for Nuvisphere's Gitea scoped workflows and the shared QuickStack OCI action.
workflows. Workflow code contains no credentials. Each consuming owner or
organization supplies its own `REGISTRY_USERNAME`, `REGISTRY_TOKEN` and
`QUICKSTACK_API_TOKEN` Actions secrets.
Repositories opt into deployment by committing `.quickstack/deploy.json`. Workflow code contains no credentials. Each consuming organization supplies its own `REGISTRY_USERNAME`, `REGISTRY_TOKEN` and `QUICKSTACK_API_TOKEN` Actions secrets. Organization-owned `Platform-CI` repositories only provide the scoped workflow entrypoint and call this central action.
The central workflow builds every declared target once, publishes an immutable
`sha-<commit>` OCI tag to the Gitea Container Registry and changes QuickStack Repositories opt into deployment by committing `.quickstack/deploy.json`. Version 1 manifests continue to build and deploy every target independently. Version 2 separates immutable build artifacts from QuickStack applications, so one artifact can be deployed to multiple processes and a Production pipeline can promote the exact tested Staging digest without rebuilding. A failed rollout or postflight automatically restores the previous QuickStack configuration.
to the exact `@sha256:` digest. A failed rollout or postflight automatically
restores the previous QuickStack configuration.
Example: Example:
@@ -24,7 +19,7 @@ Example:
"targets": [ "targets": [
{ {
"name": "website", "name": "website",
"image": "vadimenovikau/example", "image": "nuvisphere/example",
"dockerfile": "Dockerfile", "dockerfile": "Dockerfile",
"context": ".", "context": ".",
"appId": "app-example-12345678", "appId": "app-example-12345678",
@@ -39,6 +34,62 @@ Example:
} }
``` ```
Pull requests build all matching targets without logging in, publishing or Pull requests build all matching targets without logging in, publishing or deploying. Pushes and manual runs publish and deploy. The commit marker `[skip quickstack-deploy]` skips a push deliberately.
deploying. Pushes and manual runs publish and deploy. The commit marker
`[skip quickstack-deploy]` skips a push deliberately. ## Candidate and promotion pipelines
Use manifest version 2 when one image serves multiple applications or Production must promote a tested candidate:
```json
{
"version": 2,
"registry": "gitea.nuvisphere.de",
"artifacts": [
{
"name": "web",
"image": "example/web",
"dockerfile": "Dockerfile",
"buildArgs": { "BUILD_SHA": "$sha12" },
"requiredFiles": ["/app/server.js"]
}
],
"pipelines": [
{
"name": "staging",
"branch": "staging",
"strategy": "candidate",
"applications": [
{ "name": "web", "artifact": "web", "appId": "app-staging" },
{
"name": "worker",
"artifact": "web",
"appId": "app-worker-staging",
"dependsOn": ["web"],
"environment": { "PROCESS": "worker" }
}
]
},
{
"name": "production",
"branch": "prod",
"strategy": "promote",
"source": { "branch": "staging", "mergeParent": 2, "requireTreeMatch": true },
"release": {
"versionFile": "content/releases/latest.json",
"notesFile": "content/releases/$version.md",
"skipMarker": "[skip prod-release]"
},
"applications": [
{ "name": "web", "artifact": "web", "appId": "app-production" }
]
}
]
}
```
Candidate pipelines build every artifact once, push `sha-<commit>`, resolve the registry digest and deploy applications in dependency order. A pull request into a promotion branch must originate from `source.branch`; it pulls and verifies the already tested `sha-<commit>` candidates without rebuilding or deploying them. Promotion pushes require a merge parent with an identical Git tree, pull the existing candidate, verify required container files, add the SemVer alias, deploy the exact digests, create the immutable tag and publish the canonical Gitea release. Tag creation does not trigger another scoped pipeline because the workflow listens only to branch pushes.
When a promotion pull request and its source-branch push start concurrently, the
pull-request check waits for the immutable candidate tag to become available.
This bounded retry applies only to promotion pull requests; an actual promotion
still fails immediately when its tested candidate is missing.