From a0190aa0a422496e73ffb42d9bd28c9037e29623 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:33:52 +0200 Subject: [PATCH] accept JSON arrays for bake vars to preserve multiline values Parse the vars input as a JSON array of strings, falling back to the existing list parser when JSON parsing fails. Preserve multiline values, whitespace, and literal quotes in each Buildx --var argument. Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- README.md | 2 +- __tests__/context.test.ts | 74 +++++++++++++++++++++++++++++++++++++++ action.yml | 2 +- src/context.ts | 15 +++++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 903f8e3..43001c5 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ The following inputs can be used as `step.with` keys | `set` | List | List of [targets values to override](https://docs.docker.com/engine/reference/commandline/buildx_bake/#set) (e.g., `targetpattern.key=value`) | | `source` | String | Build source to use. Supports local path and [remote bake definition](https://docs.docker.com/build/bake/remote-definition/). With a local path, Bake runs from that directory, so all relative paths are resolved from it. See [Source semantics](#source-semantics). | | `targets` | List/CSV | List of bake targets (`default` target used if empty) | -| `vars` | List | [Variables](https://docs.docker.com/build/bake/variables/) to set in the Bake definition as list of key-value pair | +| `vars` | List/JSON | [Variables](https://docs.docker.com/build/bake/variables/) to set in the Bake definition as a list of key-value pairs or a JSON array of strings | | `github-token` | String | API token used to authenticate to a Git repository for [remote definitions](https://docs.docker.com/build/bake/remote-definition/) (default `${{ github.token }}`) | ### outputs diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index 31e4a9e..c494e39 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -87,6 +87,50 @@ describe('getInputs', () => { expect(gitContextSpy).toHaveBeenCalledTimes(1); gitContextSpy.mockRestore(); }); + + describe('vars', () => { + beforeEach(() => { + setRequiredBooleanInputs(); + setInput('source', '.'); + }); + + test('preserves line-based input parsing', async () => { + setInput('vars', 'TAGS=one,two\nJSON={"value":"a,b=c"}\nQUOTED="literal"\nEMPTY='); + expect((await context.getInputs()).vars).toEqual(['TAGS=one,two', 'JSON={"value":"a,b=c"}', 'QUOTED="literal"', 'EMPTY=']); + }); + + test('preserves JSON entries including multiline values and whitespace', async () => { + const vars = [ + 'DOCKER_METADATA_OUTPUT_TAGS=example/app:latest\nexample/app:main\n', + 'DOCKER_METADATA_OUTPUT_LABELS=version=1.2.3\r\ntitle=example\r\n', + 'DOCKER_METADATA_OUTPUT_JSON={"tags":["example/app:latest","example/app:main"]}', + 'PADDED= "one,two"=three ', + 'EMPTY=' + ]; + setInput('vars', ` \n${JSON.stringify(vars, null, 2)}\n `); + expect((await context.getInputs()).vars).toEqual(vars); + }); + + test('preserves duplicate JSON variable entries in order', async () => { + setInput('vars', JSON.stringify(['VERSION=default', 'OTHER=value', 'VERSION=override', 'VERSION=final'])); + expect((await context.getInputs()).vars).toEqual(['VERSION=default', 'OTHER=value', 'VERSION=override', 'VERSION=final']); + }); + + test('accepts an empty JSON array', async () => { + setInput('vars', '[]'); + expect((await context.getInputs()).vars).toEqual([]); + }); + + test('falls back to list parsing when JSON parsing fails', async () => { + setInput('vars', '["KEY=value"'); + expect((await context.getInputs()).vars).toEqual(['["KEY=value"']); + }); + + test.each(['null', 'true', '1', '"KEY=value"', '{"KEY":"value"}', '["KEY=value",1]', '[null]', '[["KEY=value"]]'])('rejects invalid JSON variable lists: %s', async value => { + setInput('vars', value); + await expect(context.getInputs()).rejects.toThrow('Invalid vars input: expected a JSON array of strings.'); + }); + }); }); describe('getArgs', () => { @@ -103,6 +147,36 @@ describe('getArgs', () => { process.env = originalEnv; }); + test('passes JSON variables as individual CLI arguments without changing their values', async () => { + const vars = ['DOCKER_METADATA_OUTPUT_TAGS=example/app:latest\nexample/app:main', 'PADDED= "one,two"=three ', 'EMPTY=']; + setInput('source', '.'); + setInput('no-cache', 'false'); + setInput('pull', 'false'); + setInput('load', 'false'); + setInput('push', 'false'); + setInput('provenance', 'false'); + setInput('vars', JSON.stringify(vars)); + vi.spyOn(Buildx.prototype, 'version').mockResolvedValue('0.31.0'); + const toolkit = new Toolkit(); + const inputs = await context.getInputs(); + const definition = await toolkit.buildxBake.getDefinition({vars: inputs.vars}); + expect(await context.getArgs(inputs, definition, toolkit)).toEqual(['bake', '--allow', 'fs=*', ...vars.flatMap(value => ['--var', value]), '--metadata-file', metadataJson, '--provenance', 'false']); + }); + + test('requires Buildx 0.31.0 for JSON variables', async () => { + setInput('source', '.'); + setInput('no-cache', 'false'); + setInput('pull', 'false'); + setInput('load', 'false'); + setInput('push', 'false'); + setInput('vars', '["KEY=value"]'); + vi.spyOn(Buildx.prototype, 'version').mockResolvedValue('0.30.0'); + const toolkit = new Toolkit(); + const inputs = await context.getInputs(); + const definition = await toolkit.buildxBake.getDefinition({vars: inputs.vars}); + await expect(context.getArgs(inputs, definition, toolkit)).rejects.toThrow('Buildx >= 0.31.0 is required to use the vars input.'); + }); + // prettier-ignore test.each([ [ diff --git a/action.yml b/action.yml index 5e2b3ca..e9bb5b8 100644 --- a/action.yml +++ b/action.yml @@ -51,7 +51,7 @@ inputs: description: "List of bake targets" required: false vars: - description: "Variables to set in the Bake definition as list of key-value pair" + description: "Variables to set in the Bake definition as a list of key-value pairs or a JSON array of strings" required: false github-token: description: "API token used to authenticate to a Git repository for remote definitions" diff --git a/src/context.ts b/src/context.ts index 5e156fb..dfda5c3 100644 --- a/src/context.ts +++ b/src/context.ts @@ -48,7 +48,7 @@ export async function getInputs(): Promise { set: Util.getInputList('set', {ignoreComma: true, quote: false}), source: await getBakeContext(core.getInput('source')), targets: Util.getInputList('targets'), - vars: Util.getInputList('vars', {ignoreComma: true, quote: false}), + vars: getBakeVars(core.getInput('vars')), 'github-token': core.getInput('github-token') }; } @@ -171,6 +171,19 @@ async function getBakeContext(sourceInput: string): Promise { }; } +function getBakeVars(varsInput: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(varsInput); + } catch { + return Util.getList(varsInput, {ignoreComma: true, quote: false}); + } + if (!Array.isArray(parsed) || !parsed.every(value => typeof value === 'string')) { + throw new Error('Invalid vars input: expected a JSON array of strings.'); + } + return parsed; +} + function noDefaultAttestations(): boolean { if (process.env.BUILDX_NO_DEFAULT_ATTESTATIONS) { return Util.parseBool(process.env.BUILDX_NO_DEFAULT_ATTESTATIONS);