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"