Skip to content

Commit 8a0cb82

Browse files
committed
build/bake: BuildKit secrets support
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
1 parent a90cd97 commit 8a0cb82

7 files changed

Lines changed: 211 additions & 8 deletions

File tree

.github/workflows/.test-bake.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,23 @@ jobs:
405405
const builderOutputs = JSON.parse(core.getInput('builder-outputs'));
406406
core.info(JSON.stringify(builderOutputs, null, 2));
407407
408+
bake-secret:
409+
uses: ./.github/workflows/bake.yml
410+
permissions:
411+
contents: read
412+
id-token: write
413+
with:
414+
artifact-upload: false
415+
context: test
416+
output: local
417+
target: secret
418+
secrets:
419+
build-secrets: |
420+
github_builder_secret: |
421+
github-builder
422+
secret
423+
github_builder_json_secret: ${{ toJSON(format('github-builder{0}json-secret{0}', fromJSON('"\n"'))) }}
424+
408425
bake-set-runner:
409426
uses: ./.github/workflows/bake.yml
410427
permissions:

.github/workflows/.test-build.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,22 @@ jobs:
454454
const builderOutputs = JSON.parse(core.getInput('builder-outputs'));
455455
core.info(JSON.stringify(builderOutputs, null, 2));
456456
457+
build-secret:
458+
uses: ./.github/workflows/build.yml
459+
permissions:
460+
contents: read
461+
id-token: write
462+
with:
463+
artifact-upload: false
464+
file: test/secret.Dockerfile
465+
output: local
466+
secrets:
467+
build-secrets: |
468+
github_builder_secret: |
469+
github-builder
470+
secret
471+
github_builder_json_secret: ${{ toJSON(format('github-builder{0}json-secret{0}', fromJSON('"\n"'))) }}
472+
457473
build-set-runner:
458474
uses: ./.github/workflows/build.yml
459475
permissions:

.github/workflows/bake.yml

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ on:
130130
registry-auths:
131131
description: "Raw authentication to registries, defined as YAML objects (for image output)"
132132
required: false
133+
build-secrets:
134+
description: "YAML object mapping BuildKit secret IDs to secret values"
135+
required: false
133136
github-token:
134137
description: "GitHub Token used to authenticate against the repository for Git context"
135138
required: false
@@ -455,7 +458,7 @@ jobs:
455458
}
456459
);
457460
await core.group(`Set envs`, async () => {
458-
core.info(JSON.stringify(envs, null, 2));
461+
core.info(JSON.stringify(Object.keys(envs).sort(), null, 2));
459462
});
460463
461464
const metaImages = inpMetaImages.map(image => image.toLowerCase());
@@ -797,6 +800,7 @@ jobs:
797800
INPUT_CACHE: ${{ inputs.cache }}
798801
INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }}
799802
INPUT_CACHE-MODE: ${{ inputs.cache-mode }}
803+
INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }}
800804
INPUT_CONTEXT: ${{ inputs.context }}
801805
INPUT_FILES: ${{ inputs.files }}
802806
INPUT_OUTPUT: ${{ inputs.output }}
@@ -820,7 +824,14 @@ jobs:
820824
const { Build } = require('@docker/github-builder-runtime/lib/buildx/build');
821825
const { GitHub } = require('@docker/github-builder-runtime/lib/github/github');
822826
const { Util } = require('@docker/github-builder-runtime/lib/util');
823-
827+
828+
let yaml;
829+
try {
830+
yaml = require('js-yaml');
831+
} catch {
832+
yaml = require('@docker/github-builder-runtime/node_modules/js-yaml');
833+
}
834+
824835
const inpPlatform = core.getInput('platform');
825836
const platformPairSuffix = inpPlatform ? `-${inpPlatform.replace(/\//g, '-')}` : '';
826837
core.setOutput('platform-pair-suffix', platformPairSuffix);
@@ -831,6 +842,7 @@ jobs:
831842
const inpCache = core.getBooleanInput('cache');
832843
const inpCacheScope = core.getInput('cache-scope');
833844
const inpCacheMode = core.getInput('cache-mode');
845+
const inpBuildSecrets = core.getInput('build-secrets');
834846
const inpContext = core.getInput('context');
835847
const inpFiles = Util.getInputList('files');
836848
const inpOutput = core.getInput('output');
@@ -854,6 +866,40 @@ jobs:
854866
tags: inpMetaTags
855867
};
856868
const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta});
869+
const parseBuildSecrets = value => {
870+
const normalized = value.trim();
871+
if (!normalized) {
872+
return {};
873+
}
874+
let parsed;
875+
try {
876+
parsed = yaml.load(normalized, {schema: yaml.FAILSAFE_SCHEMA});
877+
} catch (err) {
878+
throw new Error(`Failed to parse build-secrets YAML: ${err.message}`);
879+
}
880+
if (!parsed) {
881+
return {};
882+
}
883+
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
884+
throw new Error('build-secrets must be a YAML object');
885+
}
886+
const secrets = {};
887+
for (const [id, secret] of Object.entries(parsed)) {
888+
if (!/^[A-Za-z0-9_.-]+$/.test(id)) {
889+
throw new Error(`Invalid build secret id "${id}": use letters, digits, dots, underscores or dashes`);
890+
}
891+
if (typeof secret !== 'string') {
892+
throw new Error(`build-secrets value for "${id}" must be a string`);
893+
}
894+
if (secret.length === 0) {
895+
throw new Error(`build-secrets value for "${id}" must not be empty`);
896+
}
897+
core.setSecret(secret);
898+
secrets[id] = secret;
899+
}
900+
return secrets;
901+
};
902+
const toBuildSecretEnvName = (id, index) => `BUILD_SECRET_${index}_${id.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`;
857903
858904
const gitContextAttrs = GitHub.context.ref.startsWith('refs/tags/') ? {checksum: GitHub.context.sha} : {'fetch-by-commit': 'true'};
859905
const bakeSource = await new Build().gitContext({subdir: inpContext, attrs: gitContextAttrs});
@@ -872,6 +918,14 @@ jobs:
872918
core.info(sbom);
873919
core.setOutput('sbom', sbom);
874920
});
921+
922+
let buildSecrets;
923+
try {
924+
buildSecrets = parseBuildSecrets(inpBuildSecrets);
925+
} catch (err) {
926+
core.setFailed(err.message);
927+
return;
928+
}
875929
876930
const envs = Object.assign({},
877931
inpVars ? inpVars.reduce((acc, curr) => {
@@ -886,8 +940,14 @@ jobs:
886940
BUILDX_BAKE_GIT_AUTH_TOKEN: inpGitHubToken
887941
}
888942
);
943+
const secretOverrides = [];
944+
Object.entries(buildSecrets).forEach(([id, secret], index) => {
945+
const envName = toBuildSecretEnvName(id, index);
946+
envs[envName] = secret;
947+
secretOverrides.push(`*.secrets+=id=${id},env=${envName}`);
948+
});
889949
await core.group(`Set envs`, async () => {
890-
core.info(JSON.stringify(envs, null, 2));
950+
core.info(JSON.stringify(Object.keys(envs).sort(), null, 2));
891951
core.setOutput('envs', JSON.stringify(envs));
892952
});
893953
@@ -950,6 +1010,7 @@ jobs:
9501010
bakeOverrides.push(`*.cache-from=type=gha,scope=${inpCacheScope || inpTarget}${platformPairSuffix}`);
9511011
bakeOverrides.push(`*.cache-to=type=gha,ignore-error=true,scope=${inpCacheScope || inpTarget}${platformPairSuffix},mode=${inpCacheMode}`);
9521012
}
1013+
bakeOverrides.push(...secretOverrides);
9531014
core.info(JSON.stringify(bakeOverrides, null, 2));
9541015
core.setOutput('overrides', bakeOverrides.join(os.EOL));
9551016
});

.github/workflows/build.yml

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ on:
133133
registry-auths:
134134
description: "Raw authentication to registries, defined as YAML objects (for image output)"
135135
required: false
136+
build-secrets:
137+
description: "YAML object mapping BuildKit secret IDs to secret values"
138+
required: false
136139
github-token:
137140
description: "GitHub Token used to authenticate against the repository for Git context"
138141
required: false
@@ -691,6 +694,7 @@ jobs:
691694
INPUT_CACHE: ${{ inputs.cache }}
692695
INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }}
693696
INPUT_CACHE-MODE: ${{ inputs.cache-mode }}
697+
INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }}
694698
INPUT_LABELS: ${{ inputs.labels }}
695699
INPUT_CONTEXT: ${{ inputs.context }}
696700
INPUT_OUTPUT: ${{ inputs.output }}
@@ -705,12 +709,20 @@ jobs:
705709
INPUT_META-ANNOTATIONS: ${{ steps.meta.outputs.annotations }}
706710
INPUT_SET-META-LABELS: ${{ inputs.set-meta-labels }}
707711
INPUT_META-LABELS: ${{ steps.meta.outputs.labels }}
712+
INPUT_GITHUB-TOKEN: ${{ secrets.github-token || github.token }}
708713
with:
709714
script: |
710715
const { Build } = require('@docker/github-builder-runtime/lib/buildx/build');
711716
const { GitHub } = require('@docker/github-builder-runtime/lib/github/github');
712717
const { Util } = require('@docker/github-builder-runtime/lib/util');
713-
718+
719+
let yaml;
720+
try {
721+
yaml = require('js-yaml');
722+
} catch {
723+
yaml = require('@docker/github-builder-runtime/node_modules/js-yaml');
724+
}
725+
714726
const inpPlatform = core.getInput('platform');
715727
const platformPairSuffix = inpPlatform ? `-${inpPlatform.replace(/\//g, '-')}` : '';
716728
core.setOutput('platform-pair-suffix', platformPairSuffix);
@@ -724,6 +736,7 @@ jobs:
724736
const inpCache = core.getBooleanInput('cache');
725737
const inpCacheScope = core.getInput('cache-scope');
726738
const inpCacheMode = core.getInput('cache-mode');
739+
const inpBuildSecrets = core.getInput('build-secrets');
727740
const inpContext = core.getInput('context');
728741
const inpLabels = core.getInput('labels');
729742
const inpOutput = core.getInput('output');
@@ -739,6 +752,7 @@ jobs:
739752
const inpMetaAnnotations = core.getMultilineInput('meta-annotations');
740753
const inpSetMetaLabels = core.getBooleanInput('set-meta-labels');
741754
const inpMetaLabels = core.getMultilineInput('meta-labels');
755+
const inpGitHubToken = core.getInput('github-token');
742756
743757
const meta = {
744758
version: inpMetaVersion,
@@ -747,6 +761,40 @@ jobs:
747761
748762
const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta});
749763
const toMultilineInput = value => value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
764+
const parseBuildSecrets = value => {
765+
const normalized = value.trim();
766+
if (!normalized) {
767+
return {};
768+
}
769+
let parsed;
770+
try {
771+
parsed = yaml.load(normalized, {schema: yaml.FAILSAFE_SCHEMA});
772+
} catch (err) {
773+
throw new Error(`Failed to parse build-secrets YAML: ${err.message}`);
774+
}
775+
if (!parsed) {
776+
return {};
777+
}
778+
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
779+
throw new Error('build-secrets must be a YAML object');
780+
}
781+
const secrets = {};
782+
for (const [id, secret] of Object.entries(parsed)) {
783+
if (!/^[A-Za-z0-9_.-]+$/.test(id)) {
784+
throw new Error(`Invalid build secret id "${id}": use letters, digits, dots, underscores or dashes`);
785+
}
786+
if (typeof secret !== 'string') {
787+
throw new Error(`build-secrets value for "${id}" must be a string`);
788+
}
789+
if (secret.length === 0) {
790+
throw new Error(`build-secrets value for "${id}" must not be empty`);
791+
}
792+
core.setSecret(secret);
793+
secrets[id] = secret;
794+
}
795+
return secrets;
796+
};
797+
const toBuildSecretEnvName = (id, index) => `BUILD_SECRET_${index}_${id.toUpperCase().replace(/[^A-Z0-9_]/g, '_')}`;
750798
751799
const gitContextAttrs = GitHub.context.ref.startsWith('refs/tags/') ? {checksum: GitHub.context.sha} : {'fetch-by-commit': 'true'};
752800
const buildContext = await new Build().gitContext({subdir: inpContext, attrs: gitContextAttrs});
@@ -803,6 +851,26 @@ jobs:
803851
}
804852
core.setOutput('labels', labels.join('\n'));
805853
core.setOutput('build-args', buildArgs);
854+
855+
let buildSecrets;
856+
try {
857+
buildSecrets = parseBuildSecrets(inpBuildSecrets);
858+
} catch (err) {
859+
core.setFailed(err.message);
860+
return;
861+
}
862+
const envs = {
863+
BUILDKIT_MULTI_PLATFORM: '1',
864+
GIT_AUTH_TOKEN: inpGitHubToken
865+
};
866+
const secretEnvs = ['GIT_AUTH_TOKEN=GIT_AUTH_TOKEN'];
867+
Object.entries(buildSecrets).forEach(([id, secret], index) => {
868+
const envName = toBuildSecretEnvName(id, index);
869+
envs[envName] = secret;
870+
secretEnvs.push(`${id}=${envName}`);
871+
});
872+
core.setOutput('envs', JSON.stringify(envs));
873+
core.setOutput('secret-envs', secretEnvs.join('\n'));
806874
807875
if (GitHub.context.payload.repository?.private ?? false) {
808876
// if this is a private repository, we set min provenance mode
@@ -833,13 +901,11 @@ jobs:
833901
platforms: ${{ steps.prepare.outputs.platform }}
834902
provenance: ${{ steps.prepare.outputs.provenance }}
835903
sbom: ${{ steps.prepare.outputs.sbom }}
836-
secret-envs: GIT_AUTH_TOKEN=GIT_AUTH_TOKEN
904+
secret-envs: ${{ steps.prepare.outputs.secret-envs }}
837905
shm-size: ${{ inputs.shm-size }}
838906
target: ${{ inputs.target }}
839907
ulimit: ${{ inputs.ulimit }}
840-
env:
841-
BUILDKIT_MULTI_PLATFORM: 1
842-
GIT_AUTH_TOKEN: ${{ secrets.github-token || github.token }}
908+
env: ${{ fromJson(steps.prepare.outputs.envs || '{}') }}
843909
-
844910
name: Login to registry for signing
845911
if: ${{ needs.prepare.outputs.sign == 'true' && inputs.output == 'image' }}

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ___
1717
* [Secrets](#secrets-1)
1818
* [Outputs](#outputs-1)
1919
* [Notes](#notes)
20+
* [BuildKit secrets](#buildkit-secrets)
2021
* [Signed GitHub Actions cache](#signed-github-actions-cache)
2122
* [Runner mapping](#runner-mapping)
2223
* [Metadata templates](#metadata-templates)
@@ -249,6 +250,7 @@ jobs:
249250
| Name | Default | Description |
250251
|------------------|-----------------------|--------------------------------------------------------------------------------|
251252
| `registry-auths` | | Raw authentication to registries, defined as YAML objects (for `image` output) |
253+
| `build-secrets` | | YAML object mapping BuildKit secret IDs to secret values |
252254
| `github-token` | `${{ github.token }}` | GitHub Token used to authenticate against the repository for Git context |
253255

254256
### Outputs
@@ -358,6 +360,7 @@ jobs:
358360
| Name | Default | Description |
359361
|------------------|-----------------------|--------------------------------------------------------------------------------|
360362
| `registry-auths` | | Raw authentication to registries, defined as YAML objects (for `image` output) |
363+
| `build-secrets` | | YAML object mapping BuildKit secret IDs to secret values |
361364
| `github-token` | `${{ github.token }}` | GitHub Token used to authenticate against the repository for Git context |
362365

363366
### Outputs
@@ -378,6 +381,33 @@ with `builder-outputs: ${{ toJSON(needs.<job_id>.outputs) }}`.
378381

379382
## Notes
380383

384+
### BuildKit secrets
385+
386+
The `build-secrets` secret is shared by the build and bake workflows. It must
387+
be a YAML object where each key is the BuildKit secret ID and each value is the
388+
secret payload:
389+
390+
```yaml
391+
secrets:
392+
build-secrets: |
393+
npmrc: ${{ toJSON(secrets.NPMRC) }}
394+
aws_credentials: ${{ toJSON(secrets.AWS_CREDENTIALS) }}
395+
inline_config: |
396+
first line
397+
second line
398+
```
399+
400+
Use `toJSON(...)` when injecting GitHub secrets so values that contain newlines
401+
or YAML syntax are preserved as a single YAML scalar.
402+
403+
Each secret is exposed as an env-backed BuildKit secret. The build workflow
404+
passes these values through `docker/build-push-action` `secret-envs`, and the
405+
bake workflow appends matching `*.secrets+=id=...,env=...` overrides.
406+
407+
File-based secrets are not supported. These workflows build from Git context
408+
and do not checkout the caller repository, so caller workspace paths are not
409+
available to the build job.
410+
381411
### Signed GitHub Actions cache
382412

383413
When the workflow has GitHub OIDC available through `id-token: write`, BuildKit

test/docker-bake.hcl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ target "hello-cross" {
3838
platforms = ["linux/amd64", "linux/arm64"]
3939
}
4040

41+
target "secret" {
42+
dockerfile = "secret.Dockerfile"
43+
}
44+
4145
target "go-cross-with-contexts" {
4246
inherits = ["go-cross"]
4347
contexts = {

test/secret.Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# syntax=docker/dockerfile:1
2+
3+
FROM alpine
4+
RUN --mount=type=secret,id=github_builder_secret,env=github_builder_secret \
5+
--mount=type=secret,id=github_builder_json_secret,env=github_builder_json_secret \
6+
printf 'github-builder\nsecret\n' > /tmp/expected && \
7+
printf '%s' "$github_builder_secret" | cmp - /tmp/expected && \
8+
printf 'github-builder\njson-secret\n' > /tmp/expected-json && \
9+
printf '%s' "$github_builder_json_secret" | cmp - /tmp/expected-json

0 commit comments

Comments
 (0)