feat(build): support optional BuildKit secrets
Platform CI tests / QuickStack deploy action tests (pull_request) Successful in 40s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (pull_request) Successful in 27s
Platform CI tests / QuickStack deploy action tests (push) Successful in 28s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 29s

This commit was merged in pull request #4.
This commit is contained in:
2026-08-25 10:00:36 +02:00
parent b6a550e9a3
commit c8bc0ff58d
3 changed files with 66 additions and 0 deletions
+28
View File
@@ -374,6 +374,32 @@ function validateImagePath(value, name) {
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) => {
@@ -396,6 +422,7 @@ export function validateArtifact(artifact) {
dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
context: safeRelative(artifact.context ?? ".", `Build context for ${name}`),
buildArgs,
buildSecrets: normalizeBuildSecrets(artifact.buildSecrets),
requiredFiles,
};
}
@@ -566,6 +593,7 @@ function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validati
for (const [key, value] of Object.entries(artifact.buildArgs)) {
buildArgs.push("--build-arg", `${key}=${expandTokens(value, { sha })}`);
}
buildArgs.push(...resolveBuildSecretArguments(artifact.buildSecrets, dockerEnv));
buildArgs.push(artifact.context);
console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`);
run("docker", buildArgs, { cwd: workspace, env: dockerEnv });
@@ -6,6 +6,7 @@ import {
deployExactImage,
expandTokens,
mergeEnvironment,
resolveBuildSecretArguments,
resolveSecretEnvironment,
orderApplications,
resolveDeploymentBranch,
@@ -151,6 +152,34 @@ test("version 2 validates promotion contracts and token expansion", () => {
);
});
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"] }]),