Compare commits
20
Commits
c9e5f82e28
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed7a5c0ff8 | ||
|
|
f520255d61 | ||
|
|
0cc4f072e7 | ||
|
|
f39d803d7a | ||
|
|
b512035c07 | ||
|
|
9bc520ce64 | ||
|
|
7625cab0d3 | ||
|
|
c8bc0ff58d | ||
|
|
b6a550e9a3 | ||
|
|
3a9bd879e6 | ||
|
|
efc0421ac1 | ||
|
|
70b3375617 | ||
|
|
f4ffb28018 | ||
|
|
a4247d2697 | ||
|
|
8f41d273d7 | ||
|
|
d3c0b76329 | ||
|
|
1cebf34ea7 | ||
|
|
d2a611aee6 | ||
|
|
c27dce00b3 | ||
|
|
2d3c7500b1 |
@@ -1,10 +1,18 @@
|
||||
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.
|
||||
required: false
|
||||
default: .quickstack/deploy.json
|
||||
mode:
|
||||
description: Execute the complete action, one artifact build slot or only the deployment coordinator.
|
||||
required: false
|
||||
default: all
|
||||
artifact-index:
|
||||
description: Zero-based artifact index used when mode is build.
|
||||
required: false
|
||||
default: "0"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -12,4 +20,6 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
QUICKSTACK_DEPLOY_CONFIG: ${{ inputs.config-path }}
|
||||
QUICKSTACK_ACTION_MODE: ${{ inputs.mode }}
|
||||
QUICKSTACK_ARTIFACT_INDEX: ${{ inputs.artifact-index }}
|
||||
run: node "$GITHUB_ACTION_PATH/deploy.mjs"
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -80,6 +87,81 @@ export function mergeEnvironment(source, overrides = {}) {
|
||||
return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n");
|
||||
}
|
||||
|
||||
export function parseEnvironment(source, { exclude = ["APP_DEPLOYMENT_ID"] } = {}) {
|
||||
const excluded = new Set(exclude);
|
||||
const environment = {};
|
||||
for (const line of String(source ?? "").split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (match && !excluded.has(match[1])) environment[match[1]] = match[2];
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
export function normalizeVolumeMountPaths(volumeMountPaths = {}) {
|
||||
if (
|
||||
volumeMountPaths === null ||
|
||||
typeof volumeMountPaths !== "object" ||
|
||||
Array.isArray(volumeMountPaths)
|
||||
) {
|
||||
throw new Error("volumeMountPaths must be an object.");
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(volumeMountPaths).map(([volumeId, rawPath]) => {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(volumeId)) {
|
||||
throw new Error(`Invalid QuickStack volume ID ${volumeId}.`);
|
||||
}
|
||||
const containerPath = String(rawPath).trim();
|
||||
if (!/^\/[A-Za-z0-9._/-]+$/.test(containerPath) || containerPath.split("/").includes("..")) {
|
||||
throw new Error(`Volume mount path for ${volumeId} must be a safe absolute path.`);
|
||||
}
|
||||
return [volumeId, containerPath];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyVolumeMountPaths(appVolumes = [], volumeMountPaths = {}) {
|
||||
const normalized = normalizeVolumeMountPaths(volumeMountPaths);
|
||||
const matched = new Set();
|
||||
const volumes = appVolumes.map((volume) => {
|
||||
const key = [volume.id, volume.sharedVolumeId].find((candidate) => candidate && normalized[candidate]);
|
||||
if (!key) return volume;
|
||||
matched.add(key);
|
||||
return { ...volume, containerMountPath: normalized[key] };
|
||||
});
|
||||
const missing = Object.keys(normalized).filter((key) => !matched.has(key));
|
||||
if (missing.length) {
|
||||
throw new Error(`QuickStack volume not found: ${missing.join(", ")}.`);
|
||||
}
|
||||
return volumes;
|
||||
}
|
||||
|
||||
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) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
@@ -101,7 +183,10 @@ export function createQuickStackClient({ baseUrl, token, fetchImpl = fetch }) {
|
||||
});
|
||||
const parsed = await readBody(response);
|
||||
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;
|
||||
}
|
||||
@@ -118,7 +203,17 @@ export function createQuickStackClient({ baseUrl, token, fetchImpl = fetch }) {
|
||||
|
||||
async function waitForDeployment(client, appId, deploymentId, sleep) {
|
||||
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();
|
||||
console.log(`QuickStack deployment ${deploymentId}: ${status} (${attempt}/180)`);
|
||||
if (SUCCESS.has(status)) return deployment;
|
||||
@@ -134,6 +229,26 @@ async function trigger(client, appId, sleep) {
|
||||
return { deploymentId, deployment: await waitForDeployment(client, appId, deploymentId, sleep) };
|
||||
}
|
||||
|
||||
async function triggerWithRetries({
|
||||
client, appId, sleep, attempts = 1, retryDelayMs = 15_000, label = "deployment", restoreConfig,
|
||||
}) {
|
||||
let latestError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
return await trigger(client, appId, sleep);
|
||||
} catch (error) {
|
||||
latestError = error;
|
||||
if (attempt >= attempts) throw error;
|
||||
console.log(
|
||||
`QuickStack ${label} attempt ${attempt}/${attempts} failed; retrying in ${retryDelayMs / 1000}s: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
await sleep(retryDelayMs);
|
||||
await restoreConfig();
|
||||
}
|
||||
}
|
||||
throw latestError;
|
||||
}
|
||||
|
||||
export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep }) {
|
||||
if (!postflight) return null;
|
||||
const url = requiredString(postflight.url, "Postflight URL");
|
||||
@@ -169,10 +284,18 @@ export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep
|
||||
|
||||
export async function deployExactImage({
|
||||
client, appId, image, registryUsername, registryToken, environment = {},
|
||||
healthCheckTcpPort, postflight, sha, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15,
|
||||
volumeMountPaths = {},
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
if (!/@sha256:[0-9a-f]{64}$/.test(image)) throw new Error("Deployment image must use an exact sha256 digest.");
|
||||
const attempts = Number(deploymentAttempts);
|
||||
const retryDelaySeconds = Number(deploymentRetrySeconds);
|
||||
if (!Number.isInteger(attempts) || attempts < 1) throw new Error("Deployment attempts must be a positive integer.");
|
||||
if (!Number.isFinite(retryDelaySeconds) || retryDelaySeconds < 0) {
|
||||
throw new Error("Deployment retry seconds must be a non-negative number.");
|
||||
}
|
||||
const current = await client.getApp(appId);
|
||||
const previous = toSavePayload(current);
|
||||
const next = {
|
||||
@@ -182,11 +305,20 @@ export async function deployExactImage({
|
||||
containerRegistryUsername: requiredString(registryUsername, "Registry username"),
|
||||
containerRegistryPassword: requiredString(registryToken, "Registry token"),
|
||||
envVars: mergeEnvironment(previous.envVars, environment),
|
||||
appVolumes: applyVolumeMountPaths(previous.appVolumes, volumeMountPaths),
|
||||
...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }),
|
||||
};
|
||||
await client.saveApp(next);
|
||||
try {
|
||||
const result = await trigger(client, appId, sleep);
|
||||
const result = await triggerWithRetries({
|
||||
client,
|
||||
appId,
|
||||
sleep,
|
||||
attempts,
|
||||
retryDelayMs: retryDelaySeconds * 1000,
|
||||
label: "deployment",
|
||||
restoreConfig: () => client.saveApp(next),
|
||||
});
|
||||
const persisted = await client.getApp(appId);
|
||||
if (persisted.sourceType !== "CONTAINER" || persisted.containerImageSource !== image) {
|
||||
throw new Error("QuickStack did not persist the exact image digest.");
|
||||
@@ -196,7 +328,15 @@ export async function deployExactImage({
|
||||
} catch (error) {
|
||||
await client.saveApp(previous);
|
||||
try {
|
||||
const rollback = await trigger(client, appId, sleep);
|
||||
const rollback = await triggerWithRetries({
|
||||
client,
|
||||
appId,
|
||||
sleep,
|
||||
attempts,
|
||||
retryDelayMs: retryDelaySeconds * 1000,
|
||||
label: "rollback",
|
||||
restoreConfig: () => client.saveApp(previous),
|
||||
});
|
||||
throw new Error(
|
||||
`Deployment failed and was rolled back with ${rollback.deploymentId}: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
@@ -229,6 +369,16 @@ function run(command, args, options = {}) {
|
||||
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
||||
}
|
||||
|
||||
function tryGit(workspace, args) {
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: workspace,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return String(result.stdout ?? "").trim();
|
||||
}
|
||||
|
||||
function safeRelative(value, name) {
|
||||
const normalized = requiredString(value, name).replace(/^\.\//, "");
|
||||
if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) {
|
||||
@@ -249,16 +399,182 @@ export function validateTarget(target) {
|
||||
const image = requiredString(target.image, `Image for ${name}`).toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9._/-]*$/.test(image)) throw new Error(`Invalid OCI image path ${image}.`);
|
||||
const appId = requiredString(target.appId, `QuickStack app ID for ${name}`);
|
||||
const deploymentAttempts = target.deploymentAttempts === undefined ? undefined : Number(target.deploymentAttempts);
|
||||
const deploymentRetrySeconds = target.deploymentRetrySeconds === undefined
|
||||
? undefined
|
||||
: Number(target.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 {
|
||||
...target,
|
||||
name,
|
||||
image,
|
||||
appId,
|
||||
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
|
||||
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
|
||||
dockerfile: safeRelative(target.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
|
||||
context: safeRelative(target.context ?? ".", `Build context for ${name}`),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeBuildSecrets(buildSecrets = {}) {
|
||||
if (buildSecrets === null || typeof buildSecrets !== "object" || Array.isArray(buildSecrets)) {
|
||||
throw new Error("buildSecrets must be an object.");
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(buildSecrets).map(([id, rawEnvironmentName]) => {
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(id)) throw new Error(`Invalid BuildKit secret ID ${id}.`);
|
||||
const environmentName = String(rawEnvironmentName);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(environmentName)) {
|
||||
throw new Error(`Invalid Actions secret environment name for BuildKit secret ${id}.`);
|
||||
}
|
||||
return [id, environmentName];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBuildSecretArguments(buildSecrets = {}, environment = process.env) {
|
||||
return Object.entries(normalizeBuildSecrets(buildSecrets)).flatMap(([id, environmentName]) => {
|
||||
const value = environment[environmentName];
|
||||
if (value === undefined || String(value).length === 0) {
|
||||
throw new Error(`Missing Actions secret ${environmentName} for BuildKit secret ${id}.`);
|
||||
}
|
||||
return ["--secret", `id=${id},env=${environmentName}`];
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
buildSecrets: normalizeBuildSecrets(artifact.buildSecrets),
|
||||
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.`);
|
||||
}
|
||||
const appId = requiredString(application.appId, `QuickStack app ID for ${name}`);
|
||||
const environmentFromAppId = application.environmentFromAppId === undefined
|
||||
? undefined
|
||||
: requiredString(application.environmentFromAppId, `Environment source app ID for ${name}`);
|
||||
if (environmentFromAppId === appId) {
|
||||
throw new Error(`Application ${name} cannot inherit environment from itself.`);
|
||||
}
|
||||
return {
|
||||
...application,
|
||||
name,
|
||||
artifact,
|
||||
appId,
|
||||
dependsOn,
|
||||
volumeMountPaths: normalizeVolumeMountPaths(application.volumeMountPaths),
|
||||
...(environmentFromAppId === undefined ? {} : { environmentFromAppId }),
|
||||
...(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");
|
||||
@@ -280,11 +596,513 @@ 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 isSameRepositoryPullRequest({ eventName, environment = process.env, eventPayload } = {}) {
|
||||
if (eventName !== "pull_request") return false;
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
const baseRepository = String(
|
||||
environment.GITHUB_REPOSITORY ||
|
||||
environment.GITEA_REPOSITORY ||
|
||||
payload?.repository?.full_name ||
|
||||
payload?.pull_request?.base?.repo?.full_name ||
|
||||
"",
|
||||
).trim();
|
||||
const headRepository = String(payload?.pull_request?.head?.repo?.full_name || "").trim();
|
||||
return Boolean(baseRepository && headRepository && baseRepository === headRepository);
|
||||
}
|
||||
|
||||
export function isValidationOnlyCandidateBuild({
|
||||
eventName,
|
||||
sameRepositoryPullRequest,
|
||||
} = {}) {
|
||||
return eventName === "pull_request" && !sameRepositoryPullRequest;
|
||||
}
|
||||
|
||||
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 resolveActionMode(value = "all") {
|
||||
const mode = String(value || "all").trim().toLowerCase();
|
||||
if (!new Set(["all", "build", "coordinate"]).has(mode)) {
|
||||
throw new Error(`Unsupported QuickStack action mode ${mode}.`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function resolveArtifactIndex(value = "0") {
|
||||
const index = Number(value);
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
throw new Error("QuickStack artifact index must be a non-negative integer.");
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function resolveTreeEquivalentCandidateSha(workspace, sha, source = {}) {
|
||||
if (source.requireTreeMatch === false) return sha;
|
||||
const mergeParent = Number(source.mergeParent ?? 2);
|
||||
if (!Number.isInteger(mergeParent) || mergeParent < 1) {
|
||||
throw new Error("Candidate mergeParent must be a positive integer.");
|
||||
}
|
||||
|
||||
let currentSha = sha;
|
||||
const visited = new Set();
|
||||
while (!visited.has(currentSha)) {
|
||||
visited.add(currentSha);
|
||||
const parentSha = tryGit(workspace, ["rev-parse", `${currentSha}^${mergeParent}`]);
|
||||
if (!parentSha) return currentSha;
|
||||
|
||||
const commitTree = run("git", ["rev-parse", `${currentSha}^{tree}`], { cwd: workspace, capture: true }).trim();
|
||||
const parentTree = run("git", ["rev-parse", `${parentSha}^{tree}`], { cwd: workspace, capture: true }).trim();
|
||||
if (commitTree !== parentTree) return currentSha;
|
||||
currentSha = parentSha;
|
||||
}
|
||||
return currentSha;
|
||||
}
|
||||
|
||||
function appendSummary(text) {
|
||||
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
|
||||
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 ensureBuildxBuilder(dockerEnv) {
|
||||
const builder = "quickstack-registry-cache";
|
||||
const existing = spawnSync(
|
||||
"docker",
|
||||
["buildx", "inspect", builder],
|
||||
{ env: dockerEnv, encoding: "utf8", stdio: "ignore" },
|
||||
);
|
||||
if (existing.status !== 0) {
|
||||
run(
|
||||
"docker",
|
||||
["buildx", "create", "--name", builder, "--driver", "docker-container"],
|
||||
{ env: dockerEnv },
|
||||
);
|
||||
}
|
||||
run("docker", ["buildx", "inspect", "--bootstrap", builder], { env: dockerEnv });
|
||||
return builder;
|
||||
}
|
||||
|
||||
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,
|
||||
useRegistryCache = !validationOnly,
|
||||
reuseExistingCandidate = false,
|
||||
}) {
|
||||
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`;
|
||||
if (reuseExistingCandidate) {
|
||||
try {
|
||||
const existing = pullCandidateArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sourceSha: sha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
});
|
||||
console.log(`Reusing verified candidate ${existing.exactImage}; no Docker build required.`);
|
||||
return existing;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`No reusable candidate found for ${taggedImage}; building it now (${error instanceof Error ? error.message : String(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const cacheImage = `${registry}/${artifact.image}:buildcache`;
|
||||
const builder = ensureBuildxBuilder(dockerEnv);
|
||||
const buildArgs = [
|
||||
"buildx", "build", "--builder", builder, "--load", "--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(...resolveBuildSecretArguments(artifact.buildSecrets, dockerEnv));
|
||||
if (useRegistryCache) {
|
||||
buildArgs.push("--cache-from", `type=registry,ref=${cacheImage}`);
|
||||
if (!validationOnly) {
|
||||
buildArgs.push(
|
||||
"--cache-to", `type=registry,ref=${cacheImage},mode=max,ignore-error=true`,
|
||||
);
|
||||
console.log(`Using read-write max-mode registry build cache ${cacheImage}.`);
|
||||
} else {
|
||||
console.log(`Using read-only registry build cache ${cacheImage}.`);
|
||||
}
|
||||
}
|
||||
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 revision = run(
|
||||
"docker",
|
||||
["image", "inspect", "--format", '{{ index .Config.Labels "org.opencontainers.image.revision" }}', taggedImage],
|
||||
{ cwd: workspace, env: dockerEnv, capture: true },
|
||||
).trim();
|
||||
if (revision !== sourceSha) {
|
||||
throw new Error(`Candidate ${taggedImage} has revision ${revision || "<missing>"}, expected ${sourceSha}.`);
|
||||
}
|
||||
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 }) {
|
||||
const pending = new Map(applications.map((application) => [application.name, application]));
|
||||
const completed = new Set();
|
||||
|
||||
while (pending.size > 0) {
|
||||
const wave = applications.filter(
|
||||
(application) =>
|
||||
pending.has(application.name) &&
|
||||
application.dependsOn.every((dependency) => completed.has(dependency)),
|
||||
);
|
||||
if (wave.length === 0) {
|
||||
throw new Error(
|
||||
`Unable to resolve deployment dependencies for: ${[...pending.keys()].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Deploying application wave: ${wave.map((application) => application.name).join(", ")}.`);
|
||||
await Promise.all(
|
||||
wave.map(async (application) => {
|
||||
const artifact = artifacts.get(application.artifact);
|
||||
const inheritedEnvironment = application.environmentFromAppId
|
||||
? parseEnvironment((await client.getApp(application.environmentFromAppId)).envVars)
|
||||
: {};
|
||||
const environment = {
|
||||
...inheritedEnvironment,
|
||||
...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,
|
||||
volumeMountPaths: application.volumeMountPaths,
|
||||
sha,
|
||||
});
|
||||
console.log(`Deployed ${application.name}: ${result.image}`);
|
||||
appendSummary(`- ${application.name}: \`${result.image}\` (${result.status})`);
|
||||
}),
|
||||
);
|
||||
for (const application of wave) {
|
||||
pending.delete(application.name);
|
||||
completed.add(application.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function executeVersion2({
|
||||
config,
|
||||
pipeline,
|
||||
eventName,
|
||||
sha,
|
||||
workspace,
|
||||
actionMode = "all",
|
||||
artifactIndex = 0,
|
||||
}) {
|
||||
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
|
||||
const sameRepositoryPullRequest = isSameRepositoryPullRequest({
|
||||
eventName,
|
||||
environment: process.env,
|
||||
});
|
||||
const validationOnly = isValidationOnlyCandidateBuild({
|
||||
eventName,
|
||||
sameRepositoryPullRequest,
|
||||
});
|
||||
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, DOCKER_BUILDKIT: "1" };
|
||||
const execution = classifyVersion2Execution(pipeline, eventName);
|
||||
const useRegistryCache =
|
||||
execution !== "build-validation" ||
|
||||
isSameRepositoryPullRequest({ eventName, environment: process.env });
|
||||
const candidateSha = execution === "build-deploy"
|
||||
? resolveTreeEquivalentCandidateSha(workspace, sha, pipeline.source)
|
||||
: sha;
|
||||
if (candidateSha !== sha) {
|
||||
console.log(`Branch commit ${sha} has the same tree as tested candidate parent ${candidateSha}; reusing candidate artifacts.`);
|
||||
}
|
||||
try {
|
||||
const needsRegistryLogin =
|
||||
!(actionMode === "coordinate" && execution === "build-validation") &&
|
||||
(execution !== "build-validation" || useRegistryCache);
|
||||
if (needsRegistryLogin) dockerLogin(registry, dockerEnv);
|
||||
|
||||
if (actionMode === "build") {
|
||||
if (execution !== "build-validation" && execution !== "build-deploy") {
|
||||
console.log(`Artifact build slot ${artifactIndex} is not needed for ${execution}.`);
|
||||
return;
|
||||
}
|
||||
const artifact = pipeline.artifacts[artifactIndex];
|
||||
if (!artifact) {
|
||||
console.log(`Artifact build slot ${artifactIndex} is unused for ${pipeline.name ?? pipeline.branch}.`);
|
||||
return;
|
||||
}
|
||||
const built = buildArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sha: candidateSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
validationOnly,
|
||||
useRegistryCache,
|
||||
reuseExistingCandidate: !validationOnly,
|
||||
});
|
||||
appendSummary(
|
||||
validationOnly
|
||||
? `Validated OCI artifact \`${artifact.name}\` for \`${pipeline.branch}\`.`
|
||||
: execution === "build-validation"
|
||||
? `Built reusable pull-request candidate \`${artifact.name}\`: \`${built.exactImage}\`.`
|
||||
: `Built OCI artifact \`${artifact.name}\`: \`${built.exactImage}\`.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionMode === "coordinate" && execution === "build-validation") {
|
||||
appendSummary(`Validated ${pipeline.artifacts.length} immutable OCI artifact build(s) for \`${pipeline.branch}\`.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionMode === "coordinate" && execution === "build-deploy") {
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sourceSha: candidateSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
}));
|
||||
}
|
||||
const client = createQuickStackClient({
|
||||
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
||||
token: process.env.QUICKSTACK_API_TOKEN,
|
||||
});
|
||||
await deployApplications({ applications: pipeline.applications, artifacts, sha: candidateSha, client });
|
||||
return;
|
||||
}
|
||||
|
||||
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: candidateSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
validationOnly,
|
||||
useRegistryCache,
|
||||
reuseExistingCandidate: !validationOnly,
|
||||
}));
|
||||
}
|
||||
if (execution === "build-validation") {
|
||||
appendSummary(
|
||||
validationOnly
|
||||
? `Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`
|
||||
: `Published ${artifacts.size} reusable pull-request candidate 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: candidateSha, client });
|
||||
return;
|
||||
}
|
||||
|
||||
if (execution === "validate-candidate") {
|
||||
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
|
||||
const release = loadRelease(workspace, pipeline.release);
|
||||
const sourceSha = resolveTreeEquivalentCandidateSha(workspace, sha, pipeline.source);
|
||||
if (sourceSha !== sha) {
|
||||
console.log(`Pull request head ${sha} has the same tree as tested candidate parent ${sourceSha}; validating parent candidate artifacts.`);
|
||||
}
|
||||
const artifacts = new Map();
|
||||
for (const artifact of pipeline.artifacts) {
|
||||
artifacts.set(artifact.name, pullCandidateArtifact({
|
||||
artifact,
|
||||
registry,
|
||||
sourceSha,
|
||||
workspace,
|
||||
dockerEnv,
|
||||
}));
|
||||
}
|
||||
console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sourceSha}; no rebuild or deployment performed.`);
|
||||
appendSummary(`Validated release ${release.tag} against tested candidate \`${sourceSha}\` from \`${sourceBranch}\` without rebuilding.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergeSourceSha = resolvePromotionSource(workspace, pipeline.source);
|
||||
const sourceSha = resolveTreeEquivalentCandidateSha(workspace, mergeSourceSha, pipeline.source);
|
||||
if (sourceSha !== mergeSourceSha) {
|
||||
console.log(`Promotion source ${mergeSourceSha} has the same tree as tested candidate parent ${sourceSha}; promoting parent candidate artifacts.`);
|
||||
}
|
||||
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");
|
||||
@@ -295,6 +1113,31 @@ 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 });
|
||||
const actionMode = resolveActionMode(process.env.QUICKSTACK_ACTION_MODE);
|
||||
const artifactIndex = resolveArtifactIndex(process.env.QUICKSTACK_ARTIFACT_INDEX);
|
||||
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,
|
||||
actionMode,
|
||||
artifactIndex,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (actionMode === "build") {
|
||||
console.log("Manifest version 1 remains on the coordinator's serial compatibility path.");
|
||||
return;
|
||||
}
|
||||
const deployment = selectDeployment(config, branch);
|
||||
if (!deployment) {
|
||||
console.log(`No deployment is declared for branch ${branch}.`);
|
||||
@@ -359,6 +1202,8 @@ async function main() {
|
||||
environment: target.environment,
|
||||
healthCheckTcpPort: target.healthCheckTcpPort,
|
||||
postflight: target.postflight,
|
||||
deploymentAttempts: target.deploymentAttempts,
|
||||
deploymentRetrySeconds: target.deploymentRetrySeconds,
|
||||
sha,
|
||||
});
|
||||
console.log(`Deployed ${target.name}: ${result.image}`);
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyVolumeMountPaths,
|
||||
classifyVersion2Execution,
|
||||
deployExactImage,
|
||||
expandTokens,
|
||||
mergeEnvironment,
|
||||
isValidationOnlyCandidateBuild,
|
||||
normalizeVolumeMountPaths,
|
||||
parseEnvironment,
|
||||
resolveActionMode,
|
||||
resolveArtifactIndex,
|
||||
resolveBuildSecretArguments,
|
||||
resolveSecretEnvironment,
|
||||
orderApplications,
|
||||
resolveDeploymentBranch,
|
||||
resolvePullRequestHeadBranch,
|
||||
resolveTreeEquivalentCandidateSha,
|
||||
selectDeployment,
|
||||
selectPipeline,
|
||||
toSavePayload,
|
||||
validateArtifact,
|
||||
validateApplication,
|
||||
validatePromotionPullRequestSource,
|
||||
validateTarget,
|
||||
validateVersion2Pipeline,
|
||||
verifyEndpoint,
|
||||
} from "./deploy.mjs";
|
||||
|
||||
@@ -56,15 +78,257 @@ 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("same-repository pull requests publish reusable candidates while forks stay validation-only", () => {
|
||||
assert.equal(
|
||||
isValidationOnlyCandidateBuild({
|
||||
eventName: "pull_request",
|
||||
sameRepositoryPullRequest: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isValidationOnlyCandidateBuild({
|
||||
eventName: "pull_request",
|
||||
sameRepositoryPullRequest: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isValidationOnlyCandidateBuild({
|
||||
eventName: "push",
|
||||
sameRepositoryPullRequest: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("tree-equivalent merge commits resolve to their tested candidate parent", () => {
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-merge-candidate-"));
|
||||
const git = (args) => execFileSync("git", args, {
|
||||
cwd: workspace,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
try {
|
||||
git(["init"]);
|
||||
git(["checkout", "-b", "main"]);
|
||||
git(["config", "user.email", "ci@example.test"]);
|
||||
git(["config", "user.name", "CI"]);
|
||||
fs.writeFileSync(path.join(workspace, "app.txt"), "base\n");
|
||||
git(["add", "app.txt"]);
|
||||
git(["commit", "-m", "base"]);
|
||||
const baseSha = git(["rev-parse", "HEAD"]);
|
||||
git(["checkout", "-b", "feature"]);
|
||||
fs.writeFileSync(path.join(workspace, "app.txt"), "feature\n");
|
||||
git(["commit", "-am", "feature"]);
|
||||
const featureSha = git(["rev-parse", "HEAD"]);
|
||||
git(["checkout", "main"]);
|
||||
git(["merge", "--no-ff", "feature", "-m", "merge feature"]);
|
||||
const mergeSha = git(["rev-parse", "HEAD"]);
|
||||
git(["checkout", "-b", "prod", baseSha]);
|
||||
git(["merge", "--no-ff", "main", "-m", "merge staging"]);
|
||||
const prodMergeSha = git(["rev-parse", "HEAD"]);
|
||||
|
||||
assert.equal(resolveTreeEquivalentCandidateSha(workspace, mergeSha), featureSha);
|
||||
assert.equal(resolveTreeEquivalentCandidateSha(workspace, prodMergeSha), featureSha);
|
||||
assert.equal(resolveTreeEquivalentCandidateSha(workspace, featureSha), featureSha);
|
||||
assert.equal(
|
||||
resolveTreeEquivalentCandidateSha(workspace, mergeSha, { requireTreeMatch: false }),
|
||||
mergeSha,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validates split action modes and artifact build slots", () => {
|
||||
assert.equal(resolveActionMode(undefined), "all");
|
||||
assert.equal(resolveActionMode("BUILD"), "build");
|
||||
assert.equal(resolveActionMode("coordinate"), "coordinate");
|
||||
assert.throws(() => resolveActionMode("parallel"), /Unsupported QuickStack action mode/);
|
||||
assert.equal(resolveArtifactIndex("2"), 2);
|
||||
assert.throws(() => resolveArtifactIndex("-1"), /non-negative integer/);
|
||||
assert.throws(() => resolveArtifactIndex("1.5"), /non-negative integer/);
|
||||
});
|
||||
|
||||
test("validates OCI paths and repository-local build paths", () => {
|
||||
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", deploymentAttempts: 0 }),
|
||||
/positive integer/,
|
||||
);
|
||||
});
|
||||
|
||||
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/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateApplication({
|
||||
name: "worker",
|
||||
artifact: "known",
|
||||
appId: "app-worker",
|
||||
environmentFromAppId: "app-worker",
|
||||
}, new Set(["known"])),
|
||||
/cannot inherit environment from itself/,
|
||||
);
|
||||
});
|
||||
|
||||
test("passes declared Actions secrets to Docker only through BuildKit secret mounts", () => {
|
||||
const secretValue = "must-not-appear-in-docker-arguments";
|
||||
const artifact = validateArtifact({
|
||||
name: "web",
|
||||
image: "owner/web",
|
||||
buildSecrets: {
|
||||
"next-server-actions-encryption-key": "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
||||
},
|
||||
});
|
||||
const args = resolveBuildSecretArguments(artifact.buildSecrets, {
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: secretValue,
|
||||
});
|
||||
|
||||
assert.deepEqual(args, [
|
||||
"--secret",
|
||||
"id=next-server-actions-encryption-key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
||||
]);
|
||||
assert.doesNotMatch(JSON.stringify(args), new RegExp(secretValue));
|
||||
assert.throws(
|
||||
() => resolveBuildSecretArguments(artifact.buildSecrets, {}),
|
||||
/Missing Actions secret NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArtifact({ name: "web", image: "owner/web", buildSecrets: { "../invalid": "SECRET" } }),
|
||||
/Invalid BuildKit secret ID/,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new");
|
||||
assert.deepEqual(parseEnvironment("SECRET=preserved\nAPP_DEPLOYMENT_ID=source\nENVIRONMENT=new\ninvalid line"), {
|
||||
SECRET: "preserved",
|
||||
ENVIRONMENT: "new",
|
||||
});
|
||||
});
|
||||
|
||||
test("overrides only declared existing QuickStack volume mount paths", () => {
|
||||
const volumes = [
|
||||
{ id: "volume-local", sharedVolumeId: "shared-volume", containerMountPath: "/old" },
|
||||
{ id: "volume-untouched", containerMountPath: "/data" },
|
||||
];
|
||||
assert.deepEqual(
|
||||
applyVolumeMountPaths(volumes, { "shared-volume": "/mnt/as4" }),
|
||||
[
|
||||
{ id: "volume-local", sharedVolumeId: "shared-volume", containerMountPath: "/mnt/as4" },
|
||||
{ id: "volume-untouched", containerMountPath: "/data" },
|
||||
],
|
||||
);
|
||||
assert.throws(() => applyVolumeMountPaths(volumes, { missing: "/mnt/data" }), /not found/);
|
||||
assert.throws(() => normalizeVolumeMountPaths({ volume: "../data" }), /safe absolute path/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -111,3 +375,67 @@ test("failed public verification restores and redeploys the previous source", as
|
||||
assert.equal(current.sourceType, original.sourceType);
|
||||
assert.equal(current.containerImageSource, original.containerImageSource);
|
||||
});
|
||||
|
||||
test("transient QuickStack deployment failures retry the desired immutable image", async () => {
|
||||
let current = app();
|
||||
let deployments = 0;
|
||||
const image = `gitea.example/owner/app@sha256:${"b".repeat(64)}`;
|
||||
const client = {
|
||||
getApp: async () => current,
|
||||
saveApp: async (payload) => { current = { ...current, ...payload }; },
|
||||
deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }),
|
||||
getDeployment: async (_appId, deploymentId) => ({
|
||||
deploymentId,
|
||||
status: deploymentId === "deployment-1" ? "ERROR" : "DEPLOYED",
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await deployExactImage({
|
||||
client,
|
||||
appId: "app-one",
|
||||
image,
|
||||
registryUsername: "deploy",
|
||||
registryToken: "token",
|
||||
sha: "2".repeat(40),
|
||||
deploymentAttempts: 2,
|
||||
deploymentRetrySeconds: 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(deployments, 2);
|
||||
assert.equal(result.image, image);
|
||||
assert.equal(current.sourceType, "CONTAINER");
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -22,7 +25,7 @@ jobs:
|
||||
fetch-depth: 2
|
||||
|
||||
- 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:
|
||||
config-path: .quickstack/deploy.json
|
||||
env:
|
||||
@@ -30,3 +33,20 @@ 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 }}
|
||||
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 }}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: QuickStack runner job image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- runner-images/quickstack-job/**
|
||||
- .gitea/workflows/runner-image.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- runner-images/quickstack-job/**
|
||||
- .gitea/workflows/runner-image.yml
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.nuvisphere.de
|
||||
IMAGE: gitea.nuvisphere.de/nuvisphere/quickstack-job
|
||||
STABLE_TAG: node20-docker27
|
||||
DOCKERFILE: runner-images/quickstack-job/Dockerfile
|
||||
CONTEXT: runner-images/quickstack-job
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and publish runner image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx create --name quickstack-runner-image --use || docker buildx use quickstack-runner-image
|
||||
docker buildx build \
|
||||
--load \
|
||||
--progress=plain \
|
||||
--file "$DOCKERFILE" \
|
||||
--tag "$IMAGE:$STABLE_TAG" \
|
||||
"$CONTEXT"
|
||||
|
||||
- name: Verify image tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm "$IMAGE:$STABLE_TAG" bash -lc '
|
||||
node --version
|
||||
git --version
|
||||
docker --version
|
||||
docker buildx version
|
||||
jq --version
|
||||
curl --version
|
||||
zstd --version
|
||||
'
|
||||
|
||||
- name: Login to Gitea registry
|
||||
if: ${{ gitea.event_name != 'pull_request' }}
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$REGISTRY_USERNAME"
|
||||
test -n "$REGISTRY_TOKEN"
|
||||
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: Publish image
|
||||
if: ${{ gitea.event_name != 'pull_request' }}
|
||||
env:
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker tag "$IMAGE:$STABLE_TAG" "$IMAGE:sha-$COMMIT_SHA"
|
||||
docker push "$IMAGE:$STABLE_TAG"
|
||||
docker push "$IMAGE:sha-$COMMIT_SHA"
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
# Platform CI
|
||||
|
||||
This repository is the central, public source for Nuvisphere's Gitea scoped
|
||||
workflows. Workflow code contains no credentials. Each consuming owner or
|
||||
organization supplies its own `REGISTRY_USERNAME`, `REGISTRY_TOKEN` and
|
||||
`QUICKSTACK_API_TOKEN` Actions secrets.
|
||||
This repository is the central, public source for Nuvisphere's Gitea scoped workflows and the shared QuickStack OCI action.
|
||||
|
||||
Repositories opt into deployment by committing `.quickstack/deploy.json`.
|
||||
The central workflow builds every declared target once, publishes an immutable
|
||||
`sha-<commit>` OCI tag to the Gitea Container Registry and changes QuickStack
|
||||
to the exact `@sha256:` digest. A failed rollout or postflight automatically
|
||||
restores the previous QuickStack configuration.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -24,7 +19,7 @@ Example:
|
||||
"targets": [
|
||||
{
|
||||
"name": "website",
|
||||
"image": "vadimenovikau/example",
|
||||
"image": "nuvisphere/example",
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".",
|
||||
"appId": "app-example-12345678",
|
||||
@@ -39,6 +34,84 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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" },
|
||||
"buildSecrets": {
|
||||
"framework-build-key": "FRAMEWORK_BUILD_KEY"
|
||||
},
|
||||
"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"],
|
||||
"environmentFromAppId": "app-staging",
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`buildSecrets` is optional and maps a BuildKit secret mount ID to the name of
|
||||
an Actions secret exposed to the workflow environment. Values are passed to
|
||||
`docker build` through `--secret id=...,env=...`; they are never included in
|
||||
the command line as build arguments. The Dockerfile consumes them with
|
||||
`RUN --mount=type=secret,id=<id>,required=true ...`.
|
||||
|
||||
`environmentFromAppId` is optional and copies the existing QuickStack runtime
|
||||
environment from another application before applying the target application's
|
||||
declared `environment` and `secretEnvironment` overrides. This is intended for
|
||||
workers that share backend credentials with an API without duplicating secret
|
||||
values in Git or Actions. `volumeMountPaths` can map an existing QuickStack
|
||||
volume ID or shared-volume ID to a safe absolute container path; it never
|
||||
creates or replaces a volume. QuickStack's deployment identity variable is
|
||||
never inherited from the source application.
|
||||
|
||||
Candidate pipelines build every artifact once, push `sha-<commit>`, resolve the registry digest and deploy applications in dependency order. Same-repository pull requests publish their verified immutable candidates immediately. A fast-forward merge keeps the tested commit SHA, so the subsequent branch push only pulls, verifies and deploys those candidates instead of building them again. A no-ff merge commit is also reused when its tree matches the configured merge parent, so Gitea's merge commit does not force a second identical build. Fork pull requests remain validation-only and never publish. 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, trace tree-equivalent staging merge commits back to their tested candidate parent, 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.
|
||||
|
||||
Scoped workflows can distribute version 2 artifact builds across independent
|
||||
runner jobs by invoking the action with `mode: build` and a zero-based
|
||||
`artifact-index`. A final job invokes `mode: coordinate` after all build jobs.
|
||||
Candidate tags are the synchronization boundary, so exact digests never depend
|
||||
on matrix output merging. Published candidates also maintain a per-image
|
||||
`buildcache` tag with inline BuildKit metadata for ephemeral runners. Version 1
|
||||
manifests remain on the serial coordinator compatibility path.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!Dockerfile
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM docker:27.5.1-cli AS docker-cli
|
||||
|
||||
FROM docker.gitea.com/runner-images:ubuntu-latest-slim
|
||||
|
||||
USER root
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
|
||||
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-buildx /usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
jq \
|
||||
openssh-client \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN node --version \
|
||||
&& git --version \
|
||||
&& docker --version \
|
||||
&& docker buildx version \
|
||||
&& jq --version \
|
||||
&& zstd --version
|
||||
@@ -0,0 +1,29 @@
|
||||
# QuickStack Job Runner Image
|
||||
|
||||
This image is the small, source-controlled job container used by the Nuvisphere
|
||||
Gitea runners for `ubuntu-latest`, `ubuntu-24.04` and `ubuntu-22.04` jobs.
|
||||
|
||||
It replaces `docker.gitea.com/runner-images:ubuntu-latest` for QuickStack
|
||||
workloads. The full upstream image is large and has caused slow or stuck pulls
|
||||
on production runners. This image starts from Gitea's slim Node runner image
|
||||
and adds only the tools required by the shared QuickStack OCI workflow:
|
||||
|
||||
- Docker CLI and Buildx
|
||||
- Git
|
||||
- curl
|
||||
- jq
|
||||
- OpenSSH client
|
||||
- CA certificates
|
||||
- zstd
|
||||
|
||||
The stable production tag is:
|
||||
|
||||
```text
|
||||
gitea.nuvisphere.de/nuvisphere/quickstack-job:node20-docker27
|
||||
```
|
||||
|
||||
The publish workflow also pushes an immutable commit tag:
|
||||
|
||||
```text
|
||||
gitea.nuvisphere.de/nuvisphere/quickstack-job:sha-<commit>
|
||||
```
|
||||
Reference in New Issue
Block a user