From 9846b0510a98a5d254746d835f21dc989fdf4db3 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:35:38 +0200 Subject: [PATCH 1/3] build/bake: secrets support Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .github/workflows/.test-bake.yml | 17 ++++++ .github/workflows/.test-build.yml | 16 ++++++ .github/workflows/bake.yml | 88 +++++++++++++++++++++++++++++-- .github/workflows/build.yml | 81 ++++++++++++++++++++++++++-- README.md | 56 ++++++++++++++++++++ test/docker-bake.hcl | 8 +++ test/secret.Dockerfile | 11 ++++ 7 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 test/secret.Dockerfile diff --git a/.github/workflows/.test-bake.yml b/.github/workflows/.test-bake.yml index 15f1ecf2..e7bd8d65 100644 --- a/.github/workflows/.test-bake.yml +++ b/.github/workflows/.test-bake.yml @@ -514,6 +514,23 @@ jobs: const builderOutputs = JSON.parse(core.getInput('builder-outputs')); core.info(JSON.stringify(builderOutputs, null, 2)); + bake-secret: + uses: ./.github/workflows/bake.yml + permissions: + contents: read + id-token: write + with: + artifact-upload: false + context: test + output: local + target: secret + secrets: + build-secrets: | + fixture_plain: | + alpha-line + beta-line + secret.fixture_json: ${{ toJSON(format('gamma-line{0}delta-line{0}', fromJSON('"\n"'))) }} + bake-set-runner: uses: ./.github/workflows/bake.yml permissions: diff --git a/.github/workflows/.test-build.yml b/.github/workflows/.test-build.yml index 8bf2d47d..16710b6c 100644 --- a/.github/workflows/.test-build.yml +++ b/.github/workflows/.test-build.yml @@ -560,6 +560,22 @@ jobs: const builderOutputs = JSON.parse(core.getInput('builder-outputs')); core.info(JSON.stringify(builderOutputs, null, 2)); + build-secret: + uses: ./.github/workflows/build.yml + permissions: + contents: read + id-token: write + with: + artifact-upload: false + file: test/secret.Dockerfile + output: local + secrets: + build-secrets: | + fixture_plain: | + alpha-line + beta-line + fixture_json: ${{ toJSON(format('gamma-line{0}delta-line{0}', fromJSON('"\n"'))) }} + build-set-runner: uses: ./.github/workflows/build.yml permissions: diff --git a/.github/workflows/bake.yml b/.github/workflows/bake.yml index 86e0b2d1..ad99341e 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -143,6 +143,9 @@ on: registry-auths: description: "Raw authentication to registries, defined as YAML objects (for image output)" required: false + build-secrets: + description: "YAML object mapping BuildKit secret IDs, optionally target-scoped, to secret values" + required: false github-token: description: "GitHub Token used to authenticate against the repository for Git context" required: false @@ -480,7 +483,7 @@ jobs: } ); await core.group(`Set envs`, async () => { - core.info(JSON.stringify(envs, null, 2)); + core.info(JSON.stringify(Object.keys(envs).sort(), null, 2)); }); const metaImages = inpMetaImages.map(image => image.toLowerCase()); @@ -831,6 +834,7 @@ jobs: INPUT_CACHE: ${{ inputs.cache }} INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }} INPUT_CACHE-MODE: ${{ inputs.cache-mode }} + INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_FILES: ${{ inputs.files }} INPUT_OUTPUT: ${{ inputs.output }} @@ -854,7 +858,14 @@ jobs: const { Build } = require('@docker/github-builder-runtime/lib/buildx/build'); const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { Util } = require('@docker/github-builder-runtime/lib/util'); - + + let yaml; + try { + yaml = require('js-yaml'); + } catch { + yaml = require('@docker/github-builder-runtime/node_modules/js-yaml'); + } + const inpPlatform = core.getInput('platform'); const platformPairSuffix = inpPlatform ? `-${inpPlatform.replace(/\//g, '-')}` : ''; core.setOutput('platform-pair-suffix', platformPairSuffix); @@ -865,6 +876,7 @@ jobs: const inpCache = core.getBooleanInput('cache'); const inpCacheScope = core.getInput('cache-scope'); const inpCacheMode = core.getInput('cache-mode'); + const inpBuildSecrets = core.getInput('build-secrets'); const inpContext = core.getInput('context'); const inpFiles = Util.getInputList('files'); const inpOutput = core.getInput('output'); @@ -888,6 +900,56 @@ jobs: tags: inpMetaTags }; const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta}); + const parseBuildSecrets = value => { + const normalized = value.trim(); + if (!normalized) { + return []; + } + let parsed; + try { + parsed = yaml.load(normalized, {schema: yaml.FAILSAFE_SCHEMA}); + } catch (err) { + const location = err.mark ? ` at line ${err.mark.line + 1}, column ${err.mark.column + 1}` : ''; + throw new Error(`Failed to parse build-secrets YAML${location}`); + } + if (!parsed) { + return []; + } + if (Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error('build-secrets must be a YAML object'); + } + const secrets = []; + const seen = new Set(); + for (const [key, secret] of Object.entries(parsed)) { + const separator = key.lastIndexOf('.'); + const target = separator === -1 ? inpTarget : key.substring(0, separator); + const id = separator === -1 ? key : key.substring(separator + 1); + if (!target) { + throw new Error(`Invalid build secret target for "${key}": target must not be empty`); + } + if (!/^[A-Za-z0-9_.-]+$/.test(target)) { + throw new Error(`Invalid build secret target "${target}": use letters, digits, dots, underscores or dashes`); + } + if (!/^[A-Za-z0-9_-]+$/.test(id)) { + throw new Error(`Invalid build secret id "${id}": use letters, digits, underscores or dashes`); + } + if (typeof secret !== 'string') { + throw new Error(`build-secrets value for "${key}" must be a string`); + } + if (secret.length === 0) { + throw new Error(`build-secrets value for "${key}" must not be empty`); + } + const ref = `${target}\0${id}`; + if (seen.has(ref)) { + throw new Error(`Build secret id "${id}" is defined more than once for target "${target}"`); + } + seen.add(ref); + core.setSecret(secret); + secrets.push({target, id, secret}); + } + return secrets; + }; + const toBuildSecretEnvName = (id, index) => `BUILD_SECRET_${index}_${id.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`; const gitContextAttrs = GitHub.context.ref.startsWith('refs/tags/') ? {checksum: GitHub.context.sha} : {'fetch-by-commit': 'true'}; const bakeSource = await new Build().gitContext({subdir: inpContext, attrs: gitContextAttrs}); @@ -906,6 +968,14 @@ jobs: core.info(sbom); core.setOutput('sbom', sbom); }); + + let buildSecrets; + try { + buildSecrets = parseBuildSecrets(inpBuildSecrets); + } catch (err) { + core.setFailed(err.message); + return; + } const envs = Object.assign({}, inpVars ? inpVars.reduce((acc, curr) => { @@ -920,9 +990,17 @@ jobs: BUILDX_BAKE_GIT_AUTH_TOKEN: inpGitHubToken } ); + const secretOverrides = []; + buildSecrets.forEach(({target, id, secret}, index) => { + const envName = toBuildSecretEnvName(id, index); + envs[envName] = secret; + secretOverrides.push(`${target}.secrets+=id=${id},env=${envName}`); + }); await core.group(`Set envs`, async () => { - core.info(JSON.stringify(envs, null, 2)); - core.setOutput('envs', JSON.stringify(envs)); + core.info(JSON.stringify(Object.keys(envs).sort(), null, 2)); + Object.entries(envs).forEach(([key, value]) => { + core.exportVariable(key, value); + }); }); let bakeFiles = inpFiles; @@ -984,6 +1062,7 @@ jobs: bakeOverrides.push(`*.cache-from=type=gha,scope=${inpCacheScope || inpTarget}${platformPairSuffix}`); bakeOverrides.push(`*.cache-to=type=gha,ignore-error=true,scope=${inpCacheScope || inpTarget}${platformPairSuffix},mode=${inpCacheMode}`); } + bakeOverrides.push(...secretOverrides); core.info(JSON.stringify(bakeOverrides, null, 2)); core.setOutput('overrides', bakeOverrides.join(os.EOL)); }); @@ -1048,7 +1127,6 @@ jobs: targets: ${{ steps.prepare.outputs.target }} sbom: ${{ steps.prepare.outputs.sbom }} set: ${{ steps.prepare.outputs.overrides }} - env: ${{ fromJson(steps.prepare.outputs.envs || '{}') }} - name: Get image digest id: get-image-digest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 008cbe7a..96036bc6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -154,6 +154,9 @@ on: registry-auths: description: "Raw authentication to registries, defined as YAML objects (for image output)" required: false + build-secrets: + description: "YAML object mapping BuildKit secret IDs to secret values" + required: false github-token: description: "GitHub Token used to authenticate against the repository for Git context" required: false @@ -733,6 +736,7 @@ jobs: INPUT_CACHE: ${{ inputs.cache }} INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }} INPUT_CACHE-MODE: ${{ inputs.cache-mode }} + INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }} INPUT_LABELS: ${{ inputs.labels }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_OUTPUT: ${{ inputs.output }} @@ -747,12 +751,20 @@ jobs: INPUT_META-ANNOTATIONS: ${{ steps.meta.outputs.annotations }} INPUT_SET-META-LABELS: ${{ inputs.set-meta-labels }} INPUT_META-LABELS: ${{ steps.meta.outputs.labels }} + INPUT_GITHUB-TOKEN: ${{ secrets.github-token || github.token }} with: script: | const { Build } = require('@docker/github-builder-runtime/lib/buildx/build'); const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { Util } = require('@docker/github-builder-runtime/lib/util'); - + + let yaml; + try { + yaml = require('js-yaml'); + } catch { + yaml = require('@docker/github-builder-runtime/node_modules/js-yaml'); + } + const inpPlatform = core.getInput('platform'); const platformPairSuffix = inpPlatform ? `-${inpPlatform.replace(/\//g, '-')}` : ''; core.setOutput('platform-pair-suffix', platformPairSuffix); @@ -766,6 +778,7 @@ jobs: const inpCache = core.getBooleanInput('cache'); const inpCacheScope = core.getInput('cache-scope'); const inpCacheMode = core.getInput('cache-mode'); + const inpBuildSecrets = core.getInput('build-secrets'); const inpContext = core.getInput('context'); const inpLabels = core.getInput('labels'); const inpOutput = core.getInput('output'); @@ -781,6 +794,7 @@ jobs: const inpMetaAnnotations = core.getMultilineInput('meta-annotations'); const inpSetMetaLabels = core.getBooleanInput('set-meta-labels'); const inpMetaLabels = core.getMultilineInput('meta-labels'); + const inpGitHubToken = core.getInput('github-token'); const meta = { version: inpMetaVersion, @@ -789,6 +803,44 @@ jobs: const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta}); const toMultilineInput = value => value.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + const parseBuildSecrets = value => { + const normalized = value.trim(); + if (!normalized) { + return {}; + } + let parsed; + try { + parsed = yaml.load(normalized, {schema: yaml.FAILSAFE_SCHEMA}); + } catch (err) { + const location = err.mark ? ` at line ${err.mark.line + 1}, column ${err.mark.column + 1}` : ''; + throw new Error(`Failed to parse build-secrets YAML${location}`); + } + if (!parsed) { + return {}; + } + if (Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error('build-secrets must be a YAML object'); + } + const secrets = {}; + for (const [id, secret] of Object.entries(parsed)) { + if (!/^[A-Za-z0-9_-]+$/.test(id)) { + throw new Error(`Invalid build secret id "${id}": use letters, digits, underscores or dashes`); + } + if (id === 'GIT_AUTH_TOKEN') { + throw new Error('Build secret id "GIT_AUTH_TOKEN" is reserved for Git context authentication'); + } + if (typeof secret !== 'string') { + throw new Error(`build-secrets value for "${id}" must be a string`); + } + if (secret.length === 0) { + throw new Error(`build-secrets value for "${id}" must not be empty`); + } + core.setSecret(secret); + secrets[id] = secret; + } + return secrets; + }; + const toBuildSecretEnvName = (id, index) => `BUILD_SECRET_${index}_${id.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`; const gitContextAttrs = GitHub.context.ref.startsWith('refs/tags/') ? {checksum: GitHub.context.sha} : {'fetch-by-commit': 'true'}; const buildContext = await new Build().gitContext({subdir: inpContext, attrs: gitContextAttrs}); @@ -845,6 +897,28 @@ jobs: } core.setOutput('labels', labels.join('\n')); core.setOutput('build-args', buildArgs); + + let buildSecrets; + try { + buildSecrets = parseBuildSecrets(inpBuildSecrets); + } catch (err) { + core.setFailed(err.message); + return; + } + const envs = { + BUILDKIT_MULTI_PLATFORM: '1', + GIT_AUTH_TOKEN: inpGitHubToken + }; + const secretEnvs = ['GIT_AUTH_TOKEN=GIT_AUTH_TOKEN']; + Object.entries(buildSecrets).forEach(([id, secret], index) => { + const envName = toBuildSecretEnvName(id, index); + envs[envName] = secret; + secretEnvs.push(`${id}=${envName}`); + }); + Object.entries(envs).forEach(([key, value]) => { + core.exportVariable(key, value); + }); + core.setOutput('secret-envs', secretEnvs.join('\n')); if (GitHub.context.payload.repository?.private ?? false) { // if this is a private repository, we set min provenance mode @@ -920,13 +994,10 @@ jobs: platforms: ${{ steps.prepare.outputs.platform }} provenance: ${{ steps.prepare.outputs.provenance }} sbom: ${{ steps.prepare.outputs.sbom }} - secret-envs: GIT_AUTH_TOKEN=GIT_AUTH_TOKEN + secret-envs: ${{ steps.prepare.outputs.secret-envs }} shm-size: ${{ inputs.shm-size }} target: ${{ inputs.target }} ulimit: ${{ inputs.ulimit }} - env: - BUILDKIT_MULTI_PLATFORM: 1 - GIT_AUTH_TOKEN: ${{ secrets.github-token || github.token }} - name: Login to registry for signing if: ${{ needs.prepare.outputs.sign == 'true' && inputs.output == 'image' && env.REGISTRY_AUTHS_PRESENT == 'true' }} diff --git a/README.md b/README.md index 279b8f65..f42db616 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ ___ * [Secrets](#secrets-1) * [Outputs](#outputs-1) * [Notes](#notes) + * [BuildKit secrets](#buildkit-secrets) * [Signed GitHub Actions cache](#signed-github-actions-cache) * [Registry identities](#registry-identities) * [Docker Hub OIDC](#docker-hub-oidc) @@ -258,6 +259,7 @@ jobs: | Name | Default | Description | |------------------|-----------------------|--------------------------------------------------------------------------------| | `registry-auths` | | Raw authentication to registries, defined as YAML objects (for `image` output) | +| `build-secrets` | | YAML object mapping BuildKit secret IDs to secret values | | `github-token` | `${{ github.token }}` | GitHub Token used to authenticate against the repository for Git context | ### Outputs @@ -370,6 +372,7 @@ jobs: | Name | Default | Description | |------------------|-----------------------|--------------------------------------------------------------------------------| | `registry-auths` | | Raw authentication to registries, defined as YAML objects (for `image` output) | +| `build-secrets` | | YAML object mapping BuildKit secret IDs, optionally target-scoped, to secret values | | `github-token` | `${{ github.token }}` | GitHub Token used to authenticate against the repository for Git context | ### Outputs @@ -390,6 +393,59 @@ with `builder-outputs: ${{ toJSON(needs..outputs) }}`. ## Notes +### BuildKit secrets + +The `build-secrets` secret is shared by the build and bake workflows. It must +be a YAML object. For the build workflow, each key is the BuildKit secret ID and +each value is the secret payload. The bake workflow accepts the same unqualified +keys. Secret IDs may contain letters, digits, underscores, and dashes: + +```yaml +secrets: + build-secrets: | + npmrc: ${{ toJSON(secrets.NPMRC) }} + aws_credentials: ${{ toJSON(secrets.AWS_CREDENTIALS) }} + inline_config: | + first line + second line +``` + +Use `toJSON(...)` when injecting GitHub secrets so values that contain newlines +or YAML syntax are preserved as a single YAML scalar. + +Each secret is exposed as an env-backed BuildKit secret. The build workflow +passes these values through `docker/build-push-action` `secret-envs`, and the +bake workflow appends matching `target.secrets+=id=...,env=...` overrides. For +the bake workflow, an unqualified key applies to the workflow `target` input. A +key written as `target.secret_id` applies only to that Bake target. This +target-scoped key form is only accepted by the bake workflow: + +```yaml +secrets: + build-secrets: | + default.npmrc: ${{ toJSON(secrets.NPMRC) }} + release.aws_credentials: ${{ toJSON(secrets.AWS_CREDENTIALS) }} +``` + +Bake targets can declare local secret sources for direct `docker buildx bake` +usage. When the reusable workflow receives a matching `build-secrets` entry, it +overrides that source with the workflow-provided secret value: + +```hcl +target "default" { + secret = [ + "id=npmrc,env=NPMRC", + "type=file,id=aws_credentials,src=${HOME}/.aws/credentials", + ] +} +``` + +The workflow does not accept file-based secret payloads. `build-secrets` values +are always exposed to BuildKit from environment variables. A Bake target can +still declare a file-based source for local `docker buildx bake` usage, as shown +above, but a matching `build-secrets` entry overrides that source in the reusable +workflow. + ### Signed GitHub Actions cache When the workflow has GitHub OIDC available through `id-token: write`, BuildKit diff --git a/test/docker-bake.hcl b/test/docker-bake.hcl index fbea740c..7e69aefb 100644 --- a/test/docker-bake.hcl +++ b/test/docker-bake.hcl @@ -38,6 +38,14 @@ target "hello-cross" { platforms = ["linux/amd64", "linux/arm64"] } +target "secret" { + dockerfile = "secret.Dockerfile" + secret = [ + "id=fixture_plain,env=FIXTURE_PLAIN", + "id=fixture_json,env=FIXTURE_JSON", + ] +} + target "go-cross-with-contexts" { inherits = ["go-cross"] contexts = { diff --git a/test/secret.Dockerfile b/test/secret.Dockerfile new file mode 100644 index 00000000..a1cfc56f --- /dev/null +++ b/test/secret.Dockerfile @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1 + +FROM alpine +RUN --mount=type=secret,id=fixture_plain,env=fixture_plain \ + --mount=type=secret,id=fixture_json,env=fixture_json \ + printf 'fixture_plain=%s\n' "$fixture_plain" && \ + printf 'fixture_json=%s\n' "$fixture_json" && \ + printf 'alpha-line\nbeta-line\n' > /tmp/expected && \ + printf '%s' "$fixture_plain" | cmp - /tmp/expected && \ + printf 'gamma-line\ndelta-line\n' > /tmp/expected-json && \ + printf '%s' "$fixture_json" | cmp - /tmp/expected-json From 7ccb6da558794827d0d675cc1afa964b5411690e Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:49:47 +0200 Subject: [PATCH 2/3] bake: require declared secrets Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .github/workflows/bake.yml | 43 +++++++++++++++++++++++++++++++++++++- README.md | 5 +++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bake.yml b/.github/workflows/bake.yml index ad99341e..4d2dc92d 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -211,6 +211,7 @@ jobs: includes: ${{ steps.set.outputs.includes }} metaImages: ${{ steps.set.outputs.metaImages }} sign: ${{ steps.set.outputs.sign }} + secretIds: ${{ steps.set.outputs.secretIds }} ghaCacheSign: ${{ steps.set.outputs.ghaCacheSign }} steps: - @@ -521,6 +522,16 @@ jobs: const match = value.match(/^target:(.+)$/); return match ? match[1] : undefined; }; + const parseSecretId = secret => { + if (typeof secret === 'string') { + const idAttr = secret.split(',').map(attr => attr.trim()).find(attr => attr.startsWith('id=')); + return idAttr ? idAttr.substring(3) : undefined; + } + if (secret && typeof secret === 'object' && typeof secret.id === 'string') { + return secret.id; + } + return undefined; + }; const resolveTarget = () => { if (targetDefs[inpTarget]) { return inpTarget; @@ -549,6 +560,11 @@ jobs: if (unsupportedTargets.length > 0) { throw new Error(`Only one target can be built at once, found unsupported targets: ${unsupportedTargets.join(', ')}`); } + const secretIds = {}; + for (const name of allowedTargets) { + secretIds[name] = (targetDefs[name]?.secret || []).map(parseSecretId).filter(Boolean); + } + core.setOutput('secretIds', JSON.stringify(secretIds)); }); } catch (error) { core.setFailed(error); @@ -835,6 +851,7 @@ jobs: INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }} INPUT_CACHE-MODE: ${{ inputs.cache-mode }} INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }} + INPUT_SECRET-IDS: ${{ needs.prepare.outputs.secretIds }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_FILES: ${{ inputs.files }} INPUT_OUTPUT: ${{ inputs.output }} @@ -877,6 +894,7 @@ jobs: const inpCacheScope = core.getInput('cache-scope'); const inpCacheMode = core.getInput('cache-mode'); const inpBuildSecrets = core.getInput('build-secrets'); + const inpSecretIds = core.getInput('secret-ids'); const inpContext = core.getInput('context'); const inpFiles = Util.getInputList('files'); const inpOutput = core.getInput('output'); @@ -900,6 +918,7 @@ jobs: tags: inpMetaTags }; const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta}); + const parseBuildSecrets = value => { const normalized = value.trim(); if (!normalized) { @@ -949,6 +968,19 @@ jobs: } return secrets; }; + + const validateBuildSecrets = (secrets, secretIds) => { + for (const {target, id} of secrets) { + const targetSecretIds = secretIds[target]; + if (!targetSecretIds) { + throw new Error(`Build secret target "${target}" is not part of the resolved Bake definition`); + } + if (!Array.isArray(targetSecretIds) || !targetSecretIds.includes(id)) { + throw new Error(`Build secret "${id}" must be declared in Bake target "${target}" before it can be provided through build-secrets`); + } + } + }; + const toBuildSecretEnvName = (id, index) => `BUILD_SECRET_${index}_${id.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`; const gitContextAttrs = GitHub.context.ref.startsWith('refs/tags/') ? {checksum: GitHub.context.sha} : {'fetch-by-commit': 'true'}; @@ -976,7 +1008,16 @@ jobs: core.setFailed(err.message); return; } - + + let secretIds; + try { + secretIds = JSON.parse(inpSecretIds || '{}'); + validateBuildSecrets(buildSecrets, secretIds); + } catch (err) { + core.setFailed(err.message); + return; + } + const envs = Object.assign({}, inpVars ? inpVars.reduce((acc, curr) => { const idx = curr.indexOf('='); diff --git a/README.md b/README.md index f42db616..97c683b7 100644 --- a/README.md +++ b/README.md @@ -418,7 +418,8 @@ passes these values through `docker/build-push-action` `secret-envs`, and the bake workflow appends matching `target.secrets+=id=...,env=...` overrides. For the bake workflow, an unqualified key applies to the workflow `target` input. A key written as `target.secret_id` applies only to that Bake target. This -target-scoped key form is only accepted by the bake workflow: +target-scoped key form is only accepted by the bake workflow. The target must +already declare a matching secret ID in the Bake definition: ```yaml secrets: @@ -429,7 +430,7 @@ secrets: Bake targets can declare local secret sources for direct `docker buildx bake` usage. When the reusable workflow receives a matching `build-secrets` entry, it -overrides that source with the workflow-provided secret value: +overrides that declared source with the workflow-provided secret value: ```hcl target "default" { From fdda12a33399dc2c7549b4061dd30de0f5344317 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:28:56 +0200 Subject: [PATCH 3/3] bake: use secret source overrides Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .github/workflows/bake.yml | 45 ++++++++++++-------------------------- README.md | 7 +++--- 2 files changed, 18 insertions(+), 34 deletions(-) diff --git a/.github/workflows/bake.yml b/.github/workflows/bake.yml index 4d2dc92d..ce84c09b 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -211,7 +211,7 @@ jobs: includes: ${{ steps.set.outputs.includes }} metaImages: ${{ steps.set.outputs.metaImages }} sign: ${{ steps.set.outputs.sign }} - secretIds: ${{ steps.set.outputs.secretIds }} + targets: ${{ steps.set.outputs.targets }} ghaCacheSign: ${{ steps.set.outputs.ghaCacheSign }} steps: - @@ -522,16 +522,6 @@ jobs: const match = value.match(/^target:(.+)$/); return match ? match[1] : undefined; }; - const parseSecretId = secret => { - if (typeof secret === 'string') { - const idAttr = secret.split(',').map(attr => attr.trim()).find(attr => attr.startsWith('id=')); - return idAttr ? idAttr.substring(3) : undefined; - } - if (secret && typeof secret === 'object' && typeof secret.id === 'string') { - return secret.id; - } - return undefined; - }; const resolveTarget = () => { if (targetDefs[inpTarget]) { return inpTarget; @@ -560,11 +550,7 @@ jobs: if (unsupportedTargets.length > 0) { throw new Error(`Only one target can be built at once, found unsupported targets: ${unsupportedTargets.join(', ')}`); } - const secretIds = {}; - for (const name of allowedTargets) { - secretIds[name] = (targetDefs[name]?.secret || []).map(parseSecretId).filter(Boolean); - } - core.setOutput('secretIds', JSON.stringify(secretIds)); + core.setOutput('targets', JSON.stringify([...allowedTargets])); }); } catch (error) { core.setFailed(error); @@ -851,7 +837,7 @@ jobs: INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }} INPUT_CACHE-MODE: ${{ inputs.cache-mode }} INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }} - INPUT_SECRET-IDS: ${{ needs.prepare.outputs.secretIds }} + INPUT_TARGETS: ${{ needs.prepare.outputs.targets }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_FILES: ${{ inputs.files }} INPUT_OUTPUT: ${{ inputs.output }} @@ -894,7 +880,7 @@ jobs: const inpCacheScope = core.getInput('cache-scope'); const inpCacheMode = core.getInput('cache-mode'); const inpBuildSecrets = core.getInput('build-secrets'); - const inpSecretIds = core.getInput('secret-ids'); + const inpTargets = core.getInput('targets'); const inpContext = core.getInput('context'); const inpFiles = Util.getInputList('files'); const inpOutput = core.getInput('output'); @@ -946,8 +932,8 @@ jobs: if (!target) { throw new Error(`Invalid build secret target for "${key}": target must not be empty`); } - if (!/^[A-Za-z0-9_.-]+$/.test(target)) { - throw new Error(`Invalid build secret target "${target}": use letters, digits, dots, underscores or dashes`); + if (!/^[A-Za-z0-9_-]+$/.test(target)) { + throw new Error(`Invalid build secret target "${target}": use letters, digits, underscores or dashes`); } if (!/^[A-Za-z0-9_-]+$/.test(id)) { throw new Error(`Invalid build secret id "${id}": use letters, digits, underscores or dashes`); @@ -969,15 +955,12 @@ jobs: return secrets; }; - const validateBuildSecrets = (secrets, secretIds) => { - for (const {target, id} of secrets) { - const targetSecretIds = secretIds[target]; - if (!targetSecretIds) { + const validateBuildSecretTargets = (secrets, targets) => { + const allowedTargets = new Set(targets); + for (const {target} of secrets) { + if (!allowedTargets.has(target)) { throw new Error(`Build secret target "${target}" is not part of the resolved Bake definition`); } - if (!Array.isArray(targetSecretIds) || !targetSecretIds.includes(id)) { - throw new Error(`Build secret "${id}" must be declared in Bake target "${target}" before it can be provided through build-secrets`); - } } }; @@ -1009,10 +992,10 @@ jobs: return; } - let secretIds; + let targets; try { - secretIds = JSON.parse(inpSecretIds || '{}'); - validateBuildSecrets(buildSecrets, secretIds); + targets = JSON.parse(inpTargets || '[]'); + validateBuildSecretTargets(buildSecrets, targets); } catch (err) { core.setFailed(err.message); return; @@ -1035,7 +1018,7 @@ jobs: buildSecrets.forEach(({target, id, secret}, index) => { const envName = toBuildSecretEnvName(id, index); envs[envName] = secret; - secretOverrides.push(`${target}.secrets+=id=${id},env=${envName}`); + secretOverrides.push(`${target}.secret.${id}=env=${envName}`); }); await core.group(`Set envs`, async () => { core.info(JSON.stringify(Object.keys(envs).sort(), null, 2)); diff --git a/README.md b/README.md index 97c683b7..d03c6bcb 100644 --- a/README.md +++ b/README.md @@ -415,11 +415,12 @@ or YAML syntax are preserved as a single YAML scalar. Each secret is exposed as an env-backed BuildKit secret. The build workflow passes these values through `docker/build-push-action` `secret-envs`, and the -bake workflow appends matching `target.secrets+=id=...,env=...` overrides. For +bake workflow sets matching `target.secret.=env=...` source overrides. For the bake workflow, an unqualified key applies to the workflow `target` input. A key written as `target.secret_id` applies only to that Bake target. This -target-scoped key form is only accepted by the bake workflow. The target must -already declare a matching secret ID in the Bake definition: +target-scoped key form is only accepted by the bake workflow. The target must be +part of the resolved Bake build, and Buildx requires the target to already +declare a matching secret ID in the Bake definition: ```yaml secrets: