diff --git a/.github/workflows/.test-bake.yml b/.github/workflows/.test-bake.yml index a310b231..fb346eb6 100644 --- a/.github/workflows/.test-bake.yml +++ b/.github/workflows/.test-bake.yml @@ -406,6 +406,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 + 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 c25f3598..d838b7d6 100644 --- a/.github/workflows/.test-build.yml +++ b/.github/workflows/.test-build.yml @@ -455,6 +455,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 78f14132..921f06cc 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -134,6 +134,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 @@ -465,7 +468,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()); @@ -813,6 +816,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 }} @@ -836,7 +840,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); @@ -847,6 +858,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'); @@ -870,6 +882,41 @@ 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 = {}; + 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, dots, underscores or dashes`); + } + 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 bakeSource = await new Build().gitContext({subdir: inpContext, attrs: gitContextAttrs}); @@ -888,6 +935,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) => { @@ -902,9 +957,17 @@ jobs: BUILDX_BAKE_GIT_AUTH_TOKEN: inpGitHubToken } ); + const secretOverrides = []; + Object.entries(buildSecrets).forEach(([id, secret], index) => { + const envName = toBuildSecretEnvName(id, index); + envs[envName] = secret; + secretOverrides.push(`*.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; @@ -966,6 +1029,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)); }); @@ -985,7 +1049,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 6332cb8b..6ae5df2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -137,6 +137,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 @@ -707,6 +710,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 }} @@ -721,12 +725,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); @@ -740,6 +752,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'); @@ -755,6 +768,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, @@ -763,6 +777,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, dots, 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}); @@ -819,6 +871,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 @@ -849,13 +923,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' }} diff --git a/README.md b/README.md index 5bc27729..d3afc2f3 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) * [Runner mapping](#runner-mapping) * [Metadata templates](#metadata-templates) @@ -250,6 +251,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 @@ -360,6 +362,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 @@ -380,6 +383,48 @@ 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 where each key is the BuildKit secret ID and each value is the +secret payload: + +```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 `*.secrets+=id=...,env=...` overrides. + +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