From bc965e85c71cf42f241b75a3f300f6d2568899f0 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:00:34 +0100 Subject: [PATCH] github-builder: add shared exporter construction with compression Centralize image and local exporter settings for the build and bake workflows. Preserve existing defaults and support optional compression settings with CSV quoting for exporter values. Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/github-builder/build-output.test.ts | 74 +++++++++++++++++++ package.json | 1 + src/github-builder/build-output.ts | 65 ++++++++++++++++ yarn.lock | 8 ++ 4 files changed, 148 insertions(+) create mode 100644 __tests__/github-builder/build-output.test.ts create mode 100644 src/github-builder/build-output.ts diff --git a/__tests__/github-builder/build-output.test.ts b/__tests__/github-builder/build-output.test.ts new file mode 100644 index 00000000..752fe61e --- /dev/null +++ b/__tests__/github-builder/build-output.test.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2026 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, expect, it} from 'vitest'; +import {parse} from 'csv-parse/sync'; + +import {BuildOutput, BuildOutputOpts} from '../../src/github-builder/build-output.js'; + +describe('BuildOutput.resolve', () => { + it.each([false, true])('preserves image exporter policy with push=%s', push => { + expect(BuildOutput.resolve({output: 'image', images: ['user/app', 'ghcr.io/user/app'], push})).toBe(`type=image,"name=user/app,ghcr.io/user/app",oci-artifact=true,push-by-digest=true,name-canonical=true,push=${push}`); + }); + + it('omits compression settings by default and does not push', () => { + expect(BuildOutput.resolve({output: 'image', images: ['user/app']})).toBe('type=image,name=user/app,oci-artifact=true,push-by-digest=true,name-canonical=true,push=false'); + }); + + it.each(['uncompressed', 'gzip', 'estargz', 'zstd'] as const)('supports %s compression', compression => { + const output = BuildOutput.resolve({output: 'image', images: ['user/app'], compression}); + expect(parse(output)[0]).toContain(`compression=${compression}`); + }); + + it('supports zstd compression of existing layers', () => { + expect(BuildOutput.resolve({output: 'image', images: ['user/app'], push: true, compression: 'zstd', compressionLevel: 22, forceCompression: true})).toBe( + 'type=image,name=user/app,oci-artifact=true,push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=22,force-compression=true' + ); + }); + + it('preserves level zero and explicit false without requiring a compression algorithm', () => { + expect(BuildOutput.resolve({output: 'image', images: ['user/app'], compressionLevel: 0, forceCompression: false})).toBe( + 'type=image,name=user/app,oci-artifact=true,push-by-digest=true,name-canonical=true,push=false,compression-level=0,force-compression=false' + ); + }); + + it('preserves local exporter policy', () => { + expect(BuildOutput.resolve({output: 'local', localExportDir: '/tmp/build'})).toBe('type=local,platform-split=true,dest=/tmp/build'); + }); + + it('does not apply image compression options to local output', () => { + expect(BuildOutput.resolve({output: 'local', localExportDir: '/tmp/build', compression: 'zstd', compressionLevel: 22, forceCompression: true})).toBe('type=local,platform-split=true,dest=/tmp/build'); + }); + + it.each(['/tmp/build,output', '/tmp/"build"', '/tmp/build\noutput', '/tmp/build\routput'])('round-trips destination %j as a single CSV field', localExportDir => { + expect(parse(BuildOutput.resolve({output: 'local', localExportDir}))).toEqual([['type=local', 'platform-split=true', `dest=${localExportDir}`]]); + }); + + it('keeps quoted image values within the name field', () => { + const images = ['user/app', 'quoted"name,push=false']; + expect(parse(BuildOutput.resolve({output: 'image', images, push: true}))).toEqual([['type=image', `name=${images.join(',')}`, 'oci-artifact=true', 'push-by-digest=true', 'name-canonical=true', 'push=true']]); + }); + + it.each<{opts: BuildOutputOpts; error: string}>([ + {opts: {output: 'image'}, error: 'meta-images is required when output is image'}, + {opts: {output: 'image', images: []}, error: 'meta-images is required when output is image'}, + {opts: {output: 'local'}, error: 'localExportDir is required when output is local'}, + {opts: {output: 'local', localExportDir: ''}, error: 'localExportDir is required when output is local'}, + {opts: {output: 'docker'}, error: 'Invalid output: docker'} + ])('rejects $opts', ({opts, error}) => { + expect(() => BuildOutput.resolve(opts)).toThrow(error); + }); +}); diff --git a/package.json b/package.json index 22d54e06..34d7d890 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@sigstore/verify": "^4.1.2", "async-retry": "^1.3.3", "csv-parse": "^7.0.3", + "csv-stringify": "^6.9.0", "gunzip-maybe": "^1.4.2", "handlebars": "^4.7.10", "he": "^2.0.0", diff --git a/src/github-builder/build-output.ts b/src/github-builder/build-output.ts new file mode 100644 index 00000000..360eb016 --- /dev/null +++ b/src/github-builder/build-output.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2026 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {stringify} from 'csv-stringify/sync'; + +export interface BuildOutputOpts { + output: string; + images?: Array; + push?: boolean; + localExportDir?: string; + compression?: 'uncompressed' | 'gzip' | 'estargz' | 'zstd'; + compressionLevel?: number; + forceCompression?: boolean; +} + +export class BuildOutput { + /** + * Constructs the github-builder exporter for Buildx build or Bake. + * Bake callers prefix the result with "*.output=". Compression options only + * apply to image output; omitted options retain BuildKit's defaults. + * BuildKit validates compression levels for the selected algorithm. + */ + public static resolve(opts: BuildOutputOpts): string { + let fields: Array; + switch (opts.output) { + case 'image': + if (!opts.images?.length) { + throw new Error('meta-images is required when output is image'); + } + fields = ['type=image', `name=${opts.images.join(',')}`, 'oci-artifact=true', 'push-by-digest=true', 'name-canonical=true', `push=${opts.push ?? false}`]; + if (opts.compression) { + fields.push(`compression=${opts.compression}`); + } + if (opts.compressionLevel !== undefined) { + fields.push(`compression-level=${opts.compressionLevel}`); + } + if (opts.forceCompression !== undefined) { + fields.push(`force-compression=${opts.forceCompression}`); + } + break; + case 'local': + if (!opts.localExportDir) { + throw new Error('localExportDir is required when output is local'); + } + fields = ['type=local', 'platform-split=true', `dest=${opts.localExportDir}`]; + break; + default: + throw new Error(`Invalid output: ${opts.output}`); + } + return stringify([fields], {eof: false}); + } +} diff --git a/yarn.lock b/yarn.lock index 48cd3481..f5d1a6da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -496,6 +496,7 @@ __metadata: "@vitest/eslint-plugin": "npm:^1.6.9" async-retry: "npm:^1.3.3" csv-parse: "npm:^7.0.3" + csv-stringify: "npm:^6.9.0" eslint: "npm:^9.39.3" eslint-config-prettier: "npm:^10.1.8" eslint-plugin-prettier: "npm:^5.5.5" @@ -2388,6 +2389,13 @@ __metadata: languageName: node linkType: hard +"csv-stringify@npm:^6.9.0": + version: 6.9.0 + resolution: "csv-stringify@npm:6.9.0" + checksum: 10/af8bb8999ee023183fcec2b95693cb7fda90466e1961509936eda55886963e4e01ce9109fce7e22da4c2c42ebdaf8bcddf178974c5508e8e2ea12fe42670e524 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0": version: 4.3.1 resolution: "debug@npm:4.3.1"