Skip to content

Commit c3af07e

Browse files
committed
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>
1 parent 68f5f0c commit c3af07e

7 files changed

Lines changed: 411 additions & 116 deletions

File tree

.github/workflows/.test-bake.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,23 @@ jobs:
477477
const builderOutputs = JSON.parse(core.getInput('builder-outputs'));
478478
core.info(JSON.stringify(builderOutputs, null, 2));
479479
480+
bake-secret:
481+
uses: ./.github/workflows/bake.yml
482+
permissions:
483+
contents: read
484+
id-token: write
485+
with:
486+
artifact-upload: false
487+
context: test
488+
output: local
489+
target: foosec
490+
secrets:
491+
build-secrets: |
492+
fixture_plain: |
493+
alpha-line
494+
beta-line
495+
foosec.fixture_json: ${{ toJSON(format('gamma-line{0}delta-line{0}', fromJSON('"\n"'))) }}
496+
480497
bake-set-runner:
481498
uses: ./.github/workflows/bake.yml
482499
permissions:

.github/workflows/.test-build.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,22 @@ jobs:
523523
const builderOutputs = JSON.parse(core.getInput('builder-outputs'));
524524
core.info(JSON.stringify(builderOutputs, null, 2));
525525
526+
build-secret:
527+
uses: ./.github/workflows/build.yml
528+
permissions:
529+
contents: read
530+
id-token: write
531+
with:
532+
artifact-upload: false
533+
file: test/secret.Dockerfile
534+
output: local
535+
secrets:
536+
build-secrets: |
537+
fixture_plain: |
538+
alpha-line
539+
beta-line
540+
fixture_json: ${{ toJSON(format('gamma-line{0}delta-line{0}', fromJSON('"\n"'))) }}
541+
526542
build-set-runner:
527543
uses: ./.github/workflows/build.yml
528544
permissions:

.github/workflows/bake.yml

Lines changed: 166 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ on:
149149
registry-auths:
150150
description: "Raw authentication to registries, defined as YAML objects (for image output)"
151151
required: false
152+
build-secrets:
153+
description: "YAML object mapping BuildKit secret IDs, optionally target-scoped, to secret values"
154+
required: false
152155
github-token:
153156
description: "GitHub Token used to authenticate against the repository for Git context"
154157
required: false
@@ -215,6 +218,7 @@ jobs:
215218
metaImages: ${{ steps.set.outputs.metaImages }}
216219
sign: ${{ steps.set.outputs.sign }}
217220
privateRepo: ${{ steps.set.outputs.privateRepo }}
221+
targets: ${{ steps.set.outputs.targets }}
218222
ghaCacheSign: ${{ steps.set.outputs.ghaCacheSign }}
219223
proxyNetwork: ${{ steps.set.outputs.proxyNetwork }}
220224
steps:
@@ -490,7 +494,7 @@ jobs:
490494
}
491495
);
492496
await core.group(`Set envs`, async () => {
493-
core.info(JSON.stringify(envs, null, 2));
497+
core.info(JSON.stringify(Object.keys(envs).sort(), null, 2));
494498
});
495499
496500
const metaImages = inpMetaImages.map(image => image.toLowerCase());
@@ -504,9 +508,15 @@ jobs:
504508
try {
505509
await core.group(`Validating definition`, async () => {
506510
const bake = new Bake();
511+
// Resolve the graph without local secret files. The build job validates
512+
// secrets after applying the workflow-provided source overrides.
513+
const validationOverrides = inpSet.filter(override => {
514+
const key = override.split('=', 1)[0].split('.')[1];
515+
return !['secret', 'secrets', 'secrets+'].includes(key);
516+
});
507517
def = await bake.getDefinition({
508518
files: inpFiles,
509-
overrides: inpSet,
519+
overrides: [...validationOverrides, '*.secrets='],
510520
sbom: inpSbom ? `generator=${inpSbomImage}` : 'false',
511521
source: bakeSource,
512522
targets: [inpTarget]
@@ -556,6 +566,7 @@ jobs:
556566
if (unsupportedTargets.length > 0) {
557567
throw new Error(`Only one target can be built at once, found unsupported targets: ${unsupportedTargets.join(', ')}`);
558568
}
569+
core.setOutput('targets', JSON.stringify([...allowedTargets]));
559570
});
560571
} catch (error) {
561572
core.setFailed(error);
@@ -838,6 +849,57 @@ jobs:
838849
cosignPath,
839850
`${containerName}:/usr/bin/cosign`
840851
]);
852+
-
853+
name: Configure AWS credentials
854+
if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }}
855+
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
856+
with:
857+
role-to-assume: ${{ needs.registry-identities.outputs.aws-ecr-role-to-assume }}
858+
aws-region: ${{ needs.registry-identities.outputs.aws-ecr-region }}
859+
-
860+
name: Login to Amazon ECR
861+
if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }}
862+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
863+
with:
864+
registry-auth: |
865+
- registry: ${{ needs.registry-identities.outputs.aws-ecr-registry }}
866+
-
867+
name: Authenticate to Google Cloud
868+
id: gcp-wif-auth
869+
if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }}
870+
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
871+
with:
872+
token_format: access_token
873+
workload_identity_provider: ${{ needs.registry-identities.outputs.gcp-wif-workload-identity-provider }}
874+
service_account: ${{ needs.registry-identities.outputs.gcp-wif-service-account }}
875+
project_id: ${{ needs.registry-identities.outputs.gcp-wif-project-id }}
876+
create_credentials_file: false
877+
export_environment_variables: false
878+
-
879+
name: Login to Google Artifact Registry
880+
if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }}
881+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
882+
with:
883+
registry-auth: |
884+
- registry: ${{ needs.registry-identities.outputs.gcp-wif-registry }}
885+
username: oauth2accesstoken
886+
password: ${{ steps.gcp-wif-auth.outputs.access_token }}
887+
-
888+
name: Login to Docker Hub with OIDC
889+
if: ${{ needs.registry-identities.outputs.dockerhub-oidc-enabled == 'true' }}
890+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
891+
env:
892+
DOCKERHUB_OIDC_CONNECTIONID: ${{ needs.registry-identities.outputs.dockerhub-oidc-connection-id }}
893+
with:
894+
registry-auth: |
895+
- registry: ${{ needs.registry-identities.outputs.dockerhub-oidc-registry }}
896+
username: ${{ needs.registry-identities.outputs.dockerhub-oidc-username }}
897+
-
898+
name: Login to registry
899+
if: ${{ inputs.push && inputs.output == 'image' && env.REGISTRY_AUTHS_PRESENT == 'true' }}
900+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
901+
with:
902+
registry-auth: ${{ secrets.registry-auths }}
841903
-
842904
name: Prepare
843905
id: prepare
@@ -849,6 +911,8 @@ jobs:
849911
INPUT_CACHE: ${{ inputs.cache }}
850912
INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }}
851913
INPUT_CACHE-MODE: ${{ inputs.cache-mode }}
914+
INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }}
915+
INPUT_TARGETS: ${{ needs.prepare.outputs.targets }}
852916
INPUT_CONTEXT: ${{ inputs.context }}
853917
INPUT_FILES: ${{ inputs.files }}
854918
INPUT_OUTPUT: ${{ inputs.output }}
@@ -865,15 +929,23 @@ jobs:
865929
INPUT_BAKE-FILE-TAGS: ${{ steps.meta.outputs.bake-file-tags }}
866930
INPUT_BAKE-FILE-ANNOTATIONS: ${{ steps.meta.outputs.bake-file-annotations }}
867931
INPUT_BAKE-FILE-LABELS: ${{ steps.meta.outputs.bake-file-labels }}
868-
INPUT_GITHUB-TOKEN: ${{ secrets.github-token || github.token }}
869932
INPUT_BUILDKIT-PROXY-NETWORK: ${{ inputs.buildkit-proxy-network }}
870933
with:
871934
script: |
935+
const fs = require('fs');
872936
const os = require('os');
937+
const path = require('path');
873938
const { Build } = require('@docker/github-builder-runtime/lib/buildx/build');
874939
const { GitHub } = require('@docker/github-builder-runtime/lib/github/github');
875940
const { Util } = require('@docker/github-builder-runtime/lib/util');
876-
941+
942+
let yaml;
943+
try {
944+
yaml = require('js-yaml');
945+
} catch {
946+
yaml = require('@docker/github-builder-runtime/node_modules/js-yaml');
947+
}
948+
877949
const inpPlatform = core.getInput('platform');
878950
const platformPairSuffix = inpPlatform ? `-${inpPlatform.replace(/\//g, '-')}` : '';
879951
core.setOutput('platform-pair-suffix', platformPairSuffix);
@@ -884,6 +956,8 @@ jobs:
884956
const inpCache = core.getBooleanInput('cache');
885957
const inpCacheScope = core.getInput('cache-scope');
886958
const inpCacheMode = core.getInput('cache-mode');
959+
const inpBuildSecrets = core.getInput('build-secrets');
960+
const inpTargets = core.getInput('targets');
887961
const inpContext = core.getInput('context');
888962
const inpFiles = Util.getInputList('files');
889963
const inpOutput = core.getInput('output');
@@ -892,7 +966,6 @@ jobs:
892966
const inpSet = Util.getInputList('set', {ignoreComma: true, quote: false});
893967
const inpTarget = core.getInput('target');
894968
const inpVars = Util.getInputList('vars');
895-
const inpGitHubToken = core.getInput('github-token');
896969
const inpBuildkitProxyNetwork = core.getBooleanInput('buildkit-proxy-network');
897970
898971
const inpMetaImages = core.getMultilineInput('meta-images');
@@ -909,6 +982,42 @@ jobs:
909982
tags: inpMetaTags
910983
};
911984
const renderTemplate = value => Util.compileHandlebars(value, {noEscape: true}, {meta});
985+
986+
const isInputKeySafe = value => value && !/[\r\n=]/.test(value);
987+
const parseBuildSecretKey = key => {
988+
const separator = key.lastIndexOf('.');
989+
return separator === -1 ? {target: inpTarget, id: key} : {target: key.substring(0, separator), id: key.substring(separator + 1)};
990+
};
991+
const parseBuildSecrets = value => {
992+
const normalized = value.trim();
993+
if (!normalized) {
994+
return [];
995+
}
996+
let parsed;
997+
try {
998+
parsed = yaml.load(normalized, {schema: yaml.FAILSAFE_SCHEMA});
999+
} catch (err) {
1000+
const location = err.mark ? ` at line ${err.mark.line + 1}, column ${err.mark.column + 1}` : '';
1001+
throw new Error(`Failed to parse build-secrets YAML${location}`);
1002+
}
1003+
if (!parsed) {
1004+
return [];
1005+
}
1006+
if (Array.isArray(parsed) || typeof parsed !== 'object') {
1007+
throw new Error('build-secrets must be a YAML object');
1008+
}
1009+
return Object.entries(parsed).map(([key, secret]) => {
1010+
const {target, id} = parseBuildSecretKey(key);
1011+
if (!isInputKeySafe(target) || !isInputKeySafe(id)) {
1012+
throw new Error(`Invalid build-secrets key "${key}": use "secret_id" or "target.secret_id" without empty names, line breaks or "="`);
1013+
}
1014+
if (typeof secret !== 'string') {
1015+
throw new Error(`build-secrets value for "${key}" must be a string`);
1016+
}
1017+
core.setSecret(secret);
1018+
return {target, id, secret};
1019+
});
1020+
};
9121021
9131022
const gitContextAttrs = GitHub.context.ref.startsWith('refs/tags/') ? {checksum: GitHub.context.sha} : {'fetch-by-commit': 'true'};
9141023
const bakeSource = await new Build().gitContext({subdir: inpContext, attrs: gitContextAttrs});
@@ -927,7 +1036,29 @@ jobs:
9271036
core.info(sbom);
9281037
core.setOutput('sbom', sbom);
9291038
});
930-
1039+
1040+
let buildSecrets;
1041+
try {
1042+
buildSecrets = parseBuildSecrets(inpBuildSecrets);
1043+
} catch (err) {
1044+
core.setFailed(err.message);
1045+
return;
1046+
}
1047+
1048+
let targets;
1049+
try {
1050+
targets = JSON.parse(inpTargets || '[]');
1051+
const allowedTargets = new Set(targets);
1052+
for (const {target} of buildSecrets) {
1053+
if (!allowedTargets.has(target)) {
1054+
throw new Error(`Build secret target "${target}" is not part of the resolved Bake definition`);
1055+
}
1056+
}
1057+
} catch (err) {
1058+
core.setFailed(err.message);
1059+
return;
1060+
}
1061+
9311062
const envs = Object.assign({},
9321063
inpVars ? inpVars.reduce((acc, curr) => {
9331064
const idx = curr.indexOf('=');
@@ -937,12 +1068,26 @@ jobs:
9371068
return acc;
9381069
}, {}) : {},
9391070
{
940-
BUILDKIT_MULTI_PLATFORM: '1',
941-
BUILDX_BAKE_GIT_AUTH_TOKEN: inpGitHubToken
1071+
BUILDKIT_MULTI_PLATFORM: '1'
9421072
}
9431073
);
1074+
1075+
// Git authentication is supplied directly to the Bake action.
1076+
delete envs.BUILDX_BAKE_GIT_AUTH_TOKEN;
1077+
1078+
const secretOverrides = [];
1079+
if (buildSecrets.length > 0) {
1080+
const secretDir = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'build-secrets-'));
1081+
core.setOutput('secret-dir', secretDir);
1082+
buildSecrets.forEach(({target, id, secret}, index) => {
1083+
const secretFile = path.join(secretDir, String(index));
1084+
fs.writeFileSync(secretFile, secret, {mode: 0o600});
1085+
secretOverrides.push(`${target}.secret.${id}=src=${secretFile}`);
1086+
});
1087+
}
1088+
9441089
await core.group(`Set envs`, async () => {
945-
core.info(JSON.stringify(envs, null, 2));
1090+
core.info(JSON.stringify(Object.keys(envs).sort(), null, 2));
9461091
core.setOutput('envs', JSON.stringify(envs));
9471092
});
9481093
@@ -1006,60 +1151,10 @@ jobs:
10061151
bakeOverrides.push(`*.cache-from=type=gha,scope=${inpCacheScope || inpTarget}${platformPairSuffix}${proxyNetworkSuffix}`);
10071152
bakeOverrides.push(`*.cache-to=type=gha,ignore-error=true,scope=${inpCacheScope || inpTarget}${platformPairSuffix}${proxyNetworkSuffix},mode=${inpCacheMode}`);
10081153
}
1154+
bakeOverrides.push(...secretOverrides);
10091155
core.info(JSON.stringify(bakeOverrides, null, 2));
10101156
core.setOutput('overrides', bakeOverrides.join(os.EOL));
10111157
});
1012-
-
1013-
name: Configure AWS credentials
1014-
if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }}
1015-
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
1016-
with:
1017-
role-to-assume: ${{ needs.registry-identities.outputs.aws-ecr-role-to-assume }}
1018-
aws-region: ${{ needs.registry-identities.outputs.aws-ecr-region }}
1019-
-
1020-
name: Login to Amazon ECR
1021-
if: ${{ needs.registry-identities.outputs.aws-ecr-enabled == 'true' }}
1022-
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
1023-
with:
1024-
registry-auth: |
1025-
- registry: ${{ needs.registry-identities.outputs.aws-ecr-registry }}
1026-
-
1027-
name: Authenticate to Google Cloud
1028-
id: gcp-wif-auth
1029-
if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }}
1030-
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
1031-
with:
1032-
token_format: access_token
1033-
workload_identity_provider: ${{ needs.registry-identities.outputs.gcp-wif-workload-identity-provider }}
1034-
service_account: ${{ needs.registry-identities.outputs.gcp-wif-service-account }}
1035-
project_id: ${{ needs.registry-identities.outputs.gcp-wif-project-id }}
1036-
create_credentials_file: false
1037-
export_environment_variables: false
1038-
-
1039-
name: Login to Google Artifact Registry
1040-
if: ${{ needs.registry-identities.outputs.gcp-wif-enabled == 'true' }}
1041-
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
1042-
with:
1043-
registry-auth: |
1044-
- registry: ${{ needs.registry-identities.outputs.gcp-wif-registry }}
1045-
username: oauth2accesstoken
1046-
password: ${{ steps.gcp-wif-auth.outputs.access_token }}
1047-
-
1048-
name: Login to Docker Hub with OIDC
1049-
if: ${{ needs.registry-identities.outputs.dockerhub-oidc-enabled == 'true' }}
1050-
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
1051-
env:
1052-
DOCKERHUB_OIDC_CONNECTIONID: ${{ needs.registry-identities.outputs.dockerhub-oidc-connection-id }}
1053-
with:
1054-
registry-auth: |
1055-
- registry: ${{ needs.registry-identities.outputs.dockerhub-oidc-registry }}
1056-
username: ${{ needs.registry-identities.outputs.dockerhub-oidc-username }}
1057-
-
1058-
name: Login to registry
1059-
if: ${{ inputs.push && inputs.output == 'image' && env.REGISTRY_AUTHS_PRESENT == 'true' }}
1060-
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
1061-
with:
1062-
registry-auth: ${{ secrets.registry-auths }}
10631158
-
10641159
name: Build
10651160
id: bake
@@ -1070,7 +1165,18 @@ jobs:
10701165
targets: ${{ steps.prepare.outputs.target }}
10711166
sbom: ${{ steps.prepare.outputs.sbom }}
10721167
set: ${{ steps.prepare.outputs.overrides }}
1168+
github-token: ${{ secrets.github-token || github.token }}
10731169
env: ${{ fromJson(steps.prepare.outputs.envs || '{}') }}
1170+
-
1171+
name: Remove build secrets
1172+
if: ${{ always() && steps.prepare.outputs.secret-dir != '' }}
1173+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
1174+
env:
1175+
INPUT_SECRET-DIR: ${{ steps.prepare.outputs.secret-dir }}
1176+
with:
1177+
script: |
1178+
const fs = require('fs');
1179+
fs.rmSync(core.getInput('secret-dir', {required: true}), {recursive: true, force: true});
10741180
-
10751181
name: Get image digest
10761182
id: get-image-digest

0 commit comments

Comments
 (0)