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
9 changed files with 124 additions and 723 deletions
-10
View File
@@ -5,14 +5,6 @@ inputs:
description: Path to the repository deployment manifest. description: Path to the repository deployment manifest.
required: false required: false
default: .quickstack/deploy.json 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: runs:
using: composite using: composite
steps: steps:
@@ -20,6 +12,4 @@ runs:
shell: bash shell: bash
env: env:
QUICKSTACK_DEPLOY_CONFIG: ${{ inputs.config-path }} QUICKSTACK_DEPLOY_CONFIG: ${{ inputs.config-path }}
QUICKSTACK_ACTION_MODE: ${{ inputs.mode }}
QUICKSTACK_ARTIFACT_INDEX: ${{ inputs.artifact-index }}
run: node "$GITHUB_ACTION_PATH/deploy.mjs" run: node "$GITHUB_ACTION_PATH/deploy.mjs"
+81 -410
View File
@@ -87,54 +87,6 @@ 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 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) { export function resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) {
if ( if (
secretEnvironment === null || secretEnvironment === null ||
@@ -285,7 +237,6 @@ export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep
export async function deployExactImage({ export async function deployExactImage({
client, appId, image, registryUsername, registryToken, environment = {}, client, appId, image, registryUsername, registryToken, environment = {},
healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15, healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15,
volumeMountPaths = {},
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
fetchImpl = fetch, fetchImpl = fetch,
}) { }) {
@@ -305,7 +256,6 @@ export async function deployExactImage({
containerRegistryUsername: requiredString(registryUsername, "Registry username"), containerRegistryUsername: requiredString(registryUsername, "Registry username"),
containerRegistryPassword: requiredString(registryToken, "Registry token"), containerRegistryPassword: requiredString(registryToken, "Registry token"),
envVars: mergeEnvironment(previous.envVars, environment), envVars: mergeEnvironment(previous.envVars, environment),
appVolumes: applyVolumeMountPaths(previous.appVolumes, volumeMountPaths),
...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }), ...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }),
}; };
await client.saveApp(next); await client.saveApp(next);
@@ -369,16 +319,6 @@ function run(command, args, options = {}) {
return `${result.stdout ?? ""}${result.stderr ?? ""}`; 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) { function safeRelative(value, name) {
const normalized = requiredString(value, name).replace(/^\.\//, ""); const normalized = requiredString(value, name).replace(/^\.\//, "");
if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) { if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) {
@@ -434,32 +374,6 @@ function validateImagePath(value, name) {
return 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) { export function validateArtifact(artifact) {
const name = requiredString(artifact.name, "Artifact name"); const name = requiredString(artifact.name, "Artifact name");
const requiredFiles = (artifact.requiredFiles ?? []).map((file) => { const requiredFiles = (artifact.requiredFiles ?? []).map((file) => {
@@ -482,7 +396,6 @@ export function validateArtifact(artifact) {
dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`), dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
context: safeRelative(artifact.context ?? ".", `Build context for ${name}`), context: safeRelative(artifact.context ?? ".", `Build context for ${name}`),
buildArgs, buildArgs,
buildSecrets: normalizeBuildSecrets(artifact.buildSecrets),
requiredFiles, requiredFiles,
}; };
} }
@@ -504,21 +417,12 @@ export function validateApplication(application, artifactNames) {
if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) { if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) {
throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`); 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 { return {
...application, ...application,
name, name,
artifact, artifact,
appId, appId: requiredString(application.appId, `QuickStack app ID for ${name}`),
dependsOn, dependsOn,
volumeMountPaths: normalizeVolumeMountPaths(application.volumeMountPaths),
...(environmentFromAppId === undefined ? {} : { environmentFromAppId }),
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }), ...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }), ...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
}; };
@@ -610,34 +514,6 @@ export function resolvePullRequestHeadBranch({ environment = process.env, eventP
return requiredString(payload?.pull_request?.head?.ref, "Pull request head branch"); 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) { export function classifyVersion2Execution(pipeline, eventName) {
if (eventName === "pull_request") { if (eventName === "pull_request") {
return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation"; return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation";
@@ -645,22 +521,6 @@ export function classifyVersion2Execution(pipeline, eventName) {
return pipeline.strategy === "candidate" ? "build-deploy" : "promote"; 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) { export function validatePromotionPullRequestSource(pipeline, headBranch) {
const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch"); const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch");
const actualHeadBranch = requiredString(headBranch, "Pull request head branch"); const actualHeadBranch = requiredString(headBranch, "Pull request head branch");
@@ -670,28 +530,6 @@ export function validatePromotionPullRequestSource(pipeline, headBranch) {
return sourceBranch; 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) { 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`);
@@ -705,24 +543,6 @@ function dockerLogin(registry, dockerEnv) {
); );
} }
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) { function exactDigestFromPush(pushOutput, taggedImage) {
const digest = [...pushOutput.matchAll(/digest:\s*(sha256:[0-9a-f]{64})/g)].at(-1)?.[1]; 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}.`); if (!digest) throw new Error(`Registry did not return a digest for ${taggedImage}.`);
@@ -735,38 +555,10 @@ function verifyRequiredContainerFiles(taggedImage, requiredFiles, options) {
} }
} }
function buildArtifact({ function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validationOnly }) {
artifact,
registry,
sha,
workspace,
dockerEnv,
validationOnly,
useRegistryCache = !validationOnly,
reuseExistingCandidate = false,
}) {
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`; 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 = [ const buildArgs = [
"buildx", "build", "--builder", builder, "--load", "--progress=plain", "--file", artifact.dockerfile, "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.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}`, "--label", `org.opencontainers.image.revision=${sha}`,
"--tag", taggedImage, "--tag", taggedImage,
@@ -774,18 +566,6 @@ function buildArtifact({
for (const [key, value] of Object.entries(artifact.buildArgs)) { for (const [key, value] of Object.entries(artifact.buildArgs)) {
buildArgs.push("--build-arg", `${key}=${expandTokens(value, { sha })}`); 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); buildArgs.push(artifact.context);
console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`); console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`);
run("docker", buildArgs, { cwd: workspace, env: dockerEnv }); run("docker", buildArgs, { cwd: workspace, env: dockerEnv });
@@ -797,19 +577,46 @@ function buildArtifact({
return { artifact, taggedImage, digest, exactImage: `${registry}/${artifact.image}@${digest}` }; return { artifact, taggedImage, digest, exactImage: `${registry}/${artifact.image}@${digest}` };
} }
function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, dockerEnv }) { 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}`; const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv }); if (!Number.isInteger(pullAttempts) || pullAttempts < 1) {
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv }); throw new Error("Candidate pull attempts must be a positive integer.");
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( 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", "docker",
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage], ["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
{ cwd: workspace, env: dockerEnv, capture: true }, { cwd: workspace, env: dockerEnv, capture: true },
@@ -858,78 +665,35 @@ function promoteArtifactAliases(artifacts, releaseTag, options) {
} }
async function deployApplications({ applications, artifacts, sha, client }) { async function deployApplications({ applications, artifacts, sha, client }) {
const pending = new Map(applications.map((application) => [application.name, application])); for (const application of applications) {
const completed = new Set(); const artifact = artifacts.get(application.artifact);
const environment = {
while (pending.size > 0) { ...Object.fromEntries(
const wave = applications.filter( Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
(application) => ),
pending.has(application.name) && ...resolveSecretEnvironment(application.secretEnvironment),
application.dependsOn.every((dependency) => completed.has(dependency)), };
); const result = await deployExactImage({
if (wave.length === 0) { client,
throw new Error( appId: application.appId,
`Unable to resolve deployment dependencies for: ${[...pending.keys()].join(", ")}`, image: artifact.exactImage,
); registryUsername: process.env.REGISTRY_USERNAME,
} registryToken: process.env.REGISTRY_TOKEN,
environment,
console.log(`Deploying application wave: ${wave.map((application) => application.name).join(", ")}.`); healthCheckTcpPort: application.healthCheckTcpPort,
await Promise.all( postflight: application.postflight,
wave.map(async (application) => { deploymentAttempts: application.deploymentAttempts,
const artifact = artifacts.get(application.artifact); deploymentRetrySeconds: application.deploymentRetrySeconds,
const inheritedEnvironment = application.environmentFromAppId sha,
? parseEnvironment((await client.getApp(application.environmentFromAppId)).envVars) });
: {}; console.log(`Deployed ${application.name}: ${result.image}`);
const environment = { appendSummary(`- ${application.name}: \`${result.image}\` (${result.status})`);
...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({ async function executeVersion2({ config, pipeline, eventName, sha, workspace }) {
config,
pipeline,
eventName,
sha,
workspace,
actionMode = "all",
artifactIndex = 0,
}) {
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true }); const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
const sameRepositoryPullRequest = isSameRepositoryPullRequest({ const validationOnly = eventName === "pull_request";
eventName,
environment: process.env,
});
const validationOnly = isValidationOnlyCandidateBuild({
eventName,
sameRepositoryPullRequest,
});
const skipMarker = pipeline.strategy === "promote" const skipMarker = pipeline.strategy === "promote"
? String(pipeline.release?.skipMarker ?? "[skip prod-release]") ? String(pipeline.release?.skipMarker ?? "[skip prod-release]")
: "[skip quickstack-deploy]"; : "[skip quickstack-deploy]";
@@ -939,138 +703,59 @@ async function executeVersion2({
} }
const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, ""); const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, "");
const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-")); const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-"));
const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig, DOCKER_BUILDKIT: "1" }; const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig };
const execution = classifyVersion2Execution(pipeline, eventName); 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 { try {
const needsRegistryLogin = if (execution !== "build-validation") dockerLogin(registry, dockerEnv);
!(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") { if (execution === "build-validation" || execution === "build-deploy") {
const artifacts = new Map(); const artifacts = new Map();
for (const artifact of pipeline.artifacts) { for (const artifact of pipeline.artifacts) {
artifacts.set(artifact.name, buildArtifact({ artifacts.set(artifact.name, buildArtifact({
artifact, artifact,
registry, registry,
sha: candidateSha, sha,
workspace, workspace,
dockerEnv, dockerEnv,
validationOnly, validationOnly: execution === "build-validation",
useRegistryCache,
reuseExistingCandidate: !validationOnly,
})); }));
} }
if (execution === "build-validation") { if (execution === "build-validation") {
appendSummary( appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`);
validationOnly
? `Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`
: `Published ${artifacts.size} reusable pull-request candidate artifact(s) for ${pipeline.branch}.`,
);
return; return;
} }
const client = createQuickStackClient({ const client = createQuickStackClient({
baseUrl: process.env.QUICKSTACK_BASE_URL, baseUrl: process.env.QUICKSTACK_BASE_URL,
token: process.env.QUICKSTACK_API_TOKEN, token: process.env.QUICKSTACK_API_TOKEN,
}); });
await deployApplications({ applications: pipeline.applications, artifacts, sha: candidateSha, client }); await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
return; return;
} }
if (execution === "validate-candidate") { if (execution === "validate-candidate") {
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch()); const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
const release = loadRelease(workspace, pipeline.release); 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(); const artifacts = new Map();
for (const artifact of pipeline.artifacts) { for (const artifact of pipeline.artifacts) {
artifacts.set(artifact.name, pullCandidateArtifact({ artifacts.set(artifact.name, await pullCandidateArtifact({
artifact, artifact,
registry, registry,
sourceSha, sourceSha: sha,
workspace, workspace,
dockerEnv, 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 ${sourceSha}; no rebuild or deployment performed.`); 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 \`${sourceSha}\` from \`${sourceBranch}\` without rebuilding.`); appendSummary(`Validated release ${release.tag} against tested candidate \`${sha}\` from \`${sourceBranch}\` without rebuilding.`);
return; return;
} }
const mergeSourceSha = resolvePromotionSource(workspace, pipeline.source); const sourceSha = 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 release = loadRelease(workspace, pipeline.release);
const artifacts = new Map(); const artifacts = new Map();
for (const artifact of pipeline.artifacts) { for (const artifact of pipeline.artifacts) {
artifacts.set(artifact.name, pullCandidateArtifact({ artifacts.set(artifact.name, await pullCandidateArtifact({
artifact, artifact,
registry, registry,
sourceSha, sourceSha,
@@ -1113,8 +798,6 @@ 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 });
const actionMode = resolveActionMode(process.env.QUICKSTACK_ACTION_MODE);
const artifactIndex = resolveArtifactIndex(process.env.QUICKSTACK_ARTIFACT_INDEX);
if (config.version === 2) { if (config.version === 2) {
const pipeline = validateVersion2Pipeline(config, branch); const pipeline = validateVersion2Pipeline(config, branch);
if (!pipeline) { if (!pipeline) {
@@ -1123,19 +806,7 @@ async function main() {
} }
const sha = requiredString(process.env.GITHUB_SHA ?? process.env.GITEA_SHA, "Git SHA").toLowerCase(); 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."); if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new Error("Git SHA must be a full hexadecimal commit ID.");
await executeVersion2({ await executeVersion2({ config, pipeline, eventName, sha, workspace });
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; return;
} }
const deployment = selectDeployment(config, branch); const deployment = selectDeployment(config, branch);
+37 -138
View File
@@ -1,27 +1,16 @@
import assert from "node:assert/strict"; 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 test from "node:test";
import { import {
applyVolumeMountPaths,
classifyVersion2Execution, classifyVersion2Execution,
deployExactImage, deployExactImage,
expandTokens, expandTokens,
mergeEnvironment, mergeEnvironment,
isValidationOnlyCandidateBuild, pullCandidateArtifact,
normalizeVolumeMountPaths,
parseEnvironment,
resolveActionMode,
resolveArtifactIndex,
resolveBuildSecretArguments,
resolveSecretEnvironment, resolveSecretEnvironment,
orderApplications, orderApplications,
resolveDeploymentBranch, resolveDeploymentBranch,
resolvePullRequestHeadBranch, resolvePullRequestHeadBranch,
resolveTreeEquivalentCandidateSha,
selectDeployment, selectDeployment,
selectPipeline, selectPipeline,
toSavePayload, toSavePayload,
@@ -111,77 +100,44 @@ test("production pull requests validate tested candidates without rebuilding", (
); );
}); });
test("same-repository pull requests publish reusable candidates while forks stay validation-only", () => { test("production pull requests wait for a concurrently published candidate", async () => {
assert.equal( const registry = "gitea.nuvisphere.de";
isValidationOnlyCandidateBuild({ const artifact = {
eventName: "pull_request", name: "webapp",
sameRepositoryPullRequest: true, image: "innomieter/webapp",
}), requiredFiles: [],
false, };
); const sourceSha = "1234567890abcdef1234567890abcdef12345678";
assert.equal( const digest = `sha256:${"a".repeat(64)}`;
isValidationOnlyCandidateBuild({ const sleeps = [];
eventName: "pull_request", let pullCalls = 0;
sameRepositoryPullRequest: false, const runCommand = (_command, args) => {
}), if (args[0] === "pull") {
true, pullCalls += 1;
); if (pullCalls < 3) throw new Error("manifest unknown");
assert.equal( return "";
isValidationOnlyCandidateBuild({ }
eventName: "push", if (args[0] === "image") {
sameRepositoryPullRequest: false, return `${registry}/${artifact.image}@${digest}\n`;
}), }
false, throw new Error(`Unexpected Docker command: ${args.join(" ")}`);
); };
});
test("tree-equivalent merge commits resolve to their tested candidate parent", () => { const result = await pullCandidateArtifact({
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-merge-candidate-")); artifact,
const git = (args) => execFileSync("git", args, { registry,
cwd: workspace, sourceSha,
encoding: "utf8", workspace: "/workspace",
stdio: ["ignore", "pipe", "pipe"], dockerEnv: {},
}).trim(); pullAttempts: 3,
try { pullRetryMs: 25,
git(["init"]); runCommand,
git(["checkout", "-b", "main"]); sleep: async (ms) => sleeps.push(ms),
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(pullCalls, 3);
assert.equal(resolveTreeEquivalentCandidateSha(workspace, prodMergeSha), featureSha); assert.deepEqual(sleeps, [25, 25]);
assert.equal(resolveTreeEquivalentCandidateSha(workspace, featureSha), featureSha); assert.equal(result.exactImage, `${registry}/${artifact.image}@${digest}`);
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", () => { test("validates OCI paths and repository-local build paths", () => {
@@ -234,43 +190,6 @@ test("version 2 validates promotion contracts and token expansion", () => {
() => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])), () => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])),
/unknown artifact/, /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", () => { test("application dependencies reject missing nodes and cycles", () => {
@@ -291,26 +210,6 @@ 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");
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", () => { test("maps only explicitly declared Actions secrets into runtime environment", () => {
+1 -1
View File
@@ -13,7 +13,7 @@ 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: write contents: write
-79
View File
@@ -1,79 +0,0 @@
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"
+5 -27
View File
@@ -50,9 +50,6 @@ Use manifest version 2 when one image serves multiple applications or Production
"image": "example/web", "image": "example/web",
"dockerfile": "Dockerfile", "dockerfile": "Dockerfile",
"buildArgs": { "BUILD_SHA": "$sha12" }, "buildArgs": { "BUILD_SHA": "$sha12" },
"buildSecrets": {
"framework-build-key": "FRAMEWORK_BUILD_KEY"
},
"requiredFiles": ["/app/server.js"] "requiredFiles": ["/app/server.js"]
} }
], ],
@@ -68,7 +65,6 @@ Use manifest version 2 when one image serves multiple applications or Production
"artifact": "web", "artifact": "web",
"appId": "app-worker-staging", "appId": "app-worker-staging",
"dependsOn": ["web"], "dependsOn": ["web"],
"environmentFromAppId": "app-staging",
"environment": { "PROCESS": "worker" } "environment": { "PROCESS": "worker" }
} }
] ]
@@ -91,27 +87,9 @@ Use manifest version 2 when one image serves multiple applications or Production
} }
``` ```
`buildSecrets` is optional and maps a BuildKit secret mount ID to the name of 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.
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 When a promotion pull request and its source-branch push start concurrently, the
environment from another application before applying the target application's pull-request check waits for the immutable candidate tag to become available.
declared `environment` and `secretEnvironment` overrides. This is intended for This bounded retry applies only to promotion pull requests; an actual promotion
workers that share backend credentials with an API without duplicating secret still fails immediately when its tested candidate is missing.
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.
@@ -1,2 +0,0 @@
*
!Dockerfile
-27
View File
@@ -1,27 +0,0 @@
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
-29
View File
@@ -1,29 +0,0 @@
# 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>
```