From c3bcd98f228db7183968229476f631571de6b25c Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:03:41 +0100 Subject: [PATCH] build/bake: support configurable image layer compression Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- .github/workflows/.test-bake.yml | 38 +++++++++++++++ .github/workflows/.test-build.yml | 38 +++++++++++++++ .github/workflows/bake.yml | 77 ++++++++++++++++++++----------- .github/workflows/build.yml | 75 ++++++++++++++++++++---------- README.md | 6 +++ test/compression.Dockerfile | 2 + test/docker-bake.hcl | 6 +++ 7 files changed, 191 insertions(+), 51 deletions(-) create mode 100644 test/compression.Dockerfile diff --git a/.github/workflows/.test-bake.yml b/.github/workflows/.test-bake.yml index c749b82c..8b3722eb 100644 --- a/.github/workflows/.test-bake.yml +++ b/.github/workflows/.test-bake.yml @@ -750,3 +750,41 @@ jobs: username: ${{ vars.DOCKERHUB_OIDC_USERNAME }} connection_id: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} scope: 'dhi.io@pull' + + bake-compression: + strategy: + matrix: + include: + - variant: default + compression: '' + level: '' + force: false + - variant: zstd + compression: zstd + level: '0' + force: false + - variant: zstd-force + compression: zstd + level: '9' + force: true + uses: ./.github/workflows/bake.yml + permissions: + contents: read + id-token: write + packages: write + with: + compression: ${{ matrix.compression }} + compression-level: ${{ matrix.level }} + force-compression: ${{ matrix.force }} + output: image + push: ${{ github.event_name != 'pull_request' }} + context: test + target: compression + meta-images: ghcr.io/docker/github-builder-test + meta-tags: | + type=raw,value=bake-compression-${{ matrix.variant }}-${{ github.run_id }}-${{ github.run_attempt }} + secrets: + registry-auths: | + - registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/.test-build.yml b/.github/workflows/.test-build.yml index 5bbc3867..7d45b0c5 100644 --- a/.github/workflows/.test-build.yml +++ b/.github/workflows/.test-build.yml @@ -759,3 +759,41 @@ jobs: username: ${{ vars.DOCKERHUB_OIDC_USERNAME }} connection_id: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} scope: 'dhi.io@pull' + + build-compression: + strategy: + matrix: + include: + - variant: default + compression: '' + level: '' + force: false + - variant: zstd + compression: zstd + level: '0' + force: false + - variant: zstd-force + compression: zstd + level: '9' + force: true + uses: ./.github/workflows/build.yml + permissions: + contents: read + id-token: write + packages: write + with: + compression: ${{ matrix.compression }} + compression-level: ${{ matrix.level }} + force-compression: ${{ matrix.force }} + output: image + push: ${{ github.event_name != 'pull_request' }} + file: test/compression.Dockerfile + platforms: linux/amd64,linux/arm64 + meta-images: ghcr.io/docker/github-builder-test + meta-tags: | + type=raw,value=build-compression-${{ matrix.variant }}-${{ github.run_id }}-${{ github.run_attempt }} + secrets: + registry-auths: | + - registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/bake.yml b/.github/workflows/bake.yml index 5387da6e..2b0472b7 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -59,6 +59,19 @@ on: description: "Cache layers to export if cache enabled (min or max)" required: false default: 'min' + compression: + type: string + description: "Image layer compression (uncompressed, gzip, estargz or zstd)" + required: false + compression-level: + type: string + description: "Image layer compression level (0-9 for gzip and estargz, 0-22 for zstd)" + required: false + force-compression: + type: boolean + description: "Recompress existing image layers using the selected compression type" + required: false + default: false context: type: string description: "Context to build from in the Git working tree" @@ -219,6 +232,7 @@ jobs: include: - name: ${{ inputs.job-name-prefix }} outputs: + buildOutput: ${{ steps.set.outputs.buildOutput }} includes: ${{ steps.set.outputs.includes }} metaImages: ${{ steps.set.outputs.metaImages }} sign: ${{ steps.set.outputs.sign }} @@ -315,6 +329,10 @@ jobs: id: set uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + INPUT_LOCAL-EXPORT-DIR: ${{ env.LOCAL_EXPORT_DIR }} + INPUT_COMPRESSION: ${{ inputs.compression }} + INPUT_COMPRESSION-LEVEL: ${{ inputs.compression-level }} + INPUT_FORCE-COMPRESSION: ${{ inputs.force-compression }} INPUT_SBOM-IMAGE: ${{ env.SBOM_IMAGE }} INPUT_MATRIX-SIZE-LIMIT: ${{ env.MATRIX_SIZE_LIMIT }} INPUT_META-IMAGES: ${{ inputs.meta-images }} @@ -341,6 +359,7 @@ jobs: const { Build } = require('@docker/github-builder-runtime/lib/buildx/build'); const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { MetaImages } = require('@docker/github-builder-runtime/lib/github-builder/meta-images'); + const { BuildOutput } = require('@docker/github-builder-runtime/lib/github-builder/build-output'); const { RunnerMapping } = require('@docker/github-builder-runtime/lib/github-builder/runner-mapping'); const { BakeTargets } = require('@docker/github-builder-runtime/lib/github-builder/bake-targets'); const { BakeVars } = require('@docker/github-builder-runtime/lib/github-builder/bake-vars'); @@ -364,6 +383,10 @@ jobs: const inpSbom = core.getBooleanInput('sbom'); const inpSet = Util.getInputList('set', {ignoreComma: true, quote: false}); const inpSign = core.getInput('sign'); + const inpCompression = core.getInput('compression'); + const inpCompressionLevel = core.getInput('compression-level'); + const inpForceCompression = core.getBooleanInput('force-compression'); + const inpLocalExportDir = core.getInput('local-export-dir'); const inpTarget = core.getInput('target'); const inpGitHubToken = core.getInput('github-token'); const inpProxyNetwork = core.getBooleanInput('buildkit-proxy-network'); @@ -414,7 +437,33 @@ jobs: }).sort(), null, 2)); }); + if (inpCompression && !['uncompressed', 'gzip', 'estargz', 'zstd'].includes(inpCompression)) { + core.setFailed(`Invalid compression: ${inpCompression}`); + return; + } + if (inpCompressionLevel !== '' && (!/^\d+$/.test(inpCompressionLevel) || !Number.isSafeInteger(Number(inpCompressionLevel)))) { + core.setFailed(`Invalid compression-level: ${inpCompressionLevel}`); + return; + } + if (inpOutput === 'local' && (inpCompression || inpCompressionLevel !== '' || inpForceCompression)) { + core.warning('Compression settings are ignored for local output'); + } + const metaImages = MetaImages.resolve(inpMetaImages); + const buildOutput = BuildOutput.resolve({ + output: inpOutput, + images: metaImages, + push: inpPush, + localExportDir: inpLocalExportDir, + compression: inpCompression || undefined, + compressionLevel: inpCompressionLevel !== '' ? Number(inpCompressionLevel) : undefined, + forceCompression: inpForceCompression || undefined + }); + await core.group(`Set buildOutput output`, async () => { + core.info(buildOutput); + core.setOutput('buildOutput', buildOutput); + }); + await core.group(`Set metaImages output`, async () => { core.info(JSON.stringify(metaImages, null, 2)); core.setOutput('metaImages', metaImages.join('\n')); @@ -822,9 +871,9 @@ jobs: id: prepare uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + INPUT_BUILD-OUTPUT: ${{ needs.prepare.outputs.buildOutput }} INPUT_PLATFORM: ${{ matrix.platform }} INPUT_SBOM-IMAGE: ${{ env.SBOM_IMAGE }} - INPUT_LOCAL-EXPORT-DIR: ${{ env.LOCAL_EXPORT_DIR }} INPUT_CACHE: ${{ inputs.cache }} INPUT_CACHE-SCOPE: ${{ inputs.cache-scope }} INPUT_CACHE-MODE: ${{ inputs.cache-mode }} @@ -832,13 +881,10 @@ jobs: INPUT_TARGETS: ${{ needs.prepare.outputs.targets }} INPUT_CONTEXT: ${{ inputs.context }} INPUT_FILES: ${{ inputs.files }} - INPUT_OUTPUT: ${{ inputs.output }} - INPUT_PUSH: ${{ inputs.push }} INPUT_SBOM: ${{ inputs.sbom }} INPUT_SET: ${{ inputs.set }} INPUT_TARGET: ${{ inputs.target }} INPUT_VARS: ${{ inputs.vars }} - INPUT_META-IMAGES: ${{ needs.prepare.outputs.metaImages }} INPUT_META-VERSION: ${{ steps.meta.outputs.version }} INPUT_META-TAGS: ${{ steps.meta.outputs.tags }} INPUT_SET-META-ANNOTATIONS: ${{ inputs.set-meta-annotations }} @@ -861,7 +907,6 @@ jobs: core.setOutput('platform-pair-suffix', platformPairSuffix); const inpSbomImage = core.getInput('sbom-image'); - const inpLocalExportDir = core.getInput('local-export-dir'); const inpCache = core.getBooleanInput('cache'); const inpCacheScope = core.getInput('cache-scope'); @@ -870,15 +915,12 @@ jobs: const inpTargets = core.getInput('targets'); const inpContext = core.getInput('context'); const inpFiles = Util.getInputList('files'); - const inpOutput = core.getInput('output'); - const inpPush = core.getBooleanInput('push'); const inpSbom = core.getBooleanInput('sbom'); const inpSet = Util.getInputList('set', {ignoreComma: true, quote: false}); const inpTarget = core.getInput('target'); const inpVars = Util.getInputList('vars', {ignoreComma: true, quote: false}); const inpBuildkitProxyNetwork = core.getBooleanInput('buildkit-proxy-network'); - const inpMetaImages = core.getMultilineInput('meta-images'); const inpMetaVersion = core.getInput('meta-version'); const inpMetaTags = core.getMultilineInput('meta-tags'); const inpSetMetaAnnotations = core.getBooleanInput('set-meta-annotations'); @@ -947,23 +989,6 @@ jobs: core.setOutput('files', bakeFiles.join(os.EOL)); }); - let outputOverride = ''; - switch (inpOutput) { - case 'image': - if (inpMetaImages.length == 0) { - core.setFailed('meta-images is required when output is image'); - return; - } - outputOverride = `*.output=type=image,"name=${inpMetaImages.join(',')}",oci-artifact=true,push-by-digest=true,name-canonical=true,push=${inpPush}`; - break; - case 'local': - outputOverride = `*.output=type=local,platform-split=true,dest=${inpLocalExportDir}`; - break; - default: - core.setFailed(`Invalid output: ${inpOutput}`); - return; - } - let bakeOverrides = []; try { bakeOverrides = inpSet.map(override => renderTemplate(override)); @@ -972,7 +997,7 @@ jobs: return; } await core.group(`Set bake overrides`, async () => { - bakeOverrides.push(outputOverride); + bakeOverrides.push(`*.output=${core.getInput('build-output')}`); bakeOverrides.push('*.tags='); if (GitHub.context.payload.repository?.private ?? false) { // if this is a private repository, we set min provenance mode diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 554458d3..7aaaf8b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,6 +67,19 @@ on: description: "Cache layers to export if cache enabled (min or max)" required: false default: 'min' + compression: + type: string + description: "Image layer compression (uncompressed, gzip, estargz or zstd)" + required: false + compression-level: + type: string + description: "Image layer compression level (0-9 for gzip and estargz, 0-22 for zstd)" + required: false + force-compression: + type: boolean + description: "Recompress existing image layers using the selected compression type" + required: false + default: false context: type: string description: "Context to build from in the Git working tree" @@ -230,6 +243,7 @@ jobs: include: - name: ${{ inputs.job-name-prefix }} outputs: + buildOutput: ${{ steps.set.outputs.buildOutput }} includes: ${{ steps.set.outputs.includes }} metaImages: ${{ steps.set.outputs.metaImages }} sign: ${{ steps.set.outputs.sign }} @@ -317,6 +331,10 @@ jobs: id: set uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + INPUT_LOCAL-EXPORT-DIR: ${{ env.LOCAL_EXPORT_DIR }} + INPUT_COMPRESSION: ${{ inputs.compression }} + INPUT_COMPRESSION-LEVEL: ${{ inputs.compression-level }} + INPUT_FORCE-COMPRESSION: ${{ inputs.force-compression }} INPUT_MATRIX-SIZE-LIMIT: ${{ env.MATRIX_SIZE_LIMIT }} INPUT_META-IMAGES: ${{ inputs.meta-images }} INPUT_RUNNER: ${{ inputs.runner }} @@ -333,6 +351,7 @@ jobs: script: | const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { MetaImages } = require('@docker/github-builder-runtime/lib/github-builder/meta-images'); + const { BuildOutput } = require('@docker/github-builder-runtime/lib/github-builder/build-output'); const { RunnerMapping } = require('@docker/github-builder-runtime/lib/github-builder/runner-mapping'); const { Util } = require('@docker/github-builder-runtime/lib/util'); @@ -349,6 +368,10 @@ jobs: const inpPush = core.getBooleanInput('push'); const inpRegistryLogin = core.getInput('registry-login'); const inpSign = core.getInput('sign'); + const inpCompression = core.getInput('compression'); + const inpCompressionLevel = core.getInput('compression-level'); + const inpForceCompression = core.getBooleanInput('force-compression'); + const inpLocalExportDir = core.getInput('local-export-dir'); const inpProxyNetwork = core.getBooleanInput('buildkit-proxy-network'); let runnerConfig; @@ -400,7 +423,33 @@ jobs: core.setOutput('proxyNetwork', proxyNetwork); }); + if (inpCompression && !['uncompressed', 'gzip', 'estargz', 'zstd'].includes(inpCompression)) { + core.setFailed(`Invalid compression: ${inpCompression}`); + return; + } + if (inpCompressionLevel !== '' && (!/^\d+$/.test(inpCompressionLevel) || !Number.isSafeInteger(Number(inpCompressionLevel)))) { + core.setFailed(`Invalid compression-level: ${inpCompressionLevel}`); + return; + } + if (inpOutput === 'local' && (inpCompression || inpCompressionLevel !== '' || inpForceCompression)) { + core.warning('Compression settings are ignored for local output'); + } + const metaImages = MetaImages.resolve(inpMetaImages); + const buildOutput = BuildOutput.resolve({ + output: inpOutput, + images: metaImages, + push: inpPush, + localExportDir: inpLocalExportDir, + compression: inpCompression || undefined, + compressionLevel: inpCompressionLevel !== '' ? Number(inpCompressionLevel) : undefined, + forceCompression: inpForceCompression || undefined + }); + await core.group(`Set buildOutput output`, async () => { + core.info(buildOutput); + core.setOutput('buildOutput', buildOutput); + }); + await core.group(`Set metaImages output`, async () => { core.info(JSON.stringify(metaImages, null, 2)); core.setOutput('metaImages', metaImages.join('\n')); @@ -754,7 +803,6 @@ jobs: env: INPUT_PLATFORM: ${{ matrix.platform }} INPUT_SBOM-IMAGE: ${{ env.SBOM_IMAGE }} - INPUT_LOCAL-EXPORT-DIR: ${{ env.LOCAL_EXPORT_DIR }} INPUT_DISTRIBUTE: ${{ inputs.distribute }} INPUT_ANNOTATIONS: ${{ inputs.annotations }} INPUT_BUILD-ARGS: ${{ inputs.build-args }} @@ -765,12 +813,9 @@ jobs: INPUT_BUILD-SECRETS: ${{ secrets.build-secrets }} INPUT_LABELS: ${{ inputs.labels }} INPUT_CONTEXT: ${{ inputs.context }} - INPUT_OUTPUT: ${{ inputs.output }} INPUT_PLATFORMS: ${{ inputs.platforms }} - INPUT_PUSH: ${{ inputs.push }} INPUT_SBOM: ${{ inputs.sbom }} INPUT_TARGET: ${{ inputs.target }} - INPUT_META-IMAGES: ${{ needs.prepare.outputs.metaImages }} INPUT_META-VERSION: ${{ steps.meta.outputs.version }} INPUT_META-TAGS: ${{ steps.meta.outputs.tags }} INPUT_SET-META-ANNOTATIONS: ${{ inputs.set-meta-annotations }} @@ -789,7 +834,6 @@ jobs: core.setOutput('platform-pair-suffix', platformPairSuffix); const inpSbomImage = core.getInput('sbom-image'); - const inpLocalExportDir = core.getInput('local-export-dir'); const inpDistribute = core.getBooleanInput('distribute'); const inpAnnotations = core.getInput('annotations'); @@ -800,14 +844,11 @@ jobs: const inpBuildSecrets = core.getInput('build-secrets', {trimWhitespace: false}); const inpContext = core.getInput('context'); const inpLabels = core.getInput('labels'); - const inpOutput = core.getInput('output'); const inpPlatforms = core.getInput('platforms'); - const inpPush = core.getBooleanInput('push'); const inpSbom = core.getBooleanInput('sbom'); const inpTarget = core.getInput('target'); const inpBuildkitProxyNetwork = core.getBooleanInput('buildkit-proxy-network'); - const inpMetaImages = core.getMultilineInput('meta-images'); const inpMetaVersion = core.getInput('meta-version'); const inpMetaTags = core.getMultilineInput('meta-tags'); const inpSetMetaAnnotations = core.getBooleanInput('set-meta-annotations'); @@ -825,22 +866,6 @@ jobs: const buildContext = await new Build().gitContext({subdir: inpContext, attrs: gitContextAttrs}); core.setOutput('context', buildContext); - switch (inpOutput) { - case 'image': - if (inpMetaImages.length == 0) { - core.setFailed('meta-images is required when output is image'); - return; - } - core.setOutput('output', `type=image,"name=${inpMetaImages.join(',')}",oci-artifact=true,push-by-digest=true,name-canonical=true,push=${inpPush}`); - break; - case 'local': - core.setOutput('output', `type=local,platform-split=true,dest=${inpLocalExportDir}`); - break; - default: - core.setFailed(`Invalid output: ${inpOutput}`); - return; - } - if (inpPlatform) { core.setOutput('platform', inpPlatform); } else if (!inpDistribute && inpPlatforms) { @@ -908,7 +933,7 @@ jobs: context: ${{ steps.prepare.outputs.context }} file: ${{ inputs.file }} labels: ${{ steps.prepare.outputs.labels }} - outputs: ${{ steps.prepare.outputs.output }} + outputs: ${{ needs.prepare.outputs.buildOutput }} platforms: ${{ steps.prepare.outputs.platform }} provenance: ${{ steps.prepare.outputs.provenance }} sbom: ${{ steps.prepare.outputs.sbom }} diff --git a/README.md b/README.md index 80d3e7db..86a061c1 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,9 @@ jobs: | `cache` | Bool | `false` | Enable [GitHub Actions cache](https://docs.docker.com/build/cache/backends/gha/) exporter | | `cache-scope` | String | target name or `buildkit` | Which [scope cache object belongs to](https://docs.docker.com/build/cache/backends/gha/#scope) if `cache` is enabled. This is the cache blob prefix name used when pushing cache to GitHub Actions cache backend | | `cache-mode` | String | `min` | [Cache layers to export](https://docs.docker.com/build/cache/backends/#cache-mode) if cache enabled (`min` or `max`). In `min` cache mode, only layers that are exported into the resulting image are cached, while in `max` cache mode, all layers are cached, even those of intermediate steps | +| `compression` | String | | Image layer compression: `uncompressed`, `gzip`, `estargz`, or `zstd`. Only applies to `image` output. | +| `compression-level` | String | | Image layer compression level: `0-9` for `gzip` and `estargz`, `0-22` for `zstd`. Empty uses BuildKit's default. Only applies to `image` output. | +| `force-compression` | Bool | `false` | Recompress existing image layers using the selected compression type. Only applies to `image` output. | | `context` | String | `.` | Context to build from in the Git working tree | | `file` | String | `{context}/Dockerfile` | Path to the Dockerfile | | `labels` | List | | List of labels for an image (for `image` output) | @@ -355,6 +358,9 @@ jobs: | `cache` | Bool | `false` | Enable [GitHub Actions cache](https://docs.docker.com/build/cache/backends/gha/) exporter | | `cache-scope` | String | target name or `buildkit` | Which [scope cache object belongs to](https://docs.docker.com/build/cache/backends/gha/#scope) if `cache` is enabled. This is the cache blob prefix name used when pushing cache to GitHub Actions cache backend | | `cache-mode` | String | `min` | [Cache layers to export](https://docs.docker.com/build/cache/backends/#cache-mode) if cache enabled (`min` or `max`). In `min` cache mode, only layers that are exported into the resulting image are cached, while in `max` cache mode, all layers are cached, even those of intermediate steps | +| `compression` | String | | Image layer compression: `uncompressed`, `gzip`, `estargz`, or `zstd`. Only applies to `image` output. | +| `compression-level` | String | | Image layer compression level: `0-9` for `gzip` and `estargz`, `0-22` for `zstd`. Empty uses BuildKit's default. Only applies to `image` output. | +| `force-compression` | Bool | `false` | Recompress existing image layers using the selected compression type. Only applies to `image` output. | | `context` | String | `.` | Context to build from in the Git working tree | | `files` | List | `{context}/docker-bake.hcl` | List of bake definition files | | `output` | String | | Build output destination (one of [`image`](https://docs.docker.com/build/exporters/image-registry/) or [`local`](https://docs.docker.com/build/exporters/local-tar/)). | diff --git a/test/compression.Dockerfile b/test/compression.Dockerfile new file mode 100644 index 00000000..aea26e33 --- /dev/null +++ b/test/compression.Dockerfile @@ -0,0 +1,2 @@ +FROM alpine +RUN printf 'compression fixture\n' > /compression.txt diff --git a/test/docker-bake.hcl b/test/docker-bake.hcl index 21aca2fe..fd99388a 100644 --- a/test/docker-bake.hcl +++ b/test/docker-bake.hcl @@ -128,3 +128,9 @@ target "dhi" { inherits = ["docker-metadata-action"] dockerfile = "dhi.Dockerfile" } + +target "compression" { + inherits = ["docker-metadata-action"] + dockerfile = "compression.Dockerfile" + platforms = ["linux/amd64", "linux/arm64"] +}