Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/.test-bake.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/.test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 68 additions & 5 deletions .github/workflows/bake.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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 }}
Expand All @@ -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);
Expand All @@ -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');
Expand All @@ -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});
Expand All @@ -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) => {
Expand All @@ -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}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this adding secrets to the definition? I would think secrets need to be defined already and just values are loaded here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dug into this more and I think the secrets+= override is intentional here.

For Bake, target.secret defines both the BuildKit secret ID and the source for that secret. If a caller has secret = ["id=foo,env=MY_FOO"], only exporting another env var from the workflow does not change what Bake uses. Using *.secrets+=id=foo,env=<internal env> replaces the existing same-ID secret source instead of duplicating it, which I verified with buildx bake --print.

That keeps the reusable workflow contract as build-secrets: { foo: value } and avoids making callers reference github-builder internal env names in their Bake files. It also lets existing Bake targets keep local sources for direct docker buildx bake usage, while the reusable workflow overrides those sources when a matching build-secrets entry is provided.

I updated the docs to avoid saying file-based secrets are supported as workflow payloads. The workflow still accepts secret values only and exposes them to BuildKit from env vars. A Bake target can still declare a file source for local use, for example type=file,id=aws,src=${HOME}/.aws/credentials, and the reusable workflow overrides that source when build-secrets contains aws.

});
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;
Expand Down Expand Up @@ -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));
});
Expand All @@ -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
Expand Down
81 changes: 76 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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);
Expand All @@ -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');
Expand All @@ -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,
Expand All @@ -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});
Expand Down Expand Up @@ -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) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to verify that GIT_AUTH_TOKEN is not overwritten?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes good catch, I added a guard that rejects a user-provided BuildKit secret ID of GIT_AUTH_TOKEN.

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
Expand Down Expand Up @@ -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' }}
Expand Down
Loading
Loading