From c8bc0ff58d8d0b37395b96366e2bdb9ae169d758 Mon Sep 17 00:00:00 2001 From: Vadime Date: Tue, 25 Aug 2026 10:00:36 +0200 Subject: [PATCH] feat(build): support optional BuildKit secrets --- .gitea/actions/quickstack-oci/deploy.mjs | 28 ++++++++++++++++++ .gitea/actions/quickstack-oci/deploy.test.mjs | 29 +++++++++++++++++++ README.md | 9 ++++++ 3 files changed, 66 insertions(+) diff --git a/.gitea/actions/quickstack-oci/deploy.mjs b/.gitea/actions/quickstack-oci/deploy.mjs index 2d9bea6..8ae6301 100644 --- a/.gitea/actions/quickstack-oci/deploy.mjs +++ b/.gitea/actions/quickstack-oci/deploy.mjs @@ -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 }); diff --git a/.gitea/actions/quickstack-oci/deploy.test.mjs b/.gitea/actions/quickstack-oci/deploy.test.mjs index 27b4486..17174ad 100644 --- a/.gitea/actions/quickstack-oci/deploy.test.mjs +++ b/.gitea/actions/quickstack-oci/deploy.test.mjs @@ -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"] }]), diff --git a/README.md b/README.md index e88d561..682165f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ Use manifest version 2 when one image serves multiple applications or Production "image": "example/web", "dockerfile": "Dockerfile", "buildArgs": { "BUILD_SHA": "$sha12" }, + "buildSecrets": { + "framework-build-key": "FRAMEWORK_BUILD_KEY" + }, "requiredFiles": ["/app/server.js"] } ], @@ -87,4 +90,10 @@ 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 +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=,required=true ...`. + Candidate pipelines build every artifact once, push `sha-`, 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-` 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. -- 2.54.0