Author SHA1 Message Date
vadimenovikau ed7a5c0ff8 Add QuickStack runner job image
Platform CI tests / QuickStack deploy action tests (push) Successful in 8s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 9s
QuickStack runner job image / Build and publish runner image (push) Successful in 51s
2026-09-01 13:52:23 +02:00
vadimenovikau f520255d61 perf(ci): reuse tree-equivalent merge candidates
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 23s
Platform CI tests / QuickStack deploy action tests (push) Successful in 31s
2026-08-26 15:16:12 +02:00
vadimenovikau 0cc4f072e7 perf(ci): reuse verified pull request candidates
Platform CI tests / QuickStack deploy action tests (pull_request) Successful in 25s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (pull_request) Successful in 28s
Platform CI tests / QuickStack deploy action tests (push) Successful in 21s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 28s
2026-08-26 12:22:27 +02:00
vadimenovikau f39d803d7a Merge pull request #5 from codex/quickstack-environment-source
Platform CI tests / QuickStack deploy action tests (push) Successful in 25s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 23s
QuickStack: Laufzeitumgebung und Volume-Mounts sicher vererben
2026-08-25 16:52:54 +00:00
7 changed files with 315 additions and 15 deletions
+108 -14
View File
@@ -369,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("..")) {
@@ -621,6 +631,13 @@ export function isSameRepositoryPullRequest({ eventName, environment = process.e
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";
@@ -653,6 +670,28 @@ export function validatePromotionPullRequestSource(pipeline, headBranch) {
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`);
@@ -704,8 +743,26 @@ function buildArtifact({
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 = [
@@ -744,6 +801,14 @@ function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, docke
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],
@@ -857,7 +922,14 @@ async function executeVersion2({
artifactIndex = 0,
}) {
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
const validationOnly = eventName === "pull_request";
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]";
@@ -872,6 +944,12 @@ async function executeVersion2({
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") &&
@@ -891,15 +969,18 @@ async function executeVersion2({
const built = buildArtifact({
artifact,
registry,
sha,
sha: candidateSha,
workspace,
dockerEnv,
validationOnly: execution === "build-validation",
validationOnly,
useRegistryCache,
reuseExistingCandidate: !validationOnly,
});
appendSummary(
execution === "build-validation"
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;
@@ -916,7 +997,7 @@ async function executeVersion2({
artifacts.set(artifact.name, pullCandidateArtifact({
artifact,
registry,
sourceSha: sha,
sourceSha: candidateSha,
workspace,
dockerEnv,
}));
@@ -925,7 +1006,7 @@ async function executeVersion2({
baseUrl: process.env.QUICKSTACK_BASE_URL,
token: process.env.QUICKSTACK_API_TOKEN,
});
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
await deployApplications({ applications: pipeline.applications, artifacts, sha: candidateSha, client });
return;
}
@@ -935,44 +1016,57 @@ async function executeVersion2({
artifacts.set(artifact.name, buildArtifact({
artifact,
registry,
sha,
sha: candidateSha,
workspace,
dockerEnv,
validationOnly: execution === "build-validation",
validationOnly,
useRegistryCache,
reuseExistingCandidate: !validationOnly,
}));
}
if (execution === "build-validation") {
appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`);
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, client });
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: sha,
sourceSha,
workspace,
dockerEnv,
}));
}
console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sha}; no rebuild or deployment performed.`);
appendSummary(`Validated release ${release.tag} against tested candidate \`${sha}\` from \`${sourceBranch}\` without rebuilding.`);
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 sourceSha = resolvePromotionSource(workspace, pipeline.source);
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) {
@@ -1,4 +1,8 @@
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 {
@@ -7,6 +11,7 @@ import {
deployExactImage,
expandTokens,
mergeEnvironment,
isValidationOnlyCandidateBuild,
normalizeVolumeMountPaths,
parseEnvironment,
resolveActionMode,
@@ -16,6 +21,7 @@ import {
orderApplications,
resolveDeploymentBranch,
resolvePullRequestHeadBranch,
resolveTreeEquivalentCandidateSha,
selectDeployment,
selectPipeline,
toSavePayload,
@@ -105,6 +111,69 @@ test("production pull requests validate tested candidates without rebuilding", (
);
});
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");
+79
View File
@@ -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"
+1 -1
View File
@@ -106,7 +106,7 @@ 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. 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.
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
@@ -0,0 +1,2 @@
*
!Dockerfile
+27
View File
@@ -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
+29
View File
@@ -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>
```