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

This commit was merged in pull request #6.
This commit is contained in:
2026-08-26 12:22:27 +02:00
parent f39d803d7a
commit 0cc4f072e7
3 changed files with 79 additions and 6 deletions
+53 -5
View File
@@ -621,6 +621,13 @@ export function isSameRepositoryPullRequest({ eventName, environment = process.e
return Boolean(baseRepository && headRepository && baseRepository === headRepository); 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";
@@ -704,8 +711,26 @@ function buildArtifact({
dockerEnv, dockerEnv,
validationOnly, validationOnly,
useRegistryCache = !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 cacheImage = `${registry}/${artifact.image}:buildcache`;
const builder = ensureBuildxBuilder(dockerEnv); const builder = ensureBuildxBuilder(dockerEnv);
const buildArgs = [ const buildArgs = [
@@ -744,6 +769,14 @@ function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, docke
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`; const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv }); run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { 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( const repoDigests = run(
"docker", "docker",
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage], ["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
@@ -857,7 +890,14 @@ async function executeVersion2({
artifactIndex = 0, 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 validationOnly = eventName === "pull_request"; const sameRepositoryPullRequest = isSameRepositoryPullRequest({
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]";
@@ -894,12 +934,15 @@ async function executeVersion2({
sha, sha,
workspace, workspace,
dockerEnv, dockerEnv,
validationOnly: execution === "build-validation", validationOnly,
useRegistryCache, useRegistryCache,
reuseExistingCandidate: !validationOnly,
}); });
appendSummary( appendSummary(
execution === "build-validation" validationOnly
? `Validated OCI artifact \`${artifact.name}\` for \`${pipeline.branch}\`.` ? `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}\`.`, : `Built OCI artifact \`${artifact.name}\`: \`${built.exactImage}\`.`,
); );
return; return;
@@ -938,12 +981,17 @@ async function executeVersion2({
sha, sha,
workspace, workspace,
dockerEnv, dockerEnv,
validationOnly: execution === "build-validation", validationOnly,
useRegistryCache, useRegistryCache,
reuseExistingCandidate: !validationOnly,
})); }));
} }
if (execution === "build-validation") { 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; return;
} }
const client = createQuickStackClient({ const client = createQuickStackClient({
@@ -7,6 +7,7 @@ import {
deployExactImage, deployExactImage,
expandTokens, expandTokens,
mergeEnvironment, mergeEnvironment,
isValidationOnlyCandidateBuild,
normalizeVolumeMountPaths, normalizeVolumeMountPaths,
parseEnvironment, parseEnvironment,
resolveActionMode, resolveActionMode,
@@ -105,6 +106,30 @@ 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("validates split action modes and artifact build slots", () => { test("validates split action modes and artifact build slots", () => {
assert.equal(resolveActionMode(undefined), "all"); assert.equal(resolveActionMode(undefined), "all");
assert.equal(resolveActionMode("BUILD"), "build"); assert.equal(resolveActionMode("BUILD"), "build");
+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 creates or replaces a volume. QuickStack's deployment identity variable is
never inherited from the source application. 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. 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, 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 Scoped workflows can distribute version 2 artifact builds across independent
runner jobs by invoking the action with `mode: build` and a zero-based runner jobs by invoking the action with `mode: build` and a zero-based