From 94c26e82c1a46eed06d82ae658aa8eb90547b98c Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:15:39 +0200 Subject: [PATCH] build/bake: secrets support Accept a shared YAML build-secrets mapping and pass values through private temporary files. Scope Git credentials to the build action and remove secret files after the build, including on failure. Override only declared Bake secrets in the resolved target graph using Buildx secret source overrides. Cover multiline values and replacement of environment and file sources in the workflow fixtures. Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .github/workflows/.test-bake.yml | 21 +++ .github/workflows/.test-build.yml | 19 +++ .github/workflows/bake.yml | 230 ++++++++++++++++++++++-------- .github/workflows/build.yml | 182 ++++++++++++++++------- README.md | 66 ++++++++- test/docker-bake.hcl | 10 ++ test/secret.Dockerfile | 16 +++ 7 files changed, 428 insertions(+), 116 deletions(-) create mode 100644 test/secret.Dockerfile diff --git a/.github/workflows/.test-bake.yml b/.github/workflows/.test-bake.yml index dfd5fcd0..444420ec 100644 --- a/.github/workflows/.test-bake.yml +++ b/.github/workflows/.test-bake.yml @@ -477,6 +477,27 @@ 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: foosec + secrets: + build-secrets: |+ + fixture.plain: | + alpha-line + beta-line + foosec: + fixture.json: ${{ toJSON(format('gamma-line{0}delta-line{0}', fromJSON('"\n"'))) }} + fixture_fuu: standard-secret + fixture_keep: |+ + keep-line + bake-set-runner: uses: ./.github/workflows/bake.yml permissions: diff --git a/.github/workflows/.test-build.yml b/.github/workflows/.test-build.yml index 804204b2..e50863cc 100644 --- a/.github/workflows/.test-build.yml +++ b/.github/workflows/.test-build.yml @@ -523,6 +523,25 @@ 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"'))) }} + fixture_fuu: standard-secret + fixture_keep: |+ + keep-line + build-set-runner: uses: ./.github/workflows/build.yml permissions: diff --git a/.github/workflows/bake.yml b/.github/workflows/bake.yml index 9fff47c9..0ffd9834 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -149,6 +149,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 values, with optional nested target mappings" + required: false github-token: description: "GitHub Token used to authenticate against the repository for Git context" required: false @@ -215,6 +218,7 @@ jobs: metaImages: ${{ steps.set.outputs.metaImages }} sign: ${{ steps.set.outputs.sign }} privateRepo: ${{ steps.set.outputs.privateRepo }} + targets: ${{ steps.set.outputs.targets }} ghaCacheSign: ${{ steps.set.outputs.ghaCacheSign }} proxyNetwork: ${{ steps.set.outputs.proxyNetwork }} steps: @@ -490,7 +494,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()); @@ -504,9 +508,15 @@ jobs: try { await core.group(`Validating definition`, async () => { const bake = new Bake(); + // Resolve the graph without local secret files. The build job validates + // secrets after applying the workflow-provided source overrides. + const validationOverrides = inpSet.filter(override => { + const key = override.split('=', 1)[0].split('.')[1]; + return !['secret', 'secrets', 'secrets+'].includes(key); + }); def = await bake.getDefinition({ files: inpFiles, - overrides: inpSet, + overrides: [...validationOverrides, '*.secrets='], sbom: inpSbom ? `generator=${inpSbomImage}` : 'false', source: bakeSource, targets: [inpTarget] @@ -556,6 +566,7 @@ jobs: if (unsupportedTargets.length > 0) { throw new Error(`Only one target can be built at once, found unsupported targets: ${unsupportedTargets.join(', ')}`); } + core.setOutput('targets', JSON.stringify([...allowedTargets])); }); } catch (error) { core.setFailed(error); @@ -838,6 +849,57 @@ jobs: cosignPath, `${containerName}:/usr/bin/cosign` ]); + - + name: Configure AWS credentials + if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ needs.registry-identities.outputs.aws-ecr-role-to-assume }} + aws-region: ${{ needs.registry-identities.outputs.aws-ecr-region }} + - + name: Login to Amazon ECR + if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry-auth: | + - registry: ${{ needs.registry-identities.outputs.aws-ecr-registry }} + - + name: Authenticate to Google Cloud + id: gcp-wif-auth + if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + token_format: access_token + workload_identity_provider: ${{ needs.registry-identities.outputs.gcp-wif-workload-identity-provider }} + service_account: ${{ needs.registry-identities.outputs.gcp-wif-service-account }} + project_id: ${{ needs.registry-identities.outputs.gcp-wif-project-id }} + create_credentials_file: false + export_environment_variables: false + - + name: Login to Google Artifact Registry + if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry-auth: | + - registry: ${{ needs.registry-identities.outputs.gcp-wif-registry }} + username: oauth2accesstoken + password: ${{ steps.gcp-wif-auth.outputs.access_token }} + - + name: Login to Docker Hub with OIDC + if: ${{ needs.registry-identities.outputs.dockerhub-oidc-enabled == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + env: + DOCKERHUB_OIDC_CONNECTIONID: ${{ needs.registry-identities.outputs.dockerhub-oidc-connection-id }} + with: + registry-auth: | + - registry: ${{ needs.registry-identities.outputs.dockerhub-oidc-registry }} + username: ${{ needs.registry-identities.outputs.dockerhub-oidc-username }} + - + name: Login to registry + if: ${{ inputs.push && inputs.output == 'image' && env.REGISTRY_AUTHS_PRESENT == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry-auth: ${{ secrets.registry-auths }} - name: Prepare id: prepare @@ -849,6 +911,8 @@ jobs: INPUT_CACHE: ${{ inputs.cache }} INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }} INPUT_CACHE-MODE: ${{ inputs.cache-mode }} + INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }} + INPUT_TARGETS: ${{ needs.prepare.outputs.targets }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_FILES: ${{ inputs.files }} INPUT_OUTPUT: ${{ inputs.output }} @@ -865,15 +929,23 @@ jobs: INPUT_BAKE-FILE-TAGS: ${{ steps.meta.outputs.bake-file-tags }} INPUT_BAKE-FILE-ANNOTATIONS: ${{ steps.meta.outputs.bake-file-annotations }} INPUT_BAKE-FILE-LABELS: ${{ steps.meta.outputs.bake-file-labels }} - INPUT_GITHUB-TOKEN: ${{ secrets.github-token || github.token }} INPUT_BUILDKIT-PROXY-NETWORK: ${{ inputs.buildkit-proxy-network }} with: script: | + const fs = require('fs'); const os = require('os'); + const path = require('path'); 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); @@ -884,6 +956,8 @@ jobs: const inpCache = core.getBooleanInput('cache'); const inpCacheScope = core.getInput('cache-scope'); const inpCacheMode = core.getInput('cache-mode'); + const inpBuildSecrets = core.getInput('build-secrets', {trimWhitespace: false}); + const inpTargets = core.getInput('targets'); const inpContext = core.getInput('context'); const inpFiles = Util.getInputList('files'); const inpOutput = core.getInput('output'); @@ -892,7 +966,6 @@ jobs: const inpSet = Util.getInputList('set', {ignoreComma: true, quote: false}); const inpTarget = core.getInput('target'); const inpVars = Util.getInputList('vars'); - const inpGitHubToken = core.getInput('github-token'); const inpBuildkitProxyNetwork = core.getBooleanInput('buildkit-proxy-network'); const inpMetaImages = core.getMultilineInput('meta-images'); @@ -909,6 +982,46 @@ jobs: tags: inpMetaTags }; const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta}); + + const isInputKeySafe = value => value && !/[\r\n=]/.test(value); + const parseBuildSecrets = value => { + if (!value.trim()) { + return []; + } + let parsed; + try { + parsed = yaml.load(value, {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'); + } + return Object.entries(parsed).flatMap(([key, value]) => { + if (typeof value !== 'string' && (!value || typeof value !== 'object' || Array.isArray(value))) { + throw new Error('build-secrets entries must be secret strings or target mappings'); + } + const target = typeof value === 'string' ? inpTarget : key; + const entries = typeof value === 'string' ? [[key, value]] : Object.entries(value); + if (!isInputKeySafe(target)) { + throw new Error('Build secret targets must not be empty or contain line breaks or "="'); + } + return entries.map(([id, secret]) => { + if (!isInputKeySafe(id)) { + throw new Error('Build secret IDs must not be empty or contain line breaks or "="'); + } + if (typeof secret !== 'string') { + throw new Error('build-secrets values within target mappings must be strings'); + } + core.setSecret(secret); + return {target, id, secret}; + }); + }); + }; 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}); @@ -927,7 +1040,29 @@ jobs: core.info(sbom); core.setOutput('sbom', sbom); }); - + + let buildSecrets; + try { + buildSecrets = parseBuildSecrets(inpBuildSecrets); + } catch (err) { + core.setFailed(err.message); + return; + } + + let targets; + try { + targets = JSON.parse(inpTargets || '[]'); + const allowedTargets = new Set(targets); + for (const {target} of buildSecrets) { + if (!allowedTargets.has(target)) { + throw new Error(`Build secret target "${target}" is not part of the resolved Bake definition`); + } + } + } catch (err) { + core.setFailed(err.message); + return; + } + const envs = Object.assign({}, inpVars ? inpVars.reduce((acc, curr) => { const idx = curr.indexOf('='); @@ -937,12 +1072,26 @@ jobs: return acc; }, {}) : {}, { - BUILDKIT_MULTI_PLATFORM: '1', - BUILDX_BAKE_GIT_AUTH_TOKEN: inpGitHubToken + BUILDKIT_MULTI_PLATFORM: '1' } ); + + // Git authentication is supplied directly to the Bake action. + delete envs.BUILDX_BAKE_GIT_AUTH_TOKEN; + + const secretOverrides = []; + if (buildSecrets.length > 0) { + const secretDir = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'build-secrets-')); + core.setOutput('secret-dir', secretDir); + buildSecrets.forEach(({target, id, secret}, index) => { + const secretFile = path.join(secretDir, String(index)); + fs.writeFileSync(secretFile, secret, {mode: 0o600}); + secretOverrides.push(`${target}.secret.${id}=src=${secretFile}`); + }); + } + await core.group(`Set envs`, async () => { - core.info(JSON.stringify(envs, null, 2)); + core.info(JSON.stringify(Object.keys(envs).sort(), null, 2)); core.setOutput('envs', JSON.stringify(envs)); }); @@ -1006,60 +1155,10 @@ jobs: bakeOverrides.push(`*.cache-from=type=gha,scope=${inpCacheScope || inpTarget}${platformPairSuffix}${proxyNetworkSuffix}`); bakeOverrides.push(`*.cache-to=type=gha,ignore-error=true,scope=${inpCacheScope || inpTarget}${platformPairSuffix}${proxyNetworkSuffix},mode=${inpCacheMode}`); } + bakeOverrides.push(...secretOverrides); core.info(JSON.stringify(bakeOverrides, null, 2)); core.setOutput('overrides', bakeOverrides.join(os.EOL)); }); - - - name: Configure AWS credentials - if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 - with: - role-to-assume: ${{ needs.registry-identities.outputs.aws-ecr-role-to-assume }} - aws-region: ${{ needs.registry-identities.outputs.aws-ecr-region }} - - - name: Login to Amazon ECR - if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry-auth: | - - registry: ${{ needs.registry-identities.outputs.aws-ecr-registry }} - - - name: Authenticate to Google Cloud - id: gcp-wif-auth - if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 - with: - token_format: access_token - workload_identity_provider: ${{ needs.registry-identities.outputs.gcp-wif-workload-identity-provider }} - service_account: ${{ needs.registry-identities.outputs.gcp-wif-service-account }} - project_id: ${{ needs.registry-identities.outputs.gcp-wif-project-id }} - create_credentials_file: false - export_environment_variables: false - - - name: Login to Google Artifact Registry - if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry-auth: | - - registry: ${{ needs.registry-identities.outputs.gcp-wif-registry }} - username: oauth2accesstoken - password: ${{ steps.gcp-wif-auth.outputs.access_token }} - - - name: Login to Docker Hub with OIDC - if: ${{ needs.registry-identities.outputs.dockerhub-oidc-enabled == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - env: - DOCKERHUB_OIDC_CONNECTIONID: ${{ needs.registry-identities.outputs.dockerhub-oidc-connection-id }} - with: - registry-auth: | - - registry: ${{ needs.registry-identities.outputs.dockerhub-oidc-registry }} - username: ${{ needs.registry-identities.outputs.dockerhub-oidc-username }} - - - name: Login to registry - if: ${{ inputs.push && inputs.output == 'image' && env.REGISTRY_AUTHS_PRESENT == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry-auth: ${{ secrets.registry-auths }} - name: Build id: bake @@ -1070,7 +1169,18 @@ jobs: targets: ${{ steps.prepare.outputs.target }} sbom: ${{ steps.prepare.outputs.sbom }} set: ${{ steps.prepare.outputs.overrides }} + github-token: ${{ secrets.github-token || github.token }} env: ${{ fromJson(steps.prepare.outputs.envs || '{}') }} + - + name: Remove build secrets + if: ${{ always() && steps.prepare.outputs.secret-dir != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_SECRET-DIR: ${{ steps.prepare.outputs.secret-dir }} + with: + script: | + const fs = require('fs'); + fs.rmSync(core.getInput('secret-dir', {required: true}), {recursive: true, force: true}); - name: Get image digest id: get-image-digest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89674cc9..e7d75fac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -160,6 +160,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 @@ -736,6 +739,57 @@ jobs: cosignPath, `${containerName}:/usr/bin/cosign` ]); + - + name: Configure AWS credentials + if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ needs.registry-identities.outputs.aws-ecr-role-to-assume }} + aws-region: ${{ needs.registry-identities.outputs.aws-ecr-region }} + - + name: Login to Amazon ECR + if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry-auth: | + - registry: ${{ needs.registry-identities.outputs.aws-ecr-registry }} + - + name: Authenticate to Google Cloud + id: gcp-wif-auth + if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + token_format: access_token + workload_identity_provider: ${{ needs.registry-identities.outputs.gcp-wif-workload-identity-provider }} + service_account: ${{ needs.registry-identities.outputs.gcp-wif-service-account }} + project_id: ${{ needs.registry-identities.outputs.gcp-wif-project-id }} + create_credentials_file: false + export_environment_variables: false + - + name: Login to Google Artifact Registry + if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry-auth: | + - registry: ${{ needs.registry-identities.outputs.gcp-wif-registry }} + username: oauth2accesstoken + password: ${{ steps.gcp-wif-auth.outputs.access_token }} + - + name: Login to Docker Hub with OIDC + if: ${{ needs.registry-identities.outputs.dockerhub-oidc-enabled == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + env: + DOCKERHUB_OIDC_CONNECTIONID: ${{ needs.registry-identities.outputs.dockerhub-oidc-connection-id }} + with: + registry-auth: | + - registry: ${{ needs.registry-identities.outputs.dockerhub-oidc-registry }} + username: ${{ needs.registry-identities.outputs.dockerhub-oidc-username }} + - + name: Login to registry + if: ${{ inputs.push && inputs.output == 'image' && env.REGISTRY_AUTHS_PRESENT == 'true' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry-auth: ${{ secrets.registry-auths }} - name: Prepare id: prepare @@ -751,6 +805,7 @@ jobs: INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }} INPUT_CACHE-MODE: ${{ inputs.cache-mode }} INPUT_BUILDKIT-PROXY-NETWORK: ${{ inputs.buildkit-proxy-network }} + INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }} INPUT_LABELS: ${{ inputs.labels }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_OUTPUT: ${{ inputs.output }} @@ -767,10 +822,19 @@ jobs: INPUT_META-LABELS: ${{ steps.meta.outputs.labels }} with: script: | + const fs = require('fs'); + const path = require('path'); 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); @@ -784,6 +848,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', {trimWhitespace: false}); const inpContext = core.getInput('context'); const inpLabels = core.getInput('labels'); const inpOutput = core.getInput('output'); @@ -807,6 +872,40 @@ jobs: }; const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta}); + // IDs pass through the action's CSV list parser and Buildx's secret attributes. + const isInputKeySafe = value => value && value === value.trim() && !/[\r\n=,"]/.test(value); + const parseBuildSecrets = value => { + if (!value.trim()) { + return {}; + } + let parsed; + try { + parsed = yaml.load(value, {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'); + } + for (const [id, secret] of Object.entries(parsed)) { + if (!isInputKeySafe(id)) { + throw new Error('Build secret IDs must not be empty or contain surrounding whitespace, line breaks, commas, double quotes or "="'); + } + 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`); + } + core.setSecret(secret); + } + return parsed; + }; + 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}); core.setOutput('context', buildContext); @@ -864,6 +963,25 @@ 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 secretFiles = []; + if (Object.keys(buildSecrets).length > 0) { + const secretDir = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'build-secrets-')); + core.setOutput('secret-dir', secretDir); + Object.entries(buildSecrets).forEach(([id, secret], index) => { + const secretFile = path.join(secretDir, String(index)); + fs.writeFileSync(secretFile, secret, {mode: 0o600}); + secretFiles.push(`${id}=${secretFile}`); + }); + } + core.setOutput('secret-files', secretFiles.join('\n')); if (GitHub.context.payload.repository?.private ?? false) { // if this is a private repository, we set min provenance mode @@ -872,57 +990,6 @@ jobs: // for a public repository, we set max provenance mode core.setOutput('provenance', Build.resolveProvenanceAttrs(`mode=max,version=v1`)); } - - - name: Configure AWS credentials - if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 - with: - role-to-assume: ${{ needs.registry-identities.outputs.aws-ecr-role-to-assume }} - aws-region: ${{ needs.registry-identities.outputs.aws-ecr-region }} - - - name: Login to Amazon ECR - if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry-auth: | - - registry: ${{ needs.registry-identities.outputs.aws-ecr-registry }} - - - name: Authenticate to Google Cloud - id: gcp-wif-auth - if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 - with: - token_format: access_token - workload_identity_provider: ${{ needs.registry-identities.outputs.gcp-wif-workload-identity-provider }} - service_account: ${{ needs.registry-identities.outputs.gcp-wif-service-account }} - project_id: ${{ needs.registry-identities.outputs.gcp-wif-project-id }} - create_credentials_file: false - export_environment_variables: false - - - name: Login to Google Artifact Registry - if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry-auth: | - - registry: ${{ needs.registry-identities.outputs.gcp-wif-registry }} - username: oauth2accesstoken - password: ${{ steps.gcp-wif-auth.outputs.access_token }} - - - name: Login to Docker Hub with OIDC - if: ${{ needs.registry-identities.outputs.dockerhub-oidc-enabled == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - env: - DOCKERHUB_OIDC_CONNECTIONID: ${{ needs.registry-identities.outputs.dockerhub-oidc-connection-id }} - with: - registry-auth: | - - registry: ${{ needs.registry-identities.outputs.dockerhub-oidc-registry }} - username: ${{ needs.registry-identities.outputs.dockerhub-oidc-username }} - - - name: Login to registry - if: ${{ inputs.push && inputs.output == 'image' && env.REGISTRY_AUTHS_PRESENT == 'true' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry-auth: ${{ secrets.registry-auths }} - name: Build id: build @@ -940,12 +1007,23 @@ jobs: provenance: ${{ steps.prepare.outputs.provenance }} sbom: ${{ steps.prepare.outputs.sbom }} secret-envs: GIT_AUTH_TOKEN=GIT_AUTH_TOKEN + secret-files: ${{ steps.prepare.outputs.secret-files }} 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: Remove build secrets + if: ${{ always() && steps.prepare.outputs.secret-dir != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_SECRET-DIR: ${{ steps.prepare.outputs.secret-dir }} + with: + script: | + const fs = require('fs'); + fs.rmSync(core.getInput('secret-dir', {required: true}), {recursive: true, force: true}); - 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 8ed58594..77257c5a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ ___ * [Outputs](#outputs-1) * [Notes](#notes) * [BuildKit proxy network](#buildkit-proxy-network) + * [Build secrets](#build-secrets) * [Signed GitHub Actions cache](#signed-github-actions-cache) * [Registry identities](#registry-identities) * [Docker Hub OIDC](#docker-hub-oidc) @@ -260,6 +261,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,10 +372,11 @@ jobs: ### Secrets -| Name | Default | Description | -|------------------|-----------------------|--------------------------------------------------------------------------------| -| `registry-auths` | | Raw authentication to registries, defined as YAML objects (for `image` output) | -| `github-token` | `${{ github.token }}` | GitHub Token used to authenticate against the repository for Git context | +| 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 values, with optional nested target mappings | +| `github-token` | `${{ github.token }}` | GitHub Token used to authenticate against the repository for Git context | ### Outputs @@ -423,6 +426,61 @@ proxy. Enabling `buildkit-proxy-network` replaces Docker's predefined proxy build arguments for affected `RUN` operations and can bypass an application-level organizational proxy. See BuildKit's [proxy network documentation](https://github.com/moby/buildkit/blob/master/docs/proxy.md). +### Build secrets + +Both workflows accept `build-secrets` as a YAML object mapping BuildKit secret +IDs to values, not caller workspace paths. Bake requires matching secrets to be +declared in `docker-bake.hcl`: + +```hcl +target "default" { + secret = [ + "id=npm.token,env=NPM_TOKEN", + "id=aws.credentials,src=./aws-credentials", + "id=inline_config,env=INLINE_CONFIG", + ] +} +``` + +Then pass their values to the reusable workflow: + +```yaml +secrets: + build-secrets: | + npm.token: ${{ toJSON(secrets.NPM_TOKEN) }} + aws.credentials: ${{ toJSON(secrets.AWS_CREDENTIALS) }} + inline_config: | + first line + second line +``` + +Use `toJSON(...)` for GitHub secrets to preserve multiline values and YAML-sensitive +characters. For literal values with trailing blank lines, use `|+` on both the +outer `build-secrets` block and the inner value. + +In Bake, string values apply to the selected `target`. Nested mappings explicitly +select a target in the resolved build graph. For the same definition above: + +```yaml +with: + target: default +secrets: + build-secrets: | + npm.token: ${{ toJSON(secrets.NPM_TOKEN) }} + default: + aws.credentials: ${{ toJSON(secrets.AWS_CREDENTIALS) }} +``` + +Dots are always part of the secret ID, never target separators. Nested target +mappings are only supported by the bake workflow. IDs cannot be empty or contain +line breaks or `=`; the build workflow also rejects commas, double quotes, and +leading or trailing whitespace. + +Values are passed through private temporary files, not the job environment. +For Bake, these override the declared file or environment sources without adding +new secrets. Files are created after registry authentication and cleaned up after +the build, including on failure. Abrupt runner termination can prevent cleanup. + ### 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 a782ca74..a69517f6 100644 --- a/test/docker-bake.hcl +++ b/test/docker-bake.hcl @@ -42,6 +42,16 @@ target "proxy-network" { dockerfile = "proxy-network.Dockerfile" } +target "foosec" { + dockerfile = "secret.Dockerfile" + secret = [ + "id=fixture.plain,env=FIXTURE_PLAIN", + "id=fixture.json,src=./fixture-json.txt", + "id=fixture_fuu,env=FIXTURE_FUU", + "id=fixture_keep,env=FIXTURE_KEEP", + ] +} + 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..0b8b8bd0 --- /dev/null +++ b/test/secret.Dockerfile @@ -0,0 +1,16 @@ +# 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 \ + --mount=type=secret,id=fixture_fuu,env=fixture_fuu \ + --mount=type=secret,id=fixture_keep,env=fixture_keep \ + 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 && \ + test "$fixture_fuu" = 'standard-secret' && \ + printf 'keep-line\n\n' > /tmp/expected-keep && \ + printf '%s' "$fixture_keep" | cmp - /tmp/expected-keep