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