Skip to content
Merged
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
74 changes: 74 additions & 0 deletions __tests__/github-builder/build-output.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions src/github-builder/build-output.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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<string>;
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});
}
}
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading